diff --git a/CLAUDE.md b/CLAUDE.md index d67166d6..18e47748 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +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/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/__init__.py b/backend/server/api/character/__init__.py index de5f9b80..b207d81d 100644 --- a/backend/server/api/character/__init__.py +++ b/backend/server/api/character/__init__.py @@ -3,6 +3,7 @@ Sub-modules: - views — GET /character/{name}, _build_char_response, equipment helpers - gear_sets — GET /character/{name}/gear-sets (saved in-game equipment sets) + - rankings — GET /character/{name}/rankings (WCL-style per-boss ranking summary) - spells — GET /character/{name}/spells - upgrades — GET /character/{name}/upgrade-materials + /upgrade-recipes """ @@ -14,6 +15,7 @@ router = APIRouter(tags=["character"]) from backend.server.api.character import gear_sets as _gear_sets # noqa: E402,F401 +from backend.server.api.character import rankings as _rankings # noqa: E402,F401 from backend.server.api.character import spells as _spells # noqa: E402,F401 from backend.server.api.character import upgrades as _upgrades # noqa: E402,F401 from backend.server.api.character import views as _views # noqa: E402,F401 diff --git a/backend/server/api/character/rankings.py b/backend/server/api/character/rankings.py new file mode 100644 index 00000000..63365c30 --- /dev/null +++ b/backend/server/api/character/rankings.py @@ -0,0 +1,210 @@ +"""GET /character/{name}/rankings — WCL-style per-character ranking summary. + +Computed over the same primary-boss-kills dataset as /api/rankings (shared +60s TTL cache), grouped by (zone, scope). All percentiles are **within the +character's class** for that boss: + + * ``best_pct`` — rank percentile of the character's best parse among ALL + parses of that class on that boss (fraction of the pool beaten × 100). + * ``median_pct`` — median of the character's parses' percentiles. + * All Stars ``points`` — 100 × (best score / class record): closeness to + the class record, deliberately distinct from the rank-based best_pct. + ``rank`` — position among same-class characters' bests on that boss. + The zone header aggregates: points summed over the zone's bosses, rank + recomputed among class peers on that sum. + +Both metrics (dps + hps) are computed in one pass so the frontend toggle +never refetches. A character with zero ranked kills gets ``zones: []`` — +the character page uses that to hide the tab entirely. +""" + +from __future__ import annotations + +from collections import defaultdict +from statistics import median + +from fastapi import HTTPException, Request +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.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 +from backend.server.limiter import limiter +from backend.server.server_context import current_world + +_METRICS: dict[str, str] = {"dps": "encdps", "hps": "enchps"} + + +class BossMetricStats(BaseModel): + best_pct: int + best_score: float + median_pct: int + encounter_id: int # the best parse, for linking + points: float # All Stars: 100 × best / class record + rank: int # among same-class characters' bests + out_of: int + + +class BossRankingRow(BaseModel): + boss: str + kills: int + fastest_s: int + fastest_encounter_id: int + dps: BossMetricStats | None = None + hps: BossMetricStats | None = None + + +class ZoneAllStars(BaseModel): + points: float + rank: int + out_of: int + + +class ZoneRankings(BaseModel): + zone: str + scope: str # "raid" | "group" + bosses: list[BossRankingRow] + dps_allstars: ZoneAllStars | None = None + hps_allstars: ZoneAllStars | None = None + + +class CharacterRankingsResponse(BaseModel): + name: str + cls: str | None = None + zones: list[ZoneRankings] + + +def _rank_pct(score: float, pool: list[float]) -> int: + """Rank percentile: share of the pool this score beats, ×100. A pool of + one (only your own parses) reads 100 — you hold the class record.""" + others = len(pool) - 1 + if others <= 0: + return 100 + below = sum(1 for v in pool if v < score) + return round(100 * below / others) + + +def _build_character_rankings(name: str, world: str) -> CharacterRankingsResponse: + kills = _cached_kills(world) + target = name.strip().lower() + + # One pass over the dataset. Keys are (zone, scope, boss). + pools: dict[tuple, dict[str, dict[str, list[float]]]] = defaultdict( + lambda: {m: defaultdict(list) for m in _METRICS} + ) # key -> metric -> cls -> all parse scores + bests: dict[tuple, dict[str, dict[str, dict[str, float]]]] = defaultdict( + lambda: {m: defaultdict(dict) for m in _METRICS} + ) # key -> metric -> cls -> char_lower -> best score + # Target-character accumulators. + t_scores: dict[tuple, dict[str, list[tuple[float, int]]]] = defaultdict(lambda: {m: [] for m in _METRICS}) + t_kills: dict[tuple, list[tuple[int, int]]] = defaultdict(list) # key -> [(duration_s, encounter_id)] + t_cls: dict[tuple, tuple[int, str]] = {} # key -> (started_at, cls) latest + latest_cls: tuple[int, str] | None = None + + for k in kills: + key = (k["zone"], k["scope"], k["title"]) + for c in k["combatants"]: + if not _is_player_combatant(c) or not c.get("cls"): + continue + cls = c["cls"] + cname = c["name"].strip().lower() + is_target = cname == target + for metric, field in _METRICS.items(): + score = c.get(field) or 0.0 + if score <= 0: + continue + pools[key][metric][cls].append(score) + cur = bests[key][metric][cls].get(cname) + if cur is None or score > cur: + bests[key][metric][cls][cname] = score + if is_target: + t_scores[key][metric].append((score, k["id"])) + if is_target: + t_kills[key].append((k["duration_s"], k["id"])) + stamped = (k["started_at"], cls) + if key not in t_cls or stamped[0] >= t_cls[key][0]: + t_cls[key] = stamped + if latest_cls is None or stamped[0] >= latest_cls[0]: + latest_cls = stamped + + if not t_kills: + return CharacterRankingsResponse(name=name, cls=None, zones=[]) + + # Per-boss rows, bucketed by (zone, scope) section. + sections: dict[tuple[str, str], list[BossRankingRow]] = defaultdict(list) + + for key, fights in sorted(t_kills.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2])): + zone, scope, boss = key + cls = t_cls[key][1] + fastest_s, fastest_id = min(fights) + row = BossRankingRow(boss=boss, kills=len(fights), fastest_s=fastest_s, fastest_encounter_id=fastest_id) + for metric in _METRICS: + scores = t_scores[key][metric] + if not scores: + continue + pool = pools[key][metric][cls] + best_score, best_id = max(scores) + class_bests = bests[key][metric][cls] + record = max(class_bests.values()) + stats = BossMetricStats( + best_pct=_rank_pct(best_score, pool), + best_score=best_score, + median_pct=round(median(_rank_pct(s, pool) for s, _ in scores)), + encounter_id=best_id, + points=round(100 * best_score / record, 2) if record else 0.0, + rank=1 + sum(1 for v in class_bests.values() if v > best_score), + out_of=len(class_bests), + ) + setattr(row, metric, stats) + sections[(zone, scope)].append(row) + + zones: list[ZoneRankings] = [] + for (zone, scope), rows in sections.items(): + z = ZoneRankings(zone=zone, scope=scope, 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] + for metric in _METRICS: + # Zone All Stars: sum each class peer's per-boss points over ALL + # of the zone's bosses (not just the ones the target killed), so + # peers with broader coverage rank ahead — a missing boss simply + # contributes 0 points. + totals: dict[str, float] = defaultdict(float) + for bkey, per_metric in bests.items(): + if bkey[0] != zone or bkey[1] != scope: + continue + class_bests = per_metric[metric][cls] + if not class_bests: + continue + record = max(class_bests.values()) + for peer, peer_best in class_bests.items(): + totals[peer] += 100 * peer_best / record if record else 0.0 + mine = totals.get(target) + if mine is None: + continue + allstars = ZoneAllStars( + points=round(mine, 2), + rank=1 + sum(1 for v in totals.values() if v > mine), + out_of=len(totals), + ) + setattr(z, f"{metric}_allstars", allstars) + zones.append(z) + + # 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) + + +@router.get("/character/{name}/rankings", response_model=CharacterRankingsResponse) +@limiter.limit("60/minute") +async def get_character_rankings(request: Request, name: str) -> CharacterRankingsResponse: + _require_user(request) + valid = validate_character_name(name) + if not valid: + raise HTTPException(status_code=400, detail="Invalid character name") + # Resolve world in the async handler — contextvars don't cross into the + # executor thread (same rule as /api/rankings). + world = current_world() + return await run_sync(_build_character_rankings, valid, world) diff --git a/frontend/src/pages/CharacterPage.gearsets.test.tsx b/frontend/src/pages/CharacterPage.gearsets.test.tsx index 3843a9b9..66d16407 100644 --- a/frontend/src/pages/CharacterPage.gearsets.test.tsx +++ b/frontend/src/pages/CharacterPage.gearsets.test.tsx @@ -92,12 +92,21 @@ const TANK_SET: GearSet = { } const DPS_SET: GearSet = { name: 'DPS', ilvl: 58, stat_deltas: {}, equipment: [slot('Head', 'Deeps Helm')] } -function stubFetch(opts: { char: Character; sets?: GearSet[]; items?: Record; lifetime?: Record }) { +function stubFetch(opts: { + char: Character + sets?: GearSet[] + items?: Record + lifetime?: Record + rankings?: Record +}) { vi.stubGlobal('fetch', vi.fn(async (url: string) => { const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) if (url.includes('/gear-sets')) { return ok({ character_name: opts.char.name, sets: opts.sets ?? [] }) } + if (url.includes('/rankings')) { + return ok(opts.rankings ?? { name: opts.char.name, cls: null, zones: [] }) + } const itemMatch = url.match(/\/api\/item\/(\d+)/) if (itemMatch) { const item = opts.items?.[itemMatch[1]] @@ -309,3 +318,43 @@ describe('CharacterPage set bonuses', () => { await waitFor(() => expect(screen.queryByText('Set Bonuses')).not.toBeInTheDocument()) }) }) + +// ── Rankings tab gating ────────────────────────────────────────────────────── +// The tab only exists once the eager /rankings fetch returns ≥1 ranked zone. + +describe('CharacterPage rankings tab', () => { + const RANKINGS = { + name: 'Ranker', + cls: 'Guardian', + zones: [{ + zone: 'Deathtoll', scope: 'raid', + 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, + dps: { best_pct: 99, best_score: 40901.4, median_pct: 91, encounter_id: 41, points: 95.5, rank: 2, out_of: 7 }, + hps: null, + }], + }], + } + + it('is hidden for a character with no ranked kills', async () => { + stubFetch({ char: mkChar('Norank') }) + renderAt('/character/Norank') + await screen.findByRole('button', { name: 'Spells' }) + // Wait for the rankings fetch to settle before asserting absence. + await waitFor(() => + expect(vi.mocked(fetch).mock.calls.some(([u]) => String(u).includes('/rankings'))).toBe(true), + ) + expect(screen.queryByRole('button', { name: 'Rankings' })).not.toBeInTheDocument() + }) + + it('appears with ranked kills and renders the board', async () => { + stubFetch({ char: mkChar('Ranker'), rankings: RANKINGS }) + renderAt('/character/Ranker') + const tab = await screen.findByRole('button', { name: 'Rankings' }) + fireEvent.click(tab) + expect(await screen.findByRole('link', { name: 'Tarinax the Destroyer' })).toHaveAttribute('href', '/parse/41') + expect(screen.getByText('99')).toBeInTheDocument() + expect(screen.getByText(/#2 of 7/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/CharacterPage.tsx b/frontend/src/pages/CharacterPage.tsx index 2e1994ea..2ee91a2c 100644 --- a/frontend/src/pages/CharacterPage.tsx +++ b/frontend/src/pages/CharacterPage.tsx @@ -7,6 +7,7 @@ import { ItemTooltip, useItemTooltip, getCachedItem, prefetchItem, type SetBonus import { FreshnessBadge } from '../components/FreshnessBadge' import FavoriteButton from '../components/FavoriteButton' import { AAsTab } from './CharacterAAsTab' +import CharacterRankingsTab, { type CharacterRankings } from './CharacterRankingsTab' import { SpellsTab } from './CharacterSpellsTab' import DeltaChip from './compare/DeltaChip' import { useCensusStream } from '../hooks/useCensusStream' @@ -381,9 +382,9 @@ export default function CharacterPage() { // ── Character view ──────────────────────────────────────────────────────────── -type ActiveTab = 'equipment' | 'aas' | 'spells' +type ActiveTab = 'equipment' | 'aas' | 'spells' | 'rankings' -const TABS: readonly ActiveTab[] = ['equipment', 'aas', 'spells'] +const TABS: readonly ActiveTab[] = ['equipment', 'aas', 'spells', 'rankings'] function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxLevel: number; ratingConfig: RatingConfig }) { const { tooltip, showTip, hideTip, moveTip } = useItemTooltip() @@ -414,6 +415,19 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL } }, [gearSetsData, initialSetParam]) + // Parse rankings — fetched eagerly because the tab itself is gated on + // having at least one ranked kill. Errors just hide the tab. + const { data: rankingsData } = useFetch( + `/api/character/${encodeURIComponent(char.name)}/rankings`, + ) + const rankings = rankingsData?.name?.toLowerCase() === char.name.toLowerCase() ? rankingsData : null + const hasRankings = (rankings?.zones.length ?? 0) > 0 + // A ?tab=rankings deep link for a character with no ranked kills falls + // back to equipment once the (empty) response lands. + useEffect(() => { + if (activeTab === 'rankings' && rankings && rankings.zones.length === 0) setActiveTab('equipment') + }, [activeTab, rankings]) + const activeSet = selectedSet ? sets.find(s => s.name === selectedSet) ?? null : null const viewEquipment = activeSet ? activeSet.equipment : char.equipment const bySlot = buildSlotMap(viewEquipment) @@ -474,11 +488,12 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL {/* Full-width general banner */} - {/* Tab bar */} + {/* Tab bar — Rankings only exists once the character has ≥1 ranked kill */}
- {(['equipment', 'aas', 'spells'] as ActiveTab[]).map(tab => { + {(['equipment', 'aas', 'spells', ...(hasRankings ? (['rankings'] as ActiveTab[]) : [])] as ActiveTab[]).map(tab => { const label = tab === 'equipment' ? 'Equipment & Stats' : tab === 'aas' ? 'Alternate Advancements' + : tab === 'rankings' ? 'Rankings' : 'Spells' return ( } + {activeTab === 'rankings' && rankings && hasRankings && } + {tooltip && }
) diff --git a/frontend/src/pages/CharacterRankingsTab.test.tsx b/frontend/src/pages/CharacterRankingsTab.test.tsx new file mode 100644 index 00000000..0bf16f45 --- /dev/null +++ b/frontend/src/pages/CharacterRankingsTab.test.tsx @@ -0,0 +1,97 @@ +/** + * CharacterRankingsTab unit tests — WCL-style table values, percentile + * colouring, the Damage/Healing toggle, and the healer default. + */ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' + +import CharacterRankingsTab, { type CharacterRankings } from './CharacterRankingsTab' + +let archetype = 'Fighter' +vi.mock('../useClasses', () => ({ + useClasses: () => ({ + classes: [], + byName: new Map([['Templar', { archetype }]]), + colourFor: () => 'var(--text)', + iconUrlFor: () => null, + }), +})) + +const DATA: CharacterRankings = { + name: 'Ranker', + cls: 'Templar', + zones: [ + { + zone: 'Deathtoll', + scope: 'raid', + dps_allstars: { points: 160.5, rank: 2, out_of: 7 }, + hps_allstars: { points: 88, rank: 3, out_of: 5 }, + bosses: [ + { + boss: 'Tarinax the Destroyer', + kills: 8, + fastest_s: 530, + fastest_encounter_id: 42, + dps: { best_pct: 99, best_score: 40901.4, median_pct: 91, encounter_id: 41, points: 95.5, rank: 2, out_of: 7 }, + hps: { best_pct: 50, best_score: 3200, median_pct: 42, encounter_id: 40, points: 75, rank: 3, out_of: 5 }, + }, + ], + }, + ], +} + +function renderTab(data: CharacterRankings = DATA) { + return render() +} + +describe('CharacterRankingsTab', () => { + it('renders the boss row with WCL colours, links and All Stars', () => { + archetype = 'Fighter' + renderTab() + // Zone header + scope + All Stars summary + expect(screen.getByText('Deathtoll')).toBeInTheDocument() + expect(screen.getByText('Raid')).toBeInTheDocument() + expect(screen.getByText('160.50 pts')).toBeInTheDocument() + expect(screen.getByText(/#2 of 7 Templars/)).toBeInTheDocument() + // Boss row values + const boss = screen.getByRole('link', { name: 'Tarinax the Destroyer' }) + expect(boss).toHaveAttribute('href', '/parse/41') + expect(screen.getByText('99')).toHaveStyle({ color: '#e268a8' }) // 99 = pink + expect(screen.getByText('40,901.4')).toBeInTheDocument() + expect(screen.getByRole('link', { name: '8m50s' })).toHaveAttribute('href', '/parse/42') + expect(screen.getByText('91')).toHaveStyle({ color: '#a335ee' }) // 75+ = purple + expect(screen.getByText('95.50')).toBeInTheDocument() + expect(screen.getByText('#2 / 7')).toBeInTheDocument() + expect(screen.getByText('Highest DPS')).toBeInTheDocument() + }) + + it('toggles to the Healing board', () => { + archetype = 'Fighter' + renderTab() + fireEvent.click(screen.getByRole('button', { name: 'Healing' })) + expect(screen.getByText('Highest HPS')).toBeInTheDocument() + expect(screen.getByText('3,200')).toBeInTheDocument() + expect(screen.getByText('88.00 pts')).toBeInTheDocument() + }) + + it('healers default to the Healing board', () => { + archetype = 'Priest' + renderTab() + expect(screen.getByText('Highest HPS')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Damage' })) + expect(screen.getByText('Highest DPS')).toBeInTheDocument() + }) + + it('shows dashes for a metric the character never parsed', () => { + archetype = 'Fighter' + renderTab({ + ...DATA, + zones: [{ ...DATA.zones[0], hps_allstars: null, bosses: [{ ...DATA.zones[0].bosses[0], hps: null }] }], + }) + fireEvent.click(screen.getByRole('button', { name: 'Healing' })) + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(3) + // Fastest still links even without metric stats + expect(screen.getByRole('link', { name: '8m50s' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/CharacterRankingsTab.tsx b/frontend/src/pages/CharacterRankingsTab.tsx new file mode 100644 index 00000000..f33d6a5e --- /dev/null +++ b/frontend/src/pages/CharacterRankingsTab.tsx @@ -0,0 +1,184 @@ +/** + * CharacterRankingsTab — WCL-style per-boss ranking summary for one character. + * + * Data comes pre-fetched from CharacterPage (which uses it to gate the tab). + * All percentiles are class-scoped (computed server-side): Best % is the rank + * percentile of the character's best parse among all parses of their class on + * that boss; Med % is the median of their parses' percentiles. All Stars + * Points = 100 × best ÷ class record (closeness to the record); Rank is the + * position among same-class characters' bests. + */ +import { useState } from 'react' +import { Link } from 'react-router-dom' + +import { Badge } from '../components/ui' +import { fmtDuration } from '../formatters' +import { percentileColor } from '../percentileColors' +import { useClasses } from '../useClasses' + +export interface BossMetricStats { + best_pct: number + best_score: number + median_pct: number + encounter_id: number + points: number + rank: number + out_of: number +} + +export interface BossRankingRow { + boss: string + kills: number + fastest_s: number + fastest_encounter_id: number + dps: BossMetricStats | null + hps: BossMetricStats | null +} + +export interface ZoneRankings { + zone: string + scope: string + bosses: BossRankingRow[] + dps_allstars: { points: number; rank: number; out_of: number } | null + hps_allstars: { points: number; rank: number; out_of: number } | null +} + +export interface CharacterRankings { + name: string + cls: string | null + zones: ZoneRankings[] +} + +type Metric = 'dps' | 'hps' + +const fmtScore = (n: number): string => n.toLocaleString(undefined, { maximumFractionDigits: 1 }) + +function Pct({ value }: { value: number | null | undefined }) { + if (value == null) return + return {value} +} + +export default function CharacterRankingsTab({ data }: { data: CharacterRankings }) { + const { byName } = useClasses() + // Healers default to the Healing board; everyone else to Damage. + const [metric, setMetric] = useState(() => + data.cls && byName.get(data.cls)?.archetype === 'Priest' ? 'hps' : 'dps', + ) + + return ( +
+
+ {(['dps', 'hps'] as Metric[]).map(m => ( + + ))} + + Percentiles are against other {data.cls ?? 'class'}s on this server. + +
+ + {data.zones.map(zone => { + const allstars = metric === 'dps' ? zone.dps_allstars : zone.hps_allstars + return ( +
+
+

{zone.zone}

+ + {zone.scope === 'raid' ? 'Raid' : 'Group'} + + {allstars && ( + + All Stars: {allstars.points.toFixed(2)} pts + {' · '}#{allstars.rank} of {allstars.out_of} {data.cls ?? 'peer'}s + + )} +
+
+ + + + + + + + + + + + + + + {zone.bosses.map(row => { + const stats = metric === 'dps' ? row.dps : row.hps + return ( + + + + + + + + + + + ) + })} + +
BossBest % + {metric === 'dps' ? 'Highest DPS' : 'Highest HPS'} + KillsFastestMed %PointsRank
+ {stats ? ( + + {row.boss} + + ) : ( + row.boss + )} + + {stats ? ( + + {fmtScore(stats.best_score)} + + ) : ( + + )} + {row.kills} + + {fmtDuration(row.fastest_s)} + + + {stats ? stats.points.toFixed(2) : } + + {stats ? `#${stats.rank} / ${stats.out_of}` : '—'} +
+
+
+ ) + })} + +

+ Built from ranked boss kills uploaded via the ACT plugin. Best % ranks your best parse against every{' '} + {data.cls ?? 'same-class'} parse on that boss; Med % is the median across your kills. Points measure how + close your best is to the class record (100 = you hold it); Rank is your position among{' '} + {data.cls ?? 'class'}s who've logged that boss. +

+
+ ) +} diff --git a/scripts/dev/seed_fake_parses.py b/scripts/dev/seed_fake_parses.py new file mode 100644 index 00000000..9f36ed2e --- /dev/null +++ b/scripts/dev/seed_fake_parses.py @@ -0,0 +1,243 @@ +"""Seed fake boss-kill parses into the local parses.db so the character +Rankings tab (and the rankings page) can be exercised without real ACT +uploads. + +Creates winning raid encounters against real curated bosses from zones.db +(so titles canonicalise exactly like production uploads), with: + + * the target character parsing on every boss — kill counts, a spread of + scores (median ≠ best), and best percentiles landing in different WCL + colour bands (pink / purple / blue / grey) across bosses; + * same-class peers filling out the class pool the percentiles rank against; + * other-class filler raiders so every kill counts ≥8 players (raid scope). + +All rows are stamped uploaded_by='fake-seed' — re-run with --wipe to remove +every trace. + +Usage: + uv run python scripts/dev/seed_fake_parses.py --character Menludiir + uv run python scripts/dev/seed_fake_parses.py --character Menludiir --cls Templar + uv run python scripts/dev/seed_fake_parses.py --wipe + +The rankings dataset is cached for 60s — restart the dev backend (or wait a +minute) after seeding, then open /character/?tab=rankings. +""" + +from __future__ import annotations + +import argparse +import os +import random +import sqlite3 +import sys +import time +import uuid + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))) + +from dotenv import load_dotenv # noqa: E402 + +load_dotenv() # EQ2_WORLD / DB_*_PATH from .env, same as the dev backend + +from backend.eq2db.zones import catalogue as zones_db # noqa: E402 +from backend.server.parses.db import store as parses_store # noqa: E402 + +UPLOADER = "fake-seed" +GUILD = "Fake Data Co" + +# Same-class peers whose bests shape the target's percentile per boss. +PEERS = ["Peerless", "Middling", "Contender", "Bencher", "Startlet", "Duelist", "Reserve", "Vanguard"] +# Other-class filler raiders (name, cls) — pad every kill to raid scope and +# give the rankings page some cross-class flavour. +FILLERS = [ + ("Slashy", "Berserker"), + ("Stabbin", "Swashbuckler"), + ("Boomlok", "Wizard"), + ("Dotsy", "Warlock"), + ("Shieldy", "Guardian"), + ("Tuneful", "Troubador"), + ("Wardz", "Mystic"), + ("Groveler", "Fury"), +] + +# Per-boss shaping: (target parses as fraction of the class record, +# peer-best fractions). The target's best lands in a different WCL colour +# band on each boss; extra parses below the best make Med % interesting. +BOSS_SHAPES = [ + {"target": (1.0, 0.82, 0.65), "peers": (0.93, 0.88, 0.71, 0.55, 0.4)}, # record holder → gold/pink + {"target": (0.9, 0.74), "peers": (1.0, 0.85, 0.66, 0.5, 0.33, 0.21)}, # upper-mid → purple-ish + {"target": (0.62, 0.5, 0.44, 0.38), "peers": (1.0, 0.9, 0.8, 0.7, 0.35)}, # mid → blue/green + {"target": (0.3,), "peers": (1.0, 0.92, 0.83, 0.75, 0.6, 0.5, 0.45)}, # bottom → grey +] + + +def _curated_bosses(limit: int) -> list[tuple[str, str]]: + """(zone, mob_title) tuples from zones.db curated encounters. Falls back + to heuristic-friendly capitalised fakes if the local db has no curation.""" + conn = sqlite3.connect(f"file:{zones_db.path}?mode=ro", uri=True) + try: + rows = conn.execute( + """SELECT z.name, m.mob_name_lower + FROM zone_encounter_mobs m + JOIN zone_encounters e ON e.id = m.encounter_id + JOIN zones z ON z.id = e.zone_id + WHERE length(m.mob_name_lower) >= 6 + GROUP BY e.id ORDER BY z.name, e.id LIMIT ?""", + (limit,), + ).fetchall() + finally: + conn.close() + if rows: + return [(zone, mob[0].upper() + mob[1:]) for zone, mob in rows] + print("! zones.db has no curated encounters — using heuristic boss names") + return [ + ("Deathtoll", "Tarinax the Destroyer"), + ("Deathtoll", "Xerkizh The Creator"), + ("The Fabled Vaults of El'Arad", "Amitrios"), + ("Freethinker Hideout", "Zylphax the Shredder"), + ][:limit] + + +def wipe(conn: sqlite3.Connection) -> None: + n = conn.execute( + "DELETE FROM combatants WHERE encounter_id IN (SELECT id FROM encounters WHERE uploaded_by = ?)", + (UPLOADER,), + ).rowcount + m = conn.execute("DELETE FROM encounters WHERE uploaded_by = ?", (UPLOADER,)).rowcount + conn.commit() + print(f"wiped {m} fake encounters ({n} combatant rows)") + + +def _insert_kill( + conn: sqlite3.Connection, + *, + world: str, + zone: str, + boss: str, + started_at: int, + duration_s: int, + combatants: list[tuple[str, str, float, float]], # (name, cls, encdps, enchps) +) -> None: + total = int(sum(c[2] for c in combatants) * duration_s) + cur = conn.execute( + """INSERT INTO encounters (world, act_encid, title, zone, started_at, ended_at, + duration_s, total_damage, encdps, kills, deaths, success_level, + source_dsn, uploaded_by, guild_name, ingested_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 1, ?, ?, ?, ?)""", + ( + world, + f"fake-{uuid.uuid4().hex[:12]}", + boss, + zone, + started_at, + started_at + duration_s, + duration_s, + total, + sum(c[2] for c in combatants), + UPLOADER, + UPLOADER, + GUILD, + int(time.time()), + ), + ) + enc_id = cur.lastrowid + for name, cls, dps, hps in combatants: + conn.execute( + """INSERT INTO combatants (encounter_id, name, ally, started_at, ended_at, + duration_s, damage, dps, encdps, enchps, level, guild_name, cls, + ilvl, is_player) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, 70, ?, ?, ?, 1)""", + ( + enc_id, + name, + started_at, + started_at + duration_s, + duration_s, + int(dps * duration_s), + dps, + dps, + hps, + GUILD, + cls, + round(random.uniform(38, 62), 1), + ), + ) + + +def seed(conn: sqlite3.Connection, *, character: str, cls: str, world: str) -> None: + rng = random.Random(42) + bosses = _curated_bosses(len(BOSS_SHAPES)) + now = int(time.time()) + for bi, ((zone, boss), shape) in enumerate(zip(bosses, BOSS_SHAPES)): + record = rng.uniform(28_000, 45_000) # the class record for this boss + hps_record = record * 0.6 + # Peer kills: one kill each, spread over the last month. + for pi, frac in enumerate(shape["peers"]): + others = rng.sample(FILLERS, 6) + _insert_kill( + conn, + world=world, + zone=zone, + boss=boss, + started_at=now - rng.randint(3, 30) * 86_400 - pi * 3600, + duration_s=rng.randint(240, 660), + combatants=[ + (PEERS[pi], cls, record * frac, hps_record * frac * rng.uniform(0.2, 0.5)), + *[(n, c, record * rng.uniform(0.4, 1.1), 800.0) for n, c in others], + ], + ) + # Target kills: newest, one per target fraction (best ≠ median ≠ fastest). + for ti, frac in enumerate(shape["target"]): + others = rng.sample(FILLERS, 7) + _insert_kill( + conn, + world=world, + zone=zone, + boss=boss, + started_at=now - ti * 86_400 - bi * 7200, + duration_s=rng.randint(220, 700), + combatants=[ + (character, cls, record * frac, hps_record * rng.uniform(0.3, 0.9)), + *[(n, c, record * rng.uniform(0.4, 1.1), 800.0) for n, c in others], + ], + ) + conn.commit() + n = conn.execute("SELECT COUNT(*) FROM encounters WHERE uploaded_by = ?", (UPLOADER,)).fetchone()[0] + print(f"seeded {n} fake winning kills for {character} ({cls}) on {world} across {len(bosses)} bosses:") + for zone, boss in bosses: + print(f" {zone} — {boss}") + print("\nrankings cache TTL is 60s — restart the dev backend (or wait a minute), then open:") + print(f" /character/{character}?tab=rankings") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--character", help="target character name (as you'd browse them on the site)") + ap.add_argument("--cls", default="Templar", help="target character's class (default Templar)") + ap.add_argument( + "--world", default=os.getenv("EQ2_WORLD", "Varsoon"), help="world stamp (default EQ2_WORLD/Varsoon)" + ) + ap.add_argument("--wipe", action="store_true", help="remove all fake-seed rows and exit") + args = ap.parse_args() + + conn = parses_store.init_db() + # Some long-lived local parses.dbs carry a stale FK on combatants that + # references "encounters_old" (a scar from an old table-rebuild + # migration; the app never enables FK enforcement on parses + # connections, so it's dormant there). Keep it dormant here too. + conn.execute("PRAGMA foreign_keys=OFF") + try: + if args.wipe: + wipe(conn) + return + if not args.character: + ap.error("--character is required (or use --wipe)") + # Idempotence: a fresh seed replaces any previous fake batch. + wipe(conn) + seed(conn, character=args.character, cls=args.cls, world=args.world) + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/tests/server/test_character_rankings.py b/tests/server/test_character_rankings.py new file mode 100644 index 00000000..78ad7ef6 --- /dev/null +++ b/tests/server/test_character_rankings.py @@ -0,0 +1,161 @@ +"""Tests for GET /api/character/{name}/rankings (WCL-style per-boss summary). + +The kills dataset is faked at the _cached_kills seam (same data shape the +rankings module builds from parses.db), so these tests pin the percentile, +median, All Stars, and gating semantics without any DB plumbing. +""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from backend.server.api.character import rankings as mod +from tests.fixtures.users import make_fake_require_user, make_fake_user + +_fake_user = make_fake_require_user(make_fake_user(id="123")) + + +def _combatant(name: str, cls: str, dps: float, hps: float = 0.0) -> dict: + return {"name": name, "ally": 1, "cls": cls, "encdps": dps, "enchps": hps} + + +def _kill(kid: int, boss: str, duration: int, combatants: list[dict], *, zone: str = "Deathtoll") -> dict: + return { + "id": kid, + "title": boss, + "zone": zone, + "guild_name": "Testers", + "started_at": 1_700_000_000 + kid, + "duration_s": duration, + "player_count": 24, + "scope": "raid", + "combatants": combatants, + } + + +# 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. +KILLS = [ + _kill( + 1, + "Tarinax the Destroyer", + 600, + [ + _combatant("Menlu", "Templar", 1000, hps=3000), + _combatant("Wizzy", "Wizard", 5000), + ], + ), + _kill( + 2, + "Tarinax the Destroyer", + 540, + [ + _combatant("Menlu", "Templar", 1200, hps=2500), + _combatant("Elesine", "Templar", 2000, hps=4000), + ], + ), + _kill( + 3, + "Xerkizh The Creator", + 300, + [ + _combatant("Elesine", "Templar", 1800, hps=3500), + ], + ), +] + + +def test_percentiles_are_class_scoped(): + resp = mod._build_character_rankings("Menlu", "Wuoshi") + assert resp.cls == "Templar" + (zone,) = resp.zones + assert (zone.zone, zone.scope) == ("Deathtoll", "raid") + (row,) = zone.bosses # Menlu only killed Tarinax + assert row.boss == "Tarinax the Destroyer" + assert row.kills == 2 + assert row.fastest_s == 540 + assert row.fastest_encounter_id == 2 + + dps = row.dps + assert dps is not None + # Best 1200 beats 1 of the 2 other Templar parses → 50. The Wizard's + # 5000 is in a different class pool and must not drag this down. + assert dps.best_pct == 50 + assert dps.best_score == 1200 + assert dps.encounter_id == 2 + # Parse percentiles: 1000 → 0, 1200 → 50 → median 25. + assert dps.median_pct == 25 + # All Stars: 100 × 1200/2000 vs the class record; rank 2 of 2 Templars. + assert dps.points == 60.0 + assert (dps.rank, dps.out_of) == (2, 2) + + +def test_wizard_pool_is_independent(): + resp = mod._build_character_rankings("Wizzy", "Wuoshi") + (zone,) = resp.zones + dps = zone.bosses[0].dps + assert dps is not None + assert dps.best_pct == 100 # alone in the Wizard pool → class record + assert dps.points == 100.0 + assert (dps.rank, dps.out_of) == (1, 1) + # No healing parses → no hps stats. + assert zone.bosses[0].hps is None + + +def test_hps_metric_computed_alongside(): + resp = mod._build_character_rankings("Menlu", "Wuoshi") + hps = resp.zones[0].bosses[0].hps + assert hps is not None + # HPS pool [3000, 2500, 4000]; best 3000 beats 1 of 2 others → 50. + assert hps.best_pct == 50 + assert hps.best_score == 3000 + assert hps.encounter_id == 1 # best HPS parse was kill 1, not the DPS best + assert hps.points == 75.0 # 100 × 3000/4000 + + +def test_zone_allstars_cover_bosses_target_never_killed(): + resp = mod._build_character_rankings("Menlu", "Wuoshi") + allstars = resp.zones[0].dps_allstars + assert allstars is not None + # Menlu: 60 pts (Tarinax only). Elesine: 100 (Tarinax record) + 100 + # (Xerkizh record) = 200 — the Xerkizh kill counts even though Menlu + # never fought it. + assert allstars.points == 60.0 + assert (allstars.rank, allstars.out_of) == (2, 2) + + elesine = mod._build_character_rankings("Elesine", "Wuoshi") + e_allstars = elesine.zones[0].dps_allstars + assert e_allstars is not None + assert e_allstars.points == 200.0 + assert e_allstars.rank == 1 + + +def test_unranked_character_gets_empty_zones(): + resp = mod._build_character_rankings("Ghostchar", "Wuoshi") + assert resp.zones == [] + assert resp.cls is None + + +@pytest.mark.asyncio +async def test_endpoint_serves_and_validates(app): + with ( + patch.object(mod, "_cached_kills", lambda world: KILLS), + patch("backend.server.api.character.rankings._require_user", _fake_user), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http: + ok = await http.get("/api/character/Menlu/rankings") + bad = await http.get("/api/character/bad name!!/rankings") + assert ok.status_code == 200 + body = ok.json() + assert body["name"] == "Menlu" + assert body["zones"][0]["bosses"][0]["dps"]["best_pct"] == 50 + assert bad.status_code == 400 + + +@pytest.fixture(autouse=True) +def _fake_kills(monkeypatch): + monkeypatch.setattr(mod, "_cached_kills", lambda world: KILLS)