Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions backend/server/api/character/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand All @@ -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
Expand Down
210 changes: 210 additions & 0 deletions backend/server/api/character/rankings.py
Original file line number Diff line number Diff line change
@@ -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)
51 changes: 50 additions & 1 deletion frontend/src/pages/CharacterPage.gearsets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; lifetime?: Record<string, unknown> }) {
function stubFetch(opts: {
char: Character
sets?: GearSet[]
items?: Record<string, unknown>
lifetime?: Record<string, unknown>
rankings?: Record<string, unknown>
}) {
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]]
Expand Down Expand Up @@ -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()
})
})
25 changes: 21 additions & 4 deletions frontend/src/pages/CharacterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<CharacterRankings>(
`/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)
Expand Down Expand Up @@ -474,11 +488,12 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL
{/* Full-width general banner */}
<GeneralBanner char={char} />

{/* Tab bar */}
{/* Tab bar — Rankings only exists once the character has ≥1 ranked kill */}
<div className="flex flex-wrap gap-0 border-b border-border mt-4">
{(['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 (
<TabButton
Expand Down Expand Up @@ -571,6 +586,8 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL
{/* Spells tab */}
{activeTab === 'spells' && <SpellsTab charName={char.name} />}

{activeTab === 'rankings' && rankings && hasRankings && <CharacterRankingsTab data={rankings} />}

{tooltip && <ItemTooltip state={tooltip} />}
</div>
)
Expand Down
Loading
Loading