diff --git a/backend/analytics/core.py b/backend/analytics/core.py index 969c6d3e..f20af200 100644 --- a/backend/analytics/core.py +++ b/backend/analytics/core.py @@ -43,6 +43,7 @@ def aggregate_card_stats( type_filter = filters.get("upgrade_type") # For upgrades text_filter = filters.get("search_text", "").lower() ship_filter = filters.get("ship") + include_epic = filters.get("include_epic", False) initiative_filter = filters.get("initiative") # New Context Filters @@ -154,9 +155,6 @@ def _int_or(val, fallback): if data_source == DataSource.XWA: if p_loadout < loadout_min or p_loadout > loadout_max: continue - - - include_epic_content = filters.get("include_epic", False) # Strict Format Visibility Filter is_legal = p_info.get("valid_in_standard", False) @@ -193,6 +191,10 @@ def _int_or(val, fallback): show_card = True elif data_source == DataSource.LEGACY: show_card = True + + # Epic Content Toggle: if true, show all epic pilots + if include_epic and is_epic: + show_card = True if not show_card: continue @@ -410,6 +412,10 @@ def _int_or(val, fallback): show_card = True elif data_source == DataSource.LEGACY: show_card = True + + # Epic Content Toggle + if include_epic and is_epic: + show_card = True if not show_card: continue diff --git a/backend/analytics/factions.py b/backend/analytics/factions.py index 5761bb5b..9a8422b5 100644 --- a/backend/analytics/factions.py +++ b/backend/analytics/factions.py @@ -110,7 +110,7 @@ def aggregate_faction_stats( results.sort(key=lambda x: x["popularity"], reverse=True) return results -def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: list[str] | None = None) -> dict: +def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: list[str] | None = None, include_epic: bool = False) -> dict: """ Get a high-level summary of the current meta (last 90 days). """ @@ -123,6 +123,7 @@ def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: filters = { "date_start": date_str, + "include_epic": include_epic } if allowed_formats: filters["allowed_formats"] = get_active_formats(allowed_formats) diff --git a/backend/analytics/lists.py b/backend/analytics/lists.py index eee2e1df..c6cd9506 100644 --- a/backend/analytics/lists.py +++ b/backend/analytics/lists.py @@ -8,6 +8,7 @@ from ..data_structures.factions import Faction, get_faction_char from ..data_structures.data_source import DataSource from .filters import filter_query, get_active_formats, apply_tournament_filters +from ..utils.xwing_data.pilots import load_all_pilots import json def aggregate_list_stats( @@ -30,7 +31,11 @@ def aggregate_list_stats( # A more robust solution would use a canonical list hash. list_stats = {} - + + # Epic filter: exclude lists containing any epic-only pilot + include_epic = filters.get("include_epic", False) + all_pilots = load_all_pilots(data_source) + for result, tournament in rows: # Format filter optimization t_fmt_raw = tournament.format @@ -50,6 +55,24 @@ def aggregate_list_stats( pilots = xws.get("pilots", []) if not pilots: continue + + # Epic-only list exclusion: skip entire list if ANY pilot is epic-only + if not include_epic: + has_epic_only = False + for p in pilots: + pid = p.get("id") or p.get("name") + if not pid: + continue + p_info = all_pilots.get(pid, {}) + is_epic = p_info.get("epic", False) + is_legal = p_info.get("valid_in_standard", False) + is_wild = p_info.get("wildspace", False) + if is_epic and not is_legal and not is_wild: + has_epic_only = True + break + if has_epic_only: + continue + req_ships = filters.get("ships") if req_ships: diff --git a/backend/analytics/ships.py b/backend/analytics/ships.py index e7e6e7cb..6ff45b07 100644 --- a/backend/analytics/ships.py +++ b/backend/analytics/ships.py @@ -53,6 +53,7 @@ def aggregate_ship_stats( allowed_formats = filters.get("allowed_formats") or None + include_epic = filters.get("include_epic", False) allowed_date_start = filters.get("date_start") or None allowed_date_end = filters.get("date_end") or None @@ -73,6 +74,15 @@ def aggregate_ship_stats( if not ship_xws or not faction: continue + + # Epic-only exclusion: skip pilots playable ONLY in Epic + is_epic = p_info.get("epic", False) + is_legal = p_info.get("valid_in_standard", False) + is_wild = p_info.get("wildspace", False) + + if not include_epic: + if is_epic and not is_legal and not is_wild: + continue # Normalize faction to xws format try: @@ -174,8 +184,16 @@ def aggregate_ship_stats( # Fallback visibility if no formats selected show_pilot = is_legal or is_wild + # Epic Content Toggle + if include_epic and is_epic: + show_pilot = True + if not show_pilot: continue + + # Epic-only exclusion: skip pilots playable ONLY in Epic + if not include_epic and is_epic and not is_legal and not is_wild: + continue key = (ship_xws, faction_xws) if key in ship_stats: diff --git a/backend/api/cards.py b/backend/api/cards.py index b9fc6fcf..97ddae49 100644 --- a/backend/api/cards.py +++ b/backend/api/cards.py @@ -40,6 +40,7 @@ def _build_filters( date_end: Optional[str] = None, player_count_min: Optional[int] = None, player_count_max: Optional[int] = None, + include_epic: bool = False, ) -> dict: # Base sizes mapping @@ -81,7 +82,7 @@ def _build_filters( "date_end": date_end, "player_count_min": player_count_min, "player_count_max": player_count_max, - "include_epic": False + "include_epic": include_epic } @@ -124,6 +125,7 @@ def get_pilots( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -151,7 +153,8 @@ def get_pilots( is_unique=is_unique, is_limited=is_limited, is_not_limited=is_not_limited, base_sizes=base_sizes, platforms=platforms, continent=continent, country=country, city=city, date_start=date_start, date_end=date_end, - player_count_min=player_count_min, player_count_max=player_count_max + player_count_min=player_count_min, player_count_max=player_count_max, + include_epic=include_epic ) data = aggregate_card_stats(filters, criteria, s_dir, "pilots", ds_enum) @@ -183,6 +186,7 @@ def get_upgrades( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -204,7 +208,8 @@ def get_upgrades( search_text=search_text, points_min=points_min, points_max=points_max, platforms=platforms, continent=continent, country=country, city=city, date_start=date_start, date_end=date_end, - player_count_min=player_count_min, player_count_max=player_count_max + player_count_min=player_count_min, player_count_max=player_count_max, + include_epic=include_epic ) data = aggregate_card_stats(filters, criteria, s_dir, "upgrades", ds_enum) diff --git a/backend/api/lists.py b/backend/api/lists.py index 1b1be729..2fb2c4e4 100644 --- a/backend/api/lists.py +++ b/backend/api/lists.py @@ -29,6 +29,7 @@ def get_lists( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -44,6 +45,7 @@ def get_lists( "date_end": date_end, "player_count_min": player_count_min, "player_count_max": player_count_max, + "include_epic": include_epic, "ships": ships, } if formats: diff --git a/backend/api/pilot_detail.py b/backend/api/pilot_detail.py index a5c0287a..33111699 100644 --- a/backend/api/pilot_detail.py +++ b/backend/api/pilot_detail.py @@ -43,6 +43,7 @@ def get_pilot_upgrades( formats: list[str] | None = Query(None), search_text: str = Query(""), upgrade_types: list[str] | None = Query(None), + include_epic: bool = Query(False), ): """Return upgrade stats filtered to this pilot's lists.""" ds = DataSource(data_source) if data_source in ("xwa", "legacy") else DataSource.XWA @@ -54,7 +55,7 @@ def get_pilot_upgrades( "search_text": search_text, "upgrade_type": upgrade_types or [], "pilot_id": pilot_xws, - "include_epic": False, + "include_epic": include_epic, } data = aggregate_card_stats(filters, criteria, direction, "upgrades", ds) total = len(data) @@ -69,11 +70,12 @@ def get_pilot_chart( data_source: str = Query("xwa"), formats: list[str] | None = Query(None), comparison: list[str] | None = Query(None), + include_epic: bool = Query(False), ): """Return monthly usage history for the pilot and optional comparisons.""" filters = { "allowed_formats": formats, - "include_epic": False, + "include_epic": include_epic, } chart_data = get_card_usage_history( filters, diff --git a/backend/api/ship_detail.py b/backend/api/ship_detail.py index 35aebad9..4298c80c 100644 --- a/backend/api/ship_detail.py +++ b/backend/api/ship_detail.py @@ -45,6 +45,7 @@ def get_ship_pilots( data_source: str = Query("xwa"), sort_metric: str = Query("Popularity"), sort_direction: str = Query("desc"), + include_epic: bool = Query(False), ): """Return pilot stats filtered to this ship.""" ds = DataSource(data_source) if data_source in ("xwa", "legacy") else DataSource.XWA @@ -58,7 +59,7 @@ def get_ship_pilots( filters = { "ship": [ship_xws], - "include_epic": False, + "include_epic": include_epic, } data = aggregate_card_stats(filters, criteria, direction, "pilots", ds) return {"pilots": data} diff --git a/backend/api/ships.py b/backend/api/ships.py index 75555244..5fb8fada 100644 --- a/backend/api/ships.py +++ b/backend/api/ships.py @@ -50,6 +50,7 @@ def get_ships( date_end: Optional[str] = Query(None), player_count_min: Optional[int] = Query(None), player_count_max: Optional[int] = Query(None), + include_epic: bool = Query(False), ): try: ds_enum = DataSource(data_source) @@ -77,6 +78,7 @@ def get_ships( "date_end": date_end, "player_count_min": player_count_min, "player_count_max": player_count_max, + "include_epic": include_epic, } data = aggregate_ship_stats(filters, criteria, s_dir, ds_enum) diff --git a/backend/main.py b/backend/main.py index 76ff186c..8e74c81a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -52,11 +52,14 @@ def read_root(): @app.get("/api/meta-snapshot", response_model=MetaSnapshotResponse) -def get_snapshot(data_source: str = Query("xwa", description="Data source: xwa or legacy")): +def get_snapshot( + data_source: str = Query("xwa", description="Data source: xwa or legacy"), + include_epic: bool = Query(False, description="Include epic content") +): ds_enum = DataSource.XWA if data_source == "xwa" else DataSource.LEGACY # We parse what HomeState loaded - snapshot = get_meta_snapshot(ds_enum, allowed_formats=None) + snapshot = get_meta_snapshot(ds_enum, allowed_formats=None, include_epic=include_epic) raw_lists = snapshot.get("lists", []) enriched_lists = [enrich_list_data(l) for l in raw_lists] diff --git a/frontend/src/lib/api/ships.ts b/frontend/src/lib/api/ships.ts index 40ef77e4..ca8c63ce 100644 --- a/frontend/src/lib/api/ships.ts +++ b/frontend/src/lib/api/ships.ts @@ -6,9 +6,9 @@ export interface ShipChassis { factions: string[]; } -export async function fetchAllShips(dataSource: string): Promise { +export async function fetchAllShips(dataSource: string, includeEpic: boolean = false): Promise { try { - const response = await fetch(`${API_BASE}/ships/all?data_source=${dataSource}`); + const response = await fetch(`${API_BASE}/ships/all?data_source=${dataSource}&include_epic=${includeEpic}`); if (!response.ok) throw new Error('Failed to fetch ships'); return await response.json(); } catch (e) { diff --git a/frontend/src/lib/components/AdvancedFilters.svelte b/frontend/src/lib/components/AdvancedFilters.svelte index 9d025748..888b4f47 100644 --- a/frontend/src/lib/components/AdvancedFilters.svelte +++ b/frontend/src/lib/components/AdvancedFilters.svelte @@ -81,7 +81,7 @@ > Unique @@ -91,7 +91,7 @@ > Limited @@ -101,7 +101,7 @@ > Generic @@ -109,6 +109,27 @@ + +
+ Game Content +
+ +
+
+ {#if isPilotsTab}
diff --git a/frontend/src/lib/components/ShipChassisFilter.svelte b/frontend/src/lib/components/ShipChassisFilter.svelte index b6d37a27..cce26ba0 100644 --- a/frontend/src/lib/components/ShipChassisFilter.svelte +++ b/frontend/src/lib/components/ShipChassisFilter.svelte @@ -39,16 +39,18 @@ // Initial load onMount(async () => { isLoading = true; - ships = await fetchAllShips(filters.dataSource); + ships = await fetchAllShips(filters.dataSource, filters.includeEpic); isLoading = false; }); - // Re-fetch when data source changes + // Re-fetch when data source or includeEpic changes let currentDataSource = $state(filters.dataSource); + let currentIncludeEpic = $state(filters.includeEpic); $effect(() => { - if (currentDataSource !== filters.dataSource) { + if (currentDataSource !== filters.dataSource || currentIncludeEpic !== filters.includeEpic) { currentDataSource = filters.dataSource; - fetchAllShips(filters.dataSource).then((data) => (ships = data)); + currentIncludeEpic = filters.includeEpic; + fetchAllShips(filters.dataSource, filters.includeEpic).then((data) => (ships = data)); } }); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 81994791..f103aee9 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -17,7 +17,7 @@ error = false; errorMsg = ""; - const targetUrl = `/api/meta-snapshot?data_source=${source}`; + const targetUrl = `/api/meta-snapshot?data_source=${source}&include_epic=${filters.includeEpic}`; fetch(targetUrl) .then(async (res) => { if (!res.ok) { diff --git a/frontend/src/routes/api/meta-snapshot/+server.ts b/frontend/src/routes/api/meta-snapshot/+server.ts index e4c55a6e..8e66ecd9 100644 --- a/frontend/src/routes/api/meta-snapshot/+server.ts +++ b/frontend/src/routes/api/meta-snapshot/+server.ts @@ -3,8 +3,9 @@ import { API_BASE } from '$lib/api'; export async function GET({ url, fetch }) { const source = url.searchParams.get('data_source') || 'xwa'; + const includeEpic = url.searchParams.get('include_epic') || 'false'; try { - const res = await fetch(`${API_BASE}/meta-snapshot?data_source=${source}`); + const res = await fetch(`${API_BASE}/meta-snapshot?data_source=${source}&include_epic=${includeEpic}`); if (!res.ok) { throw new Error(`Backend error: ${res.status}`); } diff --git a/frontend/src/routes/cards/+page.svelte b/frontend/src/routes/cards/+page.svelte index 6383692a..53c849b6 100644 --- a/frontend/src/routes/cards/+page.svelte +++ b/frontend/src/routes/cards/+page.svelte @@ -21,8 +21,16 @@ let textSearch = $state(""); let selectedFactions = $state([]); let factionOpen = $state(false); + let isAdvanced = $state(false); const size = 20; + // Initialize store from URL if present + $effect.pre(() => { + if (data.includeEpic !== undefined) { + filters.includeEpic = data.includeEpic; + } + }); + let items = $derived(data.items ?? []); let total = $derived(data.total ?? 0); let isXwa = $derived(filters.dataSource === "xwa"); @@ -51,6 +59,8 @@ for (const c of filters.selectedCities) params.append("city", c); if (filters.dateStart) params.set("date_start", filters.dateStart); if (filters.dateEnd) params.set("date_end", filters.dateEnd); + params.set("include_epic", String(filters.includeEpic)); + // Advanced Filters if (filters.pointsMin) params.set("points_min", filters.pointsMin); diff --git a/frontend/src/routes/cards/+page.ts b/frontend/src/routes/cards/+page.ts index 5bb684bf..fd35cf4e 100644 --- a/frontend/src/routes/cards/+page.ts +++ b/frontend/src/routes/cards/+page.ts @@ -18,9 +18,16 @@ export const load: PageLoad = async ({ fetch, url }) => { const response = await fetch(apiUrl.toString()); if (!response.ok) throw new Error('Failed to fetch cards'); const data = await response.json(); - return { items: data.items, total: data.total, page: parseInt(data.page), size: parseInt(data.size), tab }; + return { + items: data.items, + total: data.total, + page: parseInt(data.page), + size: parseInt(data.size), + tab, + includeEpic: url.searchParams.get('include_epic') === 'true' + }; } catch (e) { console.error(e); - return { items: [], total: 0, page: 0, size: 20, tab }; + return { items: [], total: 0, page: 0, size: 20, tab, includeEpic: false }; } }; diff --git a/frontend/src/routes/lists/+page.svelte b/frontend/src/routes/lists/+page.svelte index 41edb266..e8b95d2a 100644 --- a/frontend/src/routes/lists/+page.svelte +++ b/frontend/src/routes/lists/+page.svelte @@ -15,13 +15,21 @@ let { data } = $props(); let page = $state(1); - let sortBy = $state("Games"); + const size = 20; + + // Initialize store from URL if present + $effect.pre(() => { + if (data.includeEpic !== undefined) { + filters.includeEpic = data.includeEpic; + } + }); + + let sortBy = $state(data.sort_metric || "Popularity"); let sortDirection = $state("desc"); let selectedFactions = $state([]); let factionOpen = $state(false); let minGames = $state(3); - const size = 20; let items = $derived(data.items ?? []); let total = $derived(data.total ?? 0); @@ -47,6 +55,8 @@ for (const c of filters.selectedCities) params.append("city", c); if (filters.dateStart) params.set("date_start", filters.dateStart); if (filters.dateEnd) params.set("date_end", filters.dateEnd); + params.set("include_epic", String(filters.includeEpic)); + goto(`?${params.toString()}`, { keepFocus: true, noScroll: true, diff --git a/frontend/src/routes/lists/+page.ts b/frontend/src/routes/lists/+page.ts index fb5b3b67..37f92145 100644 --- a/frontend/src/routes/lists/+page.ts +++ b/frontend/src/routes/lists/+page.ts @@ -18,9 +18,17 @@ export const load: PageLoad = async ({ fetch, url }) => { const response = await fetch(apiUrl.toString()); if (!response.ok) throw new Error('Failed to fetch lists'); const data = await response.json(); - return { items: data.items, total: data.total, page: parseInt(data.page), size: parseInt(data.size), sort_metric, sort_direction }; + return { + items: data.items, + total: data.total, + page: parseInt(data.page), + size: parseInt(data.size), + sort_metric, + sort_direction, + includeEpic: url.searchParams.get('include_epic') === 'true' + }; } catch (e) { console.error(e); - return { items: [], total: 0, page: 0, size: 20, sort_metric, sort_direction }; + return { items: [], total: 0, page: 0, size: 20, sort_metric, sort_direction, includeEpic: false }; } }; diff --git a/frontend/src/routes/pilot/[id]/+page.ts b/frontend/src/routes/pilot/[id]/+page.ts index 23eac507..cfd4404d 100644 --- a/frontend/src/routes/pilot/[id]/+page.ts +++ b/frontend/src/routes/pilot/[id]/+page.ts @@ -5,13 +5,14 @@ export const load: PageLoad = async ({ fetch, params, url }) => { url.search; // Force reactivity const pilotXws = params.id; const ds = url.searchParams.get('data_source') || 'xwa'; + const epic = url.searchParams.get('include_epic') || 'false'; // Fetch all 4 endpoints in parallel const [infoRes, upgradesRes, chartRes, configRes] = await Promise.allSettled([ - fetch(`${API_BASE}/pilot/${pilotXws}?data_source=${ds}`), - fetch(`${API_BASE}/pilot/${pilotXws}/upgrades?data_source=${ds}&size=50`), - fetch(`${API_BASE}/pilot/${pilotXws}/chart?data_source=${ds}`), - fetch(`${API_BASE}/pilot/${pilotXws}/configurations?data_source=${ds}&limit=10`), + fetch(`${API_BASE}/pilot/${pilotXws}?data_source=${ds}&include_epic=${epic}`), + fetch(`${API_BASE}/pilot/${pilotXws}/upgrades?data_source=${ds}&include_epic=${epic}&size=50`), + fetch(`${API_BASE}/pilot/${pilotXws}/chart?data_source=${ds}&include_epic=${epic}`), + fetch(`${API_BASE}/pilot/${pilotXws}/configurations?data_source=${ds}&include_epic=${epic}&limit=10`), ]); const info = infoRes.status === 'fulfilled' && infoRes.value.ok diff --git a/frontend/src/routes/ships/+page.svelte b/frontend/src/routes/ships/+page.svelte index 4af4c2de..5e8292d5 100644 --- a/frontend/src/routes/ships/+page.svelte +++ b/frontend/src/routes/ships/+page.svelte @@ -13,10 +13,18 @@ import { filters } from "$lib/stores/filters.svelte"; let { data } = $props(); + let page = $state(1); + const size = 50; + + // Initialize store from URL if present + $effect.pre(() => { + if (data.includeEpic !== undefined) { + filters.includeEpic = data.includeEpic; + } + }); let items = $derived(data.items ?? []); let total = $derived(data.total ?? 0); - let page = $state(1); let sortBy = $state("Popularity"); let sortDirection = $state("desc"); let selectedFactions = $state([]); @@ -43,6 +51,8 @@ for (const c of filters.selectedCities) params.append("city", c); if (filters.dateStart) params.set("date_start", filters.dateStart); if (filters.dateEnd) params.set("date_end", filters.dateEnd); + params.set("include_epic", String(filters.includeEpic)); + goto(`?${params.toString()}`, { keepFocus: true, diff --git a/frontend/src/routes/ships/+page.ts b/frontend/src/routes/ships/+page.ts index bf5304c9..55bb73df 100644 --- a/frontend/src/routes/ships/+page.ts +++ b/frontend/src/routes/ships/+page.ts @@ -15,9 +15,15 @@ export const load: PageLoad = async ({ fetch, url }) => { const response = await fetch(apiUrl.toString()); if (!response.ok) throw new Error('Failed to fetch ships'); const data = await response.json(); - return { items: data.items, total: data.total, page: parseInt(data.page), size: parseInt(data.size) }; + return { + items: data.items, + total: data.total, + page: parseInt(data.page), + size: parseInt(data.size), + includeEpic: url.searchParams.get('include_epic') === 'true' + }; } catch (e) { console.error(e); - return { items: [], total: 0, page: 0, size: 50 }; + return { items: [], total: 0, page: 0, size: 50, includeEpic: false }; } };