diff --git a/src/dashboard.html b/src/dashboard.html index c739d88..495e8f9 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -243,6 +243,28 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; .event-status { font-size: 0.75rem; font-weight: 600; } .event-status.on { color: #3fb950; } .event-status.off { color: #8b949e; } + +/* ── Card Detail Modal ── */ +.cd-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.82); z-index:300; align-items:center; justify-content:center; } +.cd-overlay.open { display:flex; } +.cd-box { background:#161b22; border:1px solid #30363d; border-radius:12px; padding:24px; max-width:520px; width:90%; max-height:88vh; overflow-y:auto; } +.cd-header { display:flex; align-items:flex-start; gap:16px; margin-bottom:16px; } +.cd-ovr { font-size:3rem; font-weight:800; color:#f5a623; line-height:1; min-width:64px; text-align:center; } +.cd-info { flex:1; } +.cd-name { font-size:1.2rem; font-weight:700; margin-bottom:2px; } +.cd-pos-badge { font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; color:#8b949e; } +.cd-meta { display:grid; grid-template-columns:1fr 1fr; gap:4px 16px; font-size:0.8rem; margin:10px 0; } +.cd-meta-row { display:flex; gap:4px; } +.cd-meta-lbl { color:#8b949e; } +.cd-stats { display:grid; grid-template-columns:1fr 1fr; gap:5px 20px; margin:12px 0; } +.cd-stat { display:flex; align-items:center; gap:6px; font-size:0.82rem; } +.cd-stat-lbl { width:30px; color:#8b949e; font-size:0.72rem; text-align:right; } +.cd-stat-val { width:26px; font-weight:700; font-size:0.88rem; } +.cd-stat-bar { flex:1; height:5px; background:#21262d; border-radius:3px; overflow:hidden; } +.cd-stat-fill { height:100%; border-radius:3px; background:#3fb950; } +.cd-stat-fill.med { background:#d29922; } +.cd-stat-fill.low { background:#f85149; } +.cd-actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; } @@ -272,6 +294,29 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; + +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading…
@@ -667,6 +715,18 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; + + +
Loading…
@@ -789,6 +849,64 @@ function quickSellValue(overall) { return 150; } +function showCardDetailById(ownedCardId) { + const item = allCards.find(c => c.owned_card_id === ownedCardId); + if (item) showCardDetail(item); +} + +function showCardDetail(item) { + const c = item.card; + const ovr = item.effective_overall ?? c.overall; + const pos = item.effective_position ?? c.position; + const sellCoins = quickSellValue(ovr); + + el('cd-rarity').className = `card-rarity ${rarityClass(c.rarity)}`; + el('cd-rarity').textContent = (c.rarity || '').toUpperCase(); + el('cd-ovr').textContent = ovr; + el('cd-pos-badge').textContent = pos; + el('cd-name').textContent = c.name; + + el('cd-meta').innerHTML = ` +
Club: ${c.club || '—'}
+
Nation: ${c.nation || '—'}
+
League: ${c.league || '—'}
+
Quick-sell: ${sellCoins.toLocaleString()}c
+ `; + + function cdBar(label, val) { + const v = val || 0; + const cls = v >= 75 ? '' : v >= 60 ? 'med' : 'low'; + return `
+ ${label} + ${v} +
+
`; + } + el('cd-stats').innerHTML = + cdBar('PAC', c.pace) + cdBar('SHO', c.shooting) + cdBar('PAS', c.passing) + + cdBar('DRI', c.dribbling) + cdBar('DEF', c.defending) + cdBar('PHY', c.physical); + + const upgrades = []; + if (item.chemistry_style && item.chemistry_style !== 'basic') + upgrades.push(`${item.chemistry_style}`); + if (item.training_bonus > 0) + upgrades.push(`Training +${item.training_bonus}`); + if (item.is_loan) + upgrades.push(`LOAN · ${item.loan_matches_remaining ?? '?'} left`); + el('cd-upgrades').innerHTML = upgrades.join(''); + + el('cd-actions').innerHTML = item.is_loan + ? `` + : ` + `; + + el('card-detail-overlay').classList.add('open'); +} + +function closeCardDetail() { + el('card-detail-overlay').classList.remove('open'); +} + async function quickSellCard(ownedCardId, coinValue, btn) { if (!confirm(`Quick-sell for ${coinValue.toLocaleString()} coins? This cannot be undone.`)) return; btn.disabled = true; @@ -817,9 +935,9 @@ function renderPlayerCard(item) { const sellCoins = quickSellValue(ovr); const sellBtn = item.is_loan ? `` - : ``; + : ``; - return `
+ return `
${(c.rarity||'').toUpperCase()}
${ovr}
${pos}
@@ -899,10 +1017,12 @@ function filterCards() { const club = el('f-club').value.toLowerCase(); const nation = el('f-nation').value.toLowerCase(); const sort = el('f-sort').value; + const loanOnly = el('f-loan-only')?.checked; let cards = allCards.filter(item => { const c = item.card; if (!c) return false; + if (loanOnly && !item.is_loan) return false; if (name && !c.name.toLowerCase().includes(name)) return false; if (pos && (item.effective_position || c.position) !== pos) return false; if (rarity && (c.rarity || '').toLowerCase() !== rarity) return false; @@ -2010,9 +2130,17 @@ async function loadMarketListings() { function filterMarket() { const q = (el('m-search')?.value || '').toLowerCase(); - const filtered = q - ? allMarketListings.filter(l => l.card && l.card.name.toLowerCase().includes(q)) - : allMarketListings; + const pos = el('m-pos')?.value || ''; + const minP = parseInt(el('m-min-price')?.value) || 0; + const maxP = parseInt(el('m-max-price')?.value) || Infinity; + const filtered = allMarketListings.filter(l => { + if (!l.card) return false; + if (q && !l.card.name.toLowerCase().includes(q)) return false; + if (pos && l.card.position !== pos) return false; + const price = l.listing?.buy_now_price || l.buy_now_price || 0; + if (price < minP || price > maxP) return false; + return true; + }); if (!filtered.length) { el('market-grid').innerHTML = 'No listings found. Click "Refresh Listings" to populate the market.'; diff --git a/src/mapper.rs b/src/mapper.rs index 7e01c38..51298d2 100644 --- a/src/mapper.rs +++ b/src/mapper.rs @@ -236,6 +236,116 @@ const EXACT: &[ExactRoute] = &[ core_method: "GET", core_path: "/notifications", notes: "FUT notifications (plural form) → Core notifications", }, + // ── Squad list ──────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/squad/list", + core_method: "GET", core_path: "/squads", + notes: "FUT squad list → Core all squads", + }, + ExactRoute { + ea_method: "DELETE", ea_path: "/ut/game/fut/squad/active", + core_method: "DELETE", core_path: "/squad", + notes: "FUT delete active squad → Core delete squad (best-effort)", + }, + // ── Achievements / Trophies ─────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/trophies", + core_method: "GET", core_path: "/achievements", + notes: "FUT trophies → Core achievements", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/trophy", + core_method: "GET", core_path: "/achievements", + notes: "FUT trophy (singular) → Core achievements", + }, + // ── Rivals extra endpoints ──────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/rivals/rank", + core_method: "GET", core_path: "/division", + notes: "FUT rivals rank → Core division", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/rivals/result", + core_method: "POST", core_path: "/matches/result", + notes: "FUT rivals match result → Core match result", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/rivals/leaderboard", + core_method: "GET", core_path: "/statistics", + notes: "FUT rivals leaderboard → Core statistics (offline approximation)", + }, + // ── Objectives sub-groups ───────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/objectives/group", + core_method: "GET", core_path: "/objectives", + notes: "FUT objectives group → Core objectives", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/objectives/daily", + core_method: "GET", core_path: "/objectives", + notes: "FUT daily objectives → Core objectives", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/objectives/weekly", + core_method: "GET", core_path: "/objectives", + notes: "FUT weekly objectives → Core objectives", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/objectives/group/claim", + core_method: "POST", core_path: "/objectives/claim", + notes: "FUT objectives group claim → Core objectives claim", + }, + // ── Catalogue / Card search ─────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/catalogue/item", + core_method: "GET", core_path: "/cards", + notes: "FUT catalogue items → Core card catalogue", + }, + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/catalogue", + core_method: "GET", core_path: "/cards", + notes: "FUT catalogue → Core card catalogue", + }, + // ── Store / Pricing ─────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/store/pricetiers", + core_method: "GET", core_path: "/packs/store", + notes: "FUT price tiers → Core pack store definitions", + }, + // ── Consumables / Chemistry / Fitness ───────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/consumables", + core_method: "GET", core_path: "/chemistry-styles", + notes: "FUT consumables → Core chemistry styles (approximation)", + }, + ExactRoute { + ea_method: "POST", ea_path: "/ut/game/fut/fitness", + core_method: "GET", core_path: "/club", + notes: "FUT fitness apply → Core club (placeholder, fitness not tracked)", + }, + // ── Loan items ──────────────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/loanitems", + core_method: "GET", core_path: "/collection", + notes: "FUT loan items → Core collection (client filters is_loan)", + }, + // ── Customization / Kit ─────────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/customization", + core_method: "GET", core_path: "/settings", + notes: "FUT customization → Core settings", + }, + ExactRoute { + ea_method: "PUT", ea_path: "/ut/game/fut/customization", + core_method: "PUT", core_path: "/settings", + notes: "FUT save customization → Core save settings", + }, + // ── Active messages / MOTD ──────────────────────────────────────────────── + ExactRoute { + ea_method: "GET", ea_path: "/ut/game/fut/activeMessage", + core_method: "GET", core_path: "/notifications", + notes: "FUT active messages → Core notifications (mapped to nearest equivalent)", + }, ]; // ── Prefix mappings (dynamic path segments after a known prefix) ─────────────── @@ -300,6 +410,44 @@ fn prefix_routes() -> &'static [PrefixRoute] { core_path_fn: |suffix| format!("/collection/{suffix}"), notes: "FUT quick-sell item → Core delete owned card", }, + // /ut/game/fut/item/{owned_id} (PUT) → PUT /collection/{owned_id}/chemistry + PrefixRoute { + ea_method: "PUT", + ea_prefix: "/ut/game/fut/item/", + core_path_fn: |suffix| { + // suffix is just the owned_card_id; game sends chemistry style in body + format!("/collection/{suffix}/chemistry") + }, + notes: "FUT apply chemistry style → Core update card chemistry", + }, + // /ut/game/fut/squad/{id} (GET) → GET /squads/{id} + // Note: must come after exact matches for /squad/active, /squad/0, etc. + PrefixRoute { + ea_method: "GET", + ea_prefix: "/ut/game/fut/squad/", + core_path_fn: |suffix| { + // Skip known non-ID suffixes already handled as exact routes + match suffix { + "active" | "chemistry" | "list" => "/squad".to_string(), + _ => format!("/squads/{suffix}"), + } + }, + notes: "FUT squad by ID → Core squad by ID", + }, + // /ut/game/fut/squad/{id} (DELETE) → DELETE /squads/{id} + PrefixRoute { + ea_method: "DELETE", + ea_prefix: "/ut/game/fut/squad/", + core_path_fn: |suffix| format!("/squads/{suffix}"), + notes: "FUT delete squad by ID → Core delete squad", + }, + // /ut/game/fut/sbc/set/{set_id} (GET) → GET /sbc/{set_id} + PrefixRoute { + ea_method: "GET", + ea_prefix: "/ut/game/fut/sbc/set/", + core_path_fn: |suffix| format!("/sbc/{suffix}"), + notes: "FUT SBC set details → Core individual SBC", + }, // /ut/game/fut/store/pack/{pack_id}/open → POST /packs/open/{pack_id} PrefixRoute { ea_method: "POST", @@ -549,7 +697,77 @@ mod tests { #[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()); + assert!(EXACT.len() >= 55, "expected at least 55 exact mappings, got {}", EXACT.len()); + } + + // ── Phase 22 new mappings ───────────────────────────────────────────────── + + #[test] + fn test_squad_list_maps() { + let m = map_to_core("GET", "/ut/game/fut/squad/list"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/squads"); + } + + #[test] + fn test_squad_by_id_get_maps() { + let m = map_to_core_with_method("GET", "/ut/game/fut/squad/abc-squad-id"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/squads/abc-squad-id"); + } + + #[test] + fn test_squad_by_id_delete_maps() { + let m = map_to_core_with_method("DELETE", "/ut/game/fut/squad/abc-squad-id"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/squads/abc-squad-id"); + } + + #[test] + fn test_item_put_chemistry_maps() { + let m = map_to_core_with_method("PUT", "/ut/game/fut/item/owned-999"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/collection/owned-999/chemistry"); + } + + #[test] + fn test_sbc_set_get_maps() { + let m = map_to_core_with_method("GET", "/ut/game/fut/sbc/set/sbc-123"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/sbc/sbc-123"); + } + + #[test] + fn test_trophies_maps() { + let m = map_to_core("GET", "/ut/game/fut/trophies"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/achievements"); + } + + #[test] + fn test_rivals_result_maps() { + let m = map_to_core("POST", "/ut/game/fut/rivals/result"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/matches/result"); + } + + #[test] + fn test_catalogue_item_maps() { + let m = map_to_core("GET", "/ut/game/fut/catalogue/item"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/cards"); + } + + #[test] + fn test_objectives_daily_maps() { + let m = map_to_core("GET", "/ut/game/fut/objectives/daily"); + assert!(m.is_some()); + assert_eq!(m.unwrap().core_path, "/objectives"); + } + + #[test] + fn test_active_message_maps() { + let m = map_to_core("GET", "/ut/game/fut/activeMessage"); + assert!(m.is_some()); } } diff --git a/src/shaper.rs b/src/shaper.rs index afd38b3..7d4c21b 100644 --- a/src/shaper.rs +++ b/src/shaper.rs @@ -28,6 +28,9 @@ pub fn shape_response(core_path: &str, core_response: Value) -> Value { "/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), + "/achievements" => return shape_achievements_response(core_response), + "/squads" => return shape_squads_list_response(core_response), + "/packs/store" => return shape_pack_store_response(core_response), _ => {} } @@ -41,6 +44,9 @@ pub fn shape_response(core_path: &str, core_response: Value) -> Value { if core_path.starts_with("/fut-champs/") { return shape_fut_champs_response(core_response); } + if core_path.starts_with("/squads/") { + return shape_squads_list_response(core_response); + } // Unknown — pass through unchanged core_response @@ -290,6 +296,40 @@ fn shape_chemistry_styles_response(core: Value) -> Value { }) } +fn shape_achievements_response(core: Value) -> Value { + // FIFA 23 uses a "trophies" envelope with "trophyData" array + let achievements = core.get("achievements").cloned().unwrap_or(json!([])); + let earned = core.get("earned").and_then(|v| v.as_i64()).unwrap_or(0); + let total = core.get("total").and_then(|v| v.as_i64()).unwrap_or(0); + json!({ + "trophyData": achievements, + "totalEarned": earned, + "totalAvailable": total, + "openfut": true, + }) +} + +fn shape_squads_list_response(core: Value) -> Value { + // Wrap squads in EA's squad list envelope + let squads = if core.is_array() { + core + } else { + core.get("squads").cloned().unwrap_or(json!([])) + }; + json!({ + "squads": squads, + "openfut": true, + }) +} + +fn shape_pack_store_response(core: Value) -> Value { + let packs = core.get("packs").cloned().unwrap_or(json!([])); + json!({ + "purchasablePacks": packs, + "openfut": true, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -328,4 +368,28 @@ mod tests { assert!(shaped["auctionInfo"].as_array().is_some()); assert_eq!(shaped["total"], 1); } + + #[test] + fn test_shape_achievements_response() { + let core = json!({ "achievements": [{ "id": "first_match" }], "earned": 1, "total": 18 }); + let shaped = shape_response("/achievements", core); + assert!(shaped["trophyData"].is_array()); + assert_eq!(shaped["totalEarned"], 1); + assert_eq!(shaped["totalAvailable"], 18); + } + + #[test] + fn test_shape_squads_list_response() { + let core = json!({ "squads": [{ "id": "sq-1", "name": "My Squad" }] }); + let shaped = shape_response("/squads", core); + assert!(shaped["squads"].is_array()); + assert_eq!(shaped["squads"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_shape_pack_store_response() { + let core = json!({ "packs": [{ "id": "gold-pack" }] }); + let shaped = shape_response("/packs/store", core); + assert!(shaped["purchasablePacks"].is_array()); + } }