Skip to content
39 changes: 39 additions & 0 deletions MISSION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Mission: FactionStat Refactor & Meta-Diversity Analytics

## Intent
The primary goal of this task is to evolve the `FactionStat` data structure from a heavy, redundant metadata container into a lean, analytics-focused schema. We want to separate **static metadata** (labels, icons) from **dynamic statistics** (wins, list counts) and introduce new metrics to measure meta-diversity.

## Rationale

### 1. Separation of Concerns (Logic vs. Presentation)
Currently, the backend calculates and sends strings like `name` ("Rebel Alliance") and `icon_char` ("!").
- **Why it's a problem:** These are static constants. If we want to change a faction color or icon, we shouldn't have to touch the backend analytics logic.
- **The Fix:** Move metadata lookup to the frontend. The backend only sends the `xws` identifier (the "source of truth").

### 2. Payload Optimization
By removing redundant strings and pre-calculated win rates from every faction entry, we reduce the API response size. While small for factions, this pattern is critical as the codebase scales to ships, pilots, and upgrades.

### 3. Introducing Meta-Diversity Metrics
`popularity` (renamed to `lists`) tells us how many people are playing a faction. But it doesn't tell us *how* they are playing.
- **New Metric: `different_lists`**: This counts unique list signatures.
- **The Value:** Comparison between `lists` and `different_lists` reveals meta health.
- *High Lists / Low Different Lists:* The faction is popular but "solved" or "stale" (everyone plays the same thing).
- *High Lists / High Different Lists:* The faction is popular and "diverse" (multiple viable archetypes).

### 4. Logic Unification (List Signatures)
We currently have disparate ways of identifying a "squadron". We need a single, unified `calculate_list_signature` function that ensures a list is bucketed correctly whether we are looking at faction stats or individual list performance.

## Goals

1. **Schema Pruning:** Update `backend/api/schemas.py` to keep only `xws`, `wins`, `games`, `lists`, and `different_lists`.
2. **Signature Utility:** Implement a robust `calculate_list_signature(xws_dict)` in `backend/utils/squadron.py`.
3. **Analytics Update:**
- Update `aggregate_faction_stats` in `backend/analytics/factions.py` to use the new signature for `different_lists` counting.
- Ensure `lists` reflects the total count of squads.
4. **Frontend Integration:**
- Calculate `win_rate` dynamically in `+page.svelte`.
- Map `xws` to labels and icons using `$lib/data/factions.ts`.

## Context Links
- Issue: Closes #86
- Discussion: Initiated to clean up `MetaSnapshotResponse`.
1 change: 1 addition & 0 deletions backend/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Package marker for backend
4 changes: 2 additions & 2 deletions backend/analytics/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from ..utils.xwing_data.pilots import get_pilot_name, get_pilot_info, load_all_pilots
from ..utils.xwing_data.upgrades import get_upgrade_name, load_all_upgrades
from ..utils.xwing_data.ships import load_all_ships
from ..data_structures.factions import Faction, get_faction_char
from ..data_structures.factions import Faction
from ..data_structures.formats import Format, MacroFormat
from ..data_structures.data_source import DataSource
from .filters import filter_query, get_active_formats, apply_tournament_filters
Expand Down Expand Up @@ -587,7 +587,7 @@ def _int_or(val, fallback):

s_data["points"] = s_data.get("cost", 0)
if mode == "pilots":
s_data["icon_char"] = get_faction_char(s_data.get("faction_xws", ""))
pass

results.append(s_data)

Expand Down
58 changes: 33 additions & 25 deletions backend/analytics/factions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
from sqlmodel import Session, select, func
from ..database import engine
from ..models import PlayerResult, Tournament
from ..data_structures.factions import Faction, get_faction_char
from ..data_structures.factions import Faction
from ..data_structures.formats import Format
from ..data_structures.data_source import DataSource
from .filters import filter_query, get_active_formats, apply_tournament_filters
from ..utils.squadron import calculate_list_signature



Expand All @@ -29,17 +30,18 @@ def aggregate_faction_stats(
rows = session.exec(query).all()

faction_stats = {}
faction_signatures = {} # Separate tracker for diversity

# Init all known factions
for f in Faction:
if f == Faction.UNKNOWN: continue
faction_stats[f.value] = {
"name": f.label,
"xws": f.value,
"wins": 0,
"games": 0,
"popularity": 0, # total lists
"lists": 0,
}
faction_signatures[f.value] = set()

allowed_formats = get_active_formats(filters.get("allowed_formats", None))

Expand Down Expand Up @@ -68,12 +70,12 @@ def aggregate_faction_stats(

if faction_xws not in faction_stats:
faction_stats[faction_xws] = {
"name": faction_enum.label,
"xws": faction_xws,
"wins": 0,
"games": 0,
"popularity": 0,
"lists": 0,
}
faction_signatures[faction_xws] = set()

s_wins = result.swiss_wins or 0
s_losses = result.swiss_losses or 0
Expand All @@ -88,26 +90,26 @@ def aggregate_faction_stats(

faction_stats[faction_xws]["wins"] += wins
faction_stats[faction_xws]["games"] += games
faction_stats[faction_xws]["popularity"] += 1
faction_stats[faction_xws]["lists"] += 1

results = []
for xws, data in faction_stats.items():
if data["popularity"] == 0: continue
sig = calculate_list_signature(xws)
if sig:
faction_signatures[faction_xws].add(sig)

win_rate = round((data["wins"] / data["games"]) * 100, 1) if data["games"] > 0 else 0.0
results = []
for xws_key, data in faction_stats.items():
if data["lists"] == 0: continue

results.append({
"name": data["name"],
"xws": data["xws"],
"icon_char": get_faction_char(data["xws"]),
"win_rate": win_rate,
"popularity": data["popularity"],
"games": data["games"],
"wins": data["wins"]
"wins": data["wins"],
"lists": data["lists"],
"different_lists": len(faction_signatures[data["xws"]])
})

# Default sort by popularity
results.sort(key=lambda x: x["popularity"], reverse=True)
# Default sort by lists
results.sort(key=lambda x: x["lists"], reverse=True)
return results

def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats: list[str] | None = None) -> dict:
Expand Down Expand Up @@ -149,17 +151,12 @@ def get_meta_snapshot(data_source: DataSource = DataSource.XWA, allowed_formats:
faction_distribution = []

for f in faction_stats:
percentage = round((f["games"] / total_games) * 100, 1) if total_games > 0 else 0
faction_distribution.append({
"name": get_faction_char(f["xws"]), # Set name to char for Recharts label=True
"real_name": f["name"], # Keep original for reference if needed
"xws": f["xws"],
"icon_char": get_faction_char(f["xws"]),
"win_rate": f["win_rate"],
"popularity": f["popularity"],
"games": f["games"],
"wins": f["wins"],
"percentage": percentage
"games": f["games"],
"lists": f["lists"],
"different_lists": f["different_lists"]
})

# Helper to filter and sort
Expand All @@ -178,13 +175,24 @@ def filter_and_sort(items, min_games):
top_pilots = filter_and_sort(pilot_stats, 30)
top_upgrades = filter_and_sort(upgrade_stats, 150)

total_unique_lists = sum(f["different_lists"] for f in faction_stats)

return {
"factions": faction_stats[:10],
"faction_distribution": faction_distribution,
"ships": top_ships[:10],
"lists": top_lists[:10],
"pilots": top_pilots[:10],
"upgrades": top_upgrades[:10],
"total_unique_lists": total_unique_lists,
"last_sync": last_sync,
"date_range": f"{date_str} to {end_date.strftime('%Y-%m-%d')}"
}

def get_faction_analytics(db: Session, filters: dict = None) -> list[dict]:
"""
API endpoint wrapper for faction statistics.
"""
if filters is None:
filters = {}
return aggregate_faction_stats(filters)
4 changes: 3 additions & 1 deletion backend/analytics/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ def check_format_filter(tournament: Tournament, format_selection: dict[str, bool

return format_selection.get(t_format_val, False)

def get_active_formats(format_selection: dict[str, bool] | list[str] | None) -> list[str]:
def get_active_formats(format_selection: dict[str, bool] | list[str] | None) -> list[str] | None:
"""
Normalize format selection to a simple list of active format keys.
"""
if format_selection is None:
return None
if not format_selection:
return []

Expand Down
30 changes: 8 additions & 22 deletions backend/analytics/lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from sqlmodel import Session, select, func
from ..database import engine
from ..models import PlayerResult, Tournament
from ..data_structures.factions import Faction, get_faction_char
from ..data_structures.factions import Faction
from ..data_structures.data_source import DataSource
from .filters import filter_query, get_active_formats, apply_tournament_filters
import json
Expand Down Expand Up @@ -47,9 +47,14 @@ def aggregate_list_stats(
xws = result.list_json
if not xws or not isinstance(xws, dict):
continue

from ..utils.squadron import calculate_list_signature
sig = calculate_list_signature(xws)
if not sig:
continue

pilots = xws.get("pilots", [])
if not pilots: continue
# (Ship filtering logic continues below)

req_ships = filters.get("ships")
if req_ships:
Expand All @@ -61,25 +66,7 @@ def aggregate_list_stats(
if not ship_matched:
continue

# Simple canonical representation for grouping
pilot_list = []
for p in pilots:
p_id = p.get("id") or p.get("name") or "unknown"
# Sort upgrades to make it stable
upgrades = []
upgrade_data = p.get("upgrades", {})
if isinstance(upgrade_data, dict):
for slot, items in upgrade_data.items():
if isinstance(items, list):
upgrades.extend([str(i) for i in items])
elif isinstance(upgrade_data, list):
upgrades.extend([str(i) for i in upgrade_data])

upgrades.sort()
pilot_list.append(f"{p_id}({','.join(upgrades)})")

pilot_list.sort()
list_key = "|".join(pilot_list)
list_key = sig

if list_key not in list_stats:
list_stats[list_key] = {
Expand Down Expand Up @@ -118,7 +105,6 @@ def aggregate_list_stats(
"name": data["name"],
"faction": f_xws,
"faction_xws": f_xws,
"icon_char": get_faction_char(f_xws),
"win_rate": win_rate,
"popularity": data["count"],
"games": data["games"],
Expand Down
4 changes: 1 addition & 3 deletions backend/analytics/ships.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from ..database import engine
from ..models import PlayerResult, Tournament
from ..utils.xwing_data.pilots import load_all_pilots
from ..data_structures.factions import Faction, get_faction_char
from ..data_structures.factions import Faction
from ..data_structures.formats import Format
from ..data_structures.data_source import DataSource
from .filters import filter_query, apply_tournament_filters
Expand Down Expand Up @@ -203,8 +203,6 @@ def aggregate_ship_stats(
"ship_name": data["ship_name"],
"ship_xws": data["ship_xws"],
"faction": data["faction_xws"],
"faction_xws": data["faction_xws"],
"icon_char": get_faction_char(data["faction_xws"]),
"win_rate": win_rate,
"popularity": popularity,
"games": games,
Expand Down
1 change: 1 addition & 0 deletions backend/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Package marker for api
7 changes: 1 addition & 6 deletions backend/api/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,6 @@ def enrich_list_data(stats: dict, source: DataSource = DataSource.XWA) -> ListDa
))

f_key = stats.get("faction", "unknown")
try:
f_label = Faction.from_xws(f_key).label
except:
f_label = f_key.title()

try: points = int(stats.get("points", 0))
except (ValueError, TypeError): points = 0
Expand All @@ -113,10 +109,9 @@ def enrich_list_data(stats: dict, source: DataSource = DataSource.XWA) -> ListDa
return ListData(
signature=stats.get("signature", "Unknown Signature") or "Unknown Signature",
name=stats.get("name", "Unknown List") or "Unknown List",
faction=f_label,
faction=f_key,
faction_key=f_key,
faction_xws=stats.get("faction_xws", f_key),
icon_char=stats.get("icon_char", ""),
points=calculated_points,
original_points=points,
count=count,
Expand Down
9 changes: 3 additions & 6 deletions backend/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,11 @@ class ListData(BaseModel):
pilots: list[PilotData] = []

class FactionStat(BaseModel):
name: str
xws: str
icon_char: str
win_rate: float
popularity: int
games: int
wins: int
percentage: float | None = None
real_name: str | None = None
lists: int
different_lists: int

class MetaSnapshotResponse(BaseModel):
factions: list[FactionStat]
Expand All @@ -47,6 +43,7 @@ class MetaSnapshotResponse(BaseModel):
last_sync: str
date_range: str
total_tournaments: int
total_unique_lists: int
total_players: int

class TournamentRow(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions backend/data_structures/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Package marker for data_structures
Loading