diff --git a/CLAUDE.md b/CLAUDE.md index 18e47748..c86c3c8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ A web companion site for EverQuest 2 (TLE), with a Discord bot for spot checks ( | `backend/server/api/aa.py` | GET /api/character/{name}/aas — AA profile list with per-tree data | | `backend/server/api/aa_plans.py` | AA planner saved builds: CRUD (owner-scoped in SQL) + read-only share by always-minted slug (`GET /api/aa/plan/{slug}`, page `/aa-plan/{slug}`). Store `backend/server/db/aa_plans.py` (users.db). Rule legality is the frontend engine's job (`frontend/src/pages/aaplanner/engine.ts` — point-cost-weighted self-exclusive thresholds, parent ranks, flat 100/tree cap, separate tradeskill pool, no-stranding removals); the server validates structurally. Planner UI = "Planner" mode on the character AA tab (`aaplanner/PlannerMode.tsx`, interactive AATree: click spend / right-click refund). | | `backend/server/api/character/gear_sets.py` | GET /api/character/{name}/gear-sets — saved in-game equipment sets (Census `adventure_sets`), store-first SWR mirroring aa.py; feeds the character-sheet set pills + the compare per-side set picker. Each set carries `stat_deltas` (set − worn, additive stats + active item-set bonuses, from items.db `item_stats`) computed in sibling `stat_deltas.py` — the sheet shows approximated stats when a set is selected | -| `backend/server/api/character/rankings.py` | GET /api/character/{name}/rankings — WCL-style per-boss summary over the /api/rankings kills dataset (shared 60s cache). Class-scoped rank percentiles (best/median vs every same-class parse), All Stars points = 100×best/class-record + rank among class peers (per boss + zone total; peers get credit for bosses the target never killed). Both dps+hps in one response. `zones: []` ⇒ character page hides the Rankings tab (`CharacterRankingsTab.tsx`; percentileColors.ts scale). Fake local data: `scripts/dev/seed_fake_parses.py --character X` (tagged uploaded_by='fake-seed', `--wipe` to remove). | +| `backend/server/api/character/rankings.py` | GET /api/character/{name}/rankings — WCL-style per-boss summary over the /api/rankings kills dataset (shared 60s cache). Gated to curated zones.db content only (same universe as the rankings dropdowns — heuristic-matched kills never surface); zone sections carry `expansion` and the response lists the character's `expansions` (newest first) for the tab's xpac dropdown. Class-scoped rank percentiles (best/median vs every same-class parse), All Stars points = 100×best/class-record + rank among class peers (per boss + zone total; peers get credit for bosses the target never killed). Both dps+hps in one response. `zones: []` ⇒ character page hides the Rankings tab (`CharacterRankingsTab.tsx`; percentileColors.ts scale). Fake local data: `scripts/dev/seed_fake_parses.py --character X` (tagged uploaded_by='fake-seed', `--wipe` to remove). | | `backend/server/api/characters.py` | GET /api/characters/search — character name search | | `backend/server/api/guild_officer.py` | Officer claim-review endpoints; imports _officer_chars, _roster_rank_map from guild.py | | `backend/server/api/item_watch.py` | Item watch endpoints; imports _officer_chars, _roster_rank_map from guild.py | diff --git a/backend/server/api/character/rankings.py b/backend/server/api/character/rankings.py index 63365c30..0ea6668c 100644 --- a/backend/server/api/character/rankings.py +++ b/backend/server/api/character/rankings.py @@ -27,7 +27,7 @@ from pydantic import BaseModel from backend.server.api.character import router -from backend.server.api.rankings import _cached_kills, _is_player_combatant +from backend.server.api.rankings import _cached_kills, _cached_zones_data, _is_player_combatant from backend.server.auth_deps import require_user_session as _require_user from backend.server.core.executor import run_sync from backend.server.core.validation import validate_character_name @@ -65,15 +65,45 @@ class ZoneAllStars(BaseModel): class ZoneRankings(BaseModel): zone: str scope: str # "raid" | "group" + expansion: str | None = None # short code from zones.db ("EoF", "RoK") bosses: list[BossRankingRow] dps_allstars: ZoneAllStars | None = None hps_allstars: ZoneAllStars | None = None +class ExpansionOption(BaseModel): + short: str + name: str + + class CharacterRankingsResponse(BaseModel): name: str cls: str | None = None zones: list[ZoneRankings] + # Expansions (newest first) the character has ranked kills in — drives + # the tab's expansion dropdown. + expansions: list[ExpansionOption] = [] + + +def _curated_content() -> tuple[dict[tuple[str, str], tuple[str | None, frozenset[str]]], dict[str, str], list[str]]: + """The curated (zone → bosses) universe from zones.db — the exact set the + rankings page dropdowns show. Returns (curated, expansion_names, order): + * curated: (scope, zone) → (expansion_short, frozenset(boss names)) + * expansion_names: short → display name + * order: distinct expansion shorts, newest first (zones.db ordering) + """ + _, raid_tree, dungeon_tree = _cached_zones_data() + curated: dict[tuple[str, str], tuple[str | None, frozenset[str]]] = {} + names: dict[str, str] = {} + order: list[str] = [] + for scope, tree in (("raid", raid_tree), ("group", dungeon_tree)): + for entry in tree: + curated[(scope, entry["zone"])] = (entry["expansion"], frozenset(entry["bosses"])) + short = entry["expansion"] + if short and short not in names: + names[short] = entry.get("expansion_name") or short + order.append(short) + return curated, names, order def _rank_pct(score: float, pool: list[float]) -> int: @@ -89,6 +119,7 @@ def _rank_pct(score: float, pool: list[float]) -> int: def _build_character_rankings(name: str, world: str) -> CharacterRankingsResponse: kills = _cached_kills(world) target = name.strip().lower() + curated, exp_names, exp_order = _curated_content() # One pass over the dataset. Keys are (zone, scope, boss). pools: dict[tuple, dict[str, dict[str, list[float]]]] = defaultdict( @@ -104,6 +135,13 @@ def _build_character_rankings(name: str, world: str) -> CharacterRankingsRespons latest_cls: tuple[int, str] | None = None for k in kills: + # Only curated rankings content counts — the same (zone → bosses) + # universe the rankings page dropdowns show. Heuristic-matched kills + # (any capitalised mob in an uncurated zone) would otherwise flood + # the tab with every named the character ever logged. + cur = curated.get((k["scope"], k["zone"])) + if cur is None or k["title"] not in cur[1]: + continue key = (k["zone"], k["scope"], k["title"]) for c in k["combatants"]: if not _is_player_combatant(c) or not c.get("cls"): @@ -162,7 +200,7 @@ def _build_character_rankings(name: str, world: str) -> CharacterRankingsRespons zones: list[ZoneRankings] = [] for (zone, scope), rows in sections.items(): - z = ZoneRankings(zone=zone, scope=scope, bosses=rows) + z = ZoneRankings(zone=zone, scope=scope, expansion=curated[(scope, zone)][0], bosses=rows) # The target's class for this zone: their most recent parse's class # among the zone's bosses. cls = max(t_cls[k] for k in t_cls if k[0] == zone and k[1] == scope)[1] @@ -194,7 +232,14 @@ def _build_character_rankings(name: str, world: str) -> CharacterRankingsRespons # Raid sections first, then dungeons; alphabetical within. zones.sort(key=lambda z: (z.scope != "raid", z.zone.lower())) - return CharacterRankingsResponse(name=name, cls=latest_cls[1] if latest_cls else None, zones=zones) + present = {z.expansion for z in zones if z.expansion} + expansions = [ExpansionOption(short=s, name=exp_names[s]) for s in exp_order if s in present] + return CharacterRankingsResponse( + name=name, + cls=latest_cls[1] if latest_cls else None, + zones=zones, + expansions=expansions, + ) @router.get("/character/{name}/rankings", response_model=CharacterRankingsResponse) diff --git a/frontend/src/pages/CharacterPage.gearsets.test.tsx b/frontend/src/pages/CharacterPage.gearsets.test.tsx index 66d16407..b20ece77 100644 --- a/frontend/src/pages/CharacterPage.gearsets.test.tsx +++ b/frontend/src/pages/CharacterPage.gearsets.test.tsx @@ -326,8 +326,9 @@ describe('CharacterPage rankings tab', () => { const RANKINGS = { name: 'Ranker', cls: 'Guardian', + expansions: [{ short: 'KoS', name: 'Kingdom of Sky' }], zones: [{ - zone: 'Deathtoll', scope: 'raid', + zone: 'Deathtoll', scope: 'raid', expansion: 'KoS', dps_allstars: { points: 95.5, rank: 2, out_of: 7 }, hps_allstars: null, bosses: [{ boss: 'Tarinax the Destroyer', kills: 8, fastest_s: 530, fastest_encounter_id: 42, diff --git a/frontend/src/pages/CharacterRankingsTab.test.tsx b/frontend/src/pages/CharacterRankingsTab.test.tsx index 0bf16f45..b870a093 100644 --- a/frontend/src/pages/CharacterRankingsTab.test.tsx +++ b/frontend/src/pages/CharacterRankingsTab.test.tsx @@ -21,10 +21,12 @@ vi.mock('../useClasses', () => ({ const DATA: CharacterRankings = { name: 'Ranker', cls: 'Templar', + expansions: [{ short: 'KoS', name: 'Kingdom of Sky' }], zones: [ { zone: 'Deathtoll', scope: 'raid', + expansion: 'KoS', dps_allstars: { points: 160.5, rank: 2, out_of: 7 }, hps_allstars: { points: 88, rank: 3, out_of: 5 }, bosses: [ @@ -83,6 +85,27 @@ describe('CharacterRankingsTab', () => { expect(screen.getByText('Highest DPS')).toBeInTheDocument() }) + it('expansion dropdown filters zone sections, newest first by default', () => { + archetype = 'Fighter' + renderTab({ + ...DATA, + expansions: [ + { short: 'RoK', name: 'Rise of Kunark' }, + { short: 'EoF', name: 'Echoes of Faydwer' }, + ], + zones: [ + { ...DATA.zones[0], zone: 'Veeshan\'s Peak', expansion: 'RoK' }, + { ...DATA.zones[0], zone: 'Emerald Halls', expansion: 'EoF' }, + ], + }) + // Newest expansion selected by default → only its zone shows. + expect(screen.getByText("Veeshan's Peak")).toBeInTheDocument() + expect(screen.queryByText('Emerald Halls')).not.toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Expansion'), { target: { value: 'EoF' } }) + expect(screen.getByText('Emerald Halls')).toBeInTheDocument() + expect(screen.queryByText("Veeshan's Peak")).not.toBeInTheDocument() + }) + it('shows dashes for a metric the character never parsed', () => { archetype = 'Fighter' renderTab({ diff --git a/frontend/src/pages/CharacterRankingsTab.tsx b/frontend/src/pages/CharacterRankingsTab.tsx index f33d6a5e..815d2448 100644 --- a/frontend/src/pages/CharacterRankingsTab.tsx +++ b/frontend/src/pages/CharacterRankingsTab.tsx @@ -38,6 +38,7 @@ export interface BossRankingRow { export interface ZoneRankings { zone: string scope: string + expansion: string | null bosses: BossRankingRow[] dps_allstars: { points: number; rank: number; out_of: number } | null hps_allstars: { points: number; rank: number; out_of: number } | null @@ -47,6 +48,7 @@ export interface CharacterRankings { name: string cls: string | null zones: ZoneRankings[] + expansions: { short: string; name: string }[] } type Metric = 'dps' | 'hps' @@ -64,10 +66,26 @@ export default function CharacterRankingsTab({ data }: { data: CharacterRankings const [metric, setMetric] = useState(() => data.cls && byName.get(data.cls)?.archetype === 'Priest' ? 'hps' : 'dps', ) + // Expansion filter — the server sends only expansions this character has + // ranked kills in, newest first. + const [xpac, setXpac] = useState(() => data.expansions[0]?.short ?? '') + const zones = xpac ? data.zones.filter(z => z.expansion === xpac) : data.zones return (
-
+
+ {data.expansions.length > 0 && ( + + )} {(['dps', 'hps'] as Metric[]).map(m => (
- {data.zones.map(zone => { + {zones.map(zone => { const allstars = metric === 'dps' ? zone.dps_allstars : zone.hps_allstars return (
diff --git a/tests/server/test_character_rankings.py b/tests/server/test_character_rankings.py index 78ad7ef6..e52dcdd5 100644 --- a/tests/server/test_character_rankings.py +++ b/tests/server/test_character_rankings.py @@ -36,6 +36,22 @@ def _kill(kid: int, boss: str, duration: int, combatants: list[dict], *, zone: s } +# The curated universe the tab is gated to — mirrors _cached_zones_data's +# (boss_index, raid_tree, dungeon_tree) shape. "Uncurated Keep" is absent on +# purpose: kills there must never surface. +FAKE_TREES = ( + {}, + [ + { + "zone": "Deathtoll", + "expansion": "KoS", + "expansion_name": "Kingdom of Sky", + "bosses": ["Tarinax the Destroyer", "Xerkizh The Creator"], + } + ], + [], +) + # Tarinax: Templar pool = [1000, 1200, 2000] (Menlu twice, Elesine once). # Wizard pool = [5000]. Elesine also solo-kills Xerkizh (boss the target # never killed) — it must still count toward zone All Stars coverage. @@ -66,6 +82,17 @@ def _kill(kid: int, boss: str, duration: int, combatants: list[dict], *, zone: s _combatant("Elesine", "Templar", 1800, hps=3500), ], ), + # Heuristic-matched named in an uncurated zone — huge score, but it must + # never appear in the tab nor pollute any pool. + _kill( + 4, + "A Very Big Named", + 120, + [ + _combatant("Menlu", "Templar", 99_999, hps=99_999), + ], + zone="Uncurated Keep", + ), ] @@ -140,6 +167,35 @@ def test_unranked_character_gets_empty_zones(): assert resp.cls is None +def test_uncurated_kills_are_excluded_and_expansions_listed(): + """Only curated rankings content surfaces: Menlu's 99,999-DPS kill in + 'Uncurated Keep' must not appear anywhere — no boss row, no pool + pollution. Zone sections carry the expansion tag for the dropdown.""" + resp = mod._build_character_rankings("Menlu", "Wuoshi") + (zone,) = resp.zones # only the curated Deathtoll section + assert zone.zone == "Deathtoll" + assert zone.expansion == "KoS" + assert [b.boss for b in zone.bosses] == ["Tarinax the Destroyer"] + assert [e.model_dump() for e in resp.expansions] == [{"short": "KoS", "name": "Kingdom of Sky"}] + # The uncurated monster score never entered the Templar pool: the best + # percentile still ranks against [1000, 1200, 2000] only. + assert zone.bosses[0].dps is not None + assert zone.bosses[0].dps.best_pct == 50 + + +def test_character_with_only_uncurated_kills_gets_empty_zones(): + """A character whose parses are all heuristic-matched noise gets zones=[] + — the tab stays hidden for them.""" + solo = mod._build_character_rankings("Menlu", "Wuoshi") + assert solo.zones # sanity: Menlu has curated kills + + uncurated_only = [k for k in KILLS if k["zone"] == "Uncurated Keep"] + with patch.object(mod, "_cached_kills", lambda world: uncurated_only): + resp = mod._build_character_rankings("Menlu", "Wuoshi") + assert resp.zones == [] + assert resp.expansions == [] + + @pytest.mark.asyncio async def test_endpoint_serves_and_validates(app): with ( @@ -159,3 +215,4 @@ async def test_endpoint_serves_and_validates(app): @pytest.fixture(autouse=True) def _fake_kills(monkeypatch): monkeypatch.setattr(mod, "_cached_kills", lambda world: KILLS) + monkeypatch.setattr(mod, "_cached_zones_data", lambda: FAKE_TREES)