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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
51 changes: 48 additions & 3 deletions backend/server/api/character/rankings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand All @@ -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"):
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/pages/CharacterPage.gearsets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/pages/CharacterRankingsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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({
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/pages/CharacterRankingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,6 +48,7 @@ export interface CharacterRankings {
name: string
cls: string | null
zones: ZoneRankings[]
expansions: { short: string; name: string }[]
}

type Metric = 'dps' | 'hps'
Expand All @@ -64,10 +66,26 @@ export default function CharacterRankingsTab({ data }: { data: CharacterRankings
const [metric, setMetric] = useState<Metric>(() =>
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 (
<div className="mt-4">
<div className="flex items-center gap-2 mb-4">
<div className="flex flex-wrap items-center gap-2 mb-4">
{data.expansions.length > 0 && (
<select
value={xpac}
onChange={e => setXpac(e.target.value)}
className="px-2 py-1 text-[0.82rem]"
aria-label="Expansion"
>
{data.expansions.map(e => (
<option key={e.short} value={e.short}>{e.name}</option>
))}
</select>
)}
{(['dps', 'hps'] as Metric[]).map(m => (
<button
key={m}
Expand All @@ -84,7 +102,7 @@ export default function CharacterRankingsTab({ data }: { data: CharacterRankings
</span>
</div>

{data.zones.map(zone => {
{zones.map(zone => {
const allstars = metric === 'dps' ? zone.dps_allstars : zone.hps_allstars
return (
<div key={`${zone.zone}-${zone.scope}`} className="mb-6">
Expand Down
57 changes: 57 additions & 0 deletions tests/server/test_character_rankings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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",
),
]


Expand Down Expand Up @@ -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 (
Expand All @@ -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)
Loading