Skip to content

Leaderboard/Integridade: ranking sem filtros premia o bucket mais fácil — filtros por linguagem/dificuldade/período (§III.8) #92

Description

@caioross

Convertida de [Feedback] O Cético Sênior · 2026-07-26 — ICE 5×5×3 = 75.

Problema

O Ranking Global ordena por WPM bruto misturando linguagens e dificuldades diferentes no mesmo pote. Como o snippet easy é curto e o hard é longo, o WPM de um bucket não é comparável com o de outro — o ranking passa a medir qual bucket o jogador escolheu, não quem digita mais rápido.

Prova colhida na própria produção (seção PARTIDAS RECENTES, 2026-07-26):

jogador wpm lang dificuldade
VINNY O RAPIDO 35 SQL hard
VINNY O RAPIDO 76 SQL easy

Mesmo jogador, mesma linguagem, 41 WPM de diferença só pela dificuldade — e é o 76 que está cravado em 🥇 no topo de "MELHORES WPM". A página tem zero filtros (0 <select>, 0 botões de filtro no DOM).

Consequência: para ser nº1 não é preciso digitar mais rápido, basta descobrir a combinação linguagem+dificuldade com o snippet mais curto e repetir. Isso esvazia o ranking para o público competitivo — que é justamente quem a página serve (docs/UI-AAA-OVERHAUL.md §III.8: "o competitivo vem aqui medir-se").

Contexto no código

  • src/app/leaderboard/page.tsx:34-37 — SSR chama getLeaderboard(25) sem nenhum parâmetro de filtro.
  • src/lib/supabase.ts:49-67getLeaderboard() lê a view leaderboard e já aplica o teto de leitura .lte("wpm", MAX_PLAUSIBLE_WPM) ([Feature] Filtrar valores irreais de WPM no ranking #68/Anti-cheat/Leaderboard: recorde impossível (3596 WPM) no Ranking Global — sem teto de plausibilidade #28). Manter.
  • ⚠️ Armadilha principal: supabase/migrations/0001_coderacer_init.sql:43-54 define a view como
    select distinct on (lower(name)) ... from scores order by lower(name), wpm desc — ou seja, o PB já foi escolhido globalmente antes de qualquer filtro. Adicionar um .eq("language", …) em cima da view NÃO funciona: um jogador cujo PB global é sql/easy simplesmente desaparece da lista ao filtrar por sql/hard, em vez de aparecer com o seu melhor tempo naquele bucket.

Abordagem sugerida (1 fatia, sem migração)

  1. getLeaderboard() ganha um parâmetro de filtro opcional. Sem filtro → caminho atual (view leaderboard, comportamento preservado). Com filtro → consulta scores diretamente com .eq("language", …) / .eq("difficulty", …) / .gte("created_at", …), mantendo .lte("wpm", MAX_PLAUSIBLE_WPM), e faz o distinct por nick em TS (helper puro e testável, ex. bestPerName(rows)).
    • Buscar folga (limit * k) antes de deduplicar, senão o corte de 25 linhas acontece antes do dedupe e a lista fica curta.
  2. A página lê os filtros de searchParams (?lang=, ?diff=, ?period=) — continua SSR, sem "use client" na página (a rota já é dynamic = "force-dynamic"). Chips = <Link> que só trocam a query string, preservando SEO.
  3. Validar os params contra allowlist já existente (langById / a allowlist de supabase/migrations/0004_settings_allowlist.sql) antes de tocar no Postgres; valor inválido cai no default.

Acceptance criteria

  • /leaderboard sem query param se comporta exatamente como hoje (PB global por nick, teto de WPM aplicado).
  • /leaderboard?lang=sql&diff=hard mostra o ranking restrito ao bucket: um jogador com 76 em sql/easy e 35 em sql/hard aparece com 35 — e não some da lista.
  • Filtro de período hoje / semana / todos funciona sobre scores.created_at.
  • O teto .lte("wpm", MAX_PLAUSIBLE_WPM) continua aplicado em todos os caminhos, filtrados ou não.
  • Chips navegáveis por teclado, com o estado atual anunciado (aria-pressed / role="tablist"); prefers-reduced-motion respeitado.
  • Query param inválido (?lang=naoexiste, ?diff=impossivel) não quebra a página nem chega cru à query.
  • Teste unitário do helper de dedupe por nick (empate de WPM → desempate estável).
  • Gate verde: pnpm typecheck · pnpm build · node scripts/validate-persistence.mjs.

Fora do escopo (fatia seguinte)

Separar ranqueado de casual — hoje corridas de 1 jogador (1p) entram no mesmo ranking das disputadas (docs/UI-AAA-OVERHAUL.md, seção de integridade ~linha 1032: "só partidas validadas entram no leaderboard global"). Exige decidir a regra e cruzar scores com matches.player_count; vira issue própria depois desta.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Prioridade normalarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:uiInterface, design system, animação, game feel

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions