Skip to content

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

Open

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-67 — getLeaderboard() 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.

Activity

  1. added
    P2Prioridade normal
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:uiInterface, design system, animação, game feel
    on Jul 26, 2026
  2. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    🏛️ Parecer do Conselho · 2026-07-28

    Veredito: APROVADA PARA EXECUÇÃO

    Revisado contra origin/main (c40f0fc). A abordagem do corpo da issue está certa e a
    armadilha da view foi bem identificada. O que segue são as quatro coisas que ela ainda não diz
    e que decidem se a fatia sai correta na primeira tentativa.

    • Engenharia: (a) Não precisa de migration — e isso não é óbvio. A rota SSR usa getServerSupabase(), que cai para a anon key quando não há service-role (supabase.ts:11-15), então valia conferir se scores é legível por anon antes de assumir. É: 0001_coderacer_init.sql:64-68 já tem create policy "public read scores" … using (true) + grant select on public.scores to anon. Consultar scores direto funciona nos dois modos de deploy. Não abra migration nesta fatia.
      (b) Dedupe em TS tem de ser por lower(name), não por name cru — a view usa distinct on (lower(name)) (0001:45). Sem isso, Caio e caio viram duas linhas no caminho filtrado e uma no não-filtrado. Desempate idêntico ao da view: wpm desc, created_at desc (0001:54) — é o que dá a estabilidade que a AC pede no empate.
      (c) Bug latente que a fatia vai expor (decida de propósito): hoje a view escolhe o PB global do nick e só depois o .lte("wpm", MAX_PLAUSIBLE_WPM) é aplicado (supabase.ts:59). Quem tem um registro legado de 3596 é apagado do ranking inteiro em vez de aparecer com seu melhor score legítimo. No caminho filtrado (filtra antes, deduplica em TS) o bug não existe → o mesmo nick apareceria com filtro e sumiria sem. Recomendo unificar: os dois caminhos consultam scores e deduplicam em TS, e a view fica só como artefato histórico. Corrige o bug, mata a divergência e deixa um caminho de código. É mudança de comportamento no caminho sem filtro — declare no PR, não a esconda.
      (d) A folga do limit precisa de teto. .order("wpm", desc).limit(limit × k) com um k solto é leitura ilimitada; e se um único nick tiver muitas linhas no topo, 25 depois do dedupe pode não fechar. Use um teto fixo (ex.: 500 linhas), deduplique, corte 25 — e aceite a lista curta no caso patológico em vez de paginar.

    • Segurança/Anti-cheat: A superfície nova é a query string virando predicado de Postgres. Não escreva validação nova: resolveLang e resolveDifficulty (room.ts:215-241) já retornam FieldResolution e são a allowlist que as rotas de sala usam — reuse. Divergência deliberada com a rota de criação: lá, valor inválido é 400; numa URL compartilhada o certo é ignorar o filtro e cair no default, nunca 400 (a AC já pede isso — mas note que resolveLang devolve {ok:false}, então o call site é que decide cair no default). period não passa por allowlist nenhuma: mapeie hoje|semana|todos para um Date calculado no servidor e nunca interpole a string do usuário numa data.
      O teto .lte("wpm", MAX_PLAUSIBLE_WPM) tem de estar em todos os ramos — é o que segura o legado forjável enquanto a Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34 não fecha a porta de escrita. Um select sem o .lte em um único caminho reabre o 3596 no ranking.

    • UX/Game-feel: Chips como <Link> que só trocam a query string mantêm a página 100% servidor — sem "use client", sem JS novo, e o estado do filtro fica compartilhável e no histórico do navegador, que é o comportamento certo para quem quer mandar "olha meu 1º lugar em sql/hard". Para teclado, <Link> já é focável: use role="group" + aria-current="page" no chip ativo em vez de aria-pressed (que é semântica de botão de alternância, e estes são navegação). Nada anima → prefers-reduced-motion não entra aqui, e não é motivo para adicionar movimento.
      Um pedido concreto de rótulo: troque hoje / semana por 24 h / 7 dias. A Vercel roda em UTC e o público é UTC−3: "hoje" viraria ontem às 21h local, e o jogador veria seu score sumir sem explicação. Janela deslizante elimina o fuso do problema sem uma linha de código de timezone.

    • Performance: Fora da área sagrada — outra rota, sem textarea. Bundle inalterado (tudo servidor, e a página já é dynamic = "force-dynamic", então cada combinação de filtro é renderizada por request, sem cache a invalidar). No banco: os índices existentes são scores (wpm desc) e scores (lower(name)) (0001:37-39) — não há índice em created_at nem em (language, difficulty), então o filtro varre. No volume atual é irrelevante e não é para abrir migration agora; anote como follow-up (índice aditivo é §7.2) para quando scores crescer.

    • Produto: Prioridade P2 correta e a fatia é a certa: sem filtro, o ranking mede qual bucket o jogador escolheu, e a prova colhida em produção (mesmo jogador, mesma linguagem, 41 WPM de diferença) é boa demais para deixar parada. Atende exatamente a persona competitiva da spec §0.3 e o §III.8 ("o competitivo vem aqui medir-se"). Mantenha "ranqueado × casual" fora — depende de cruzar matches.player_count e de uma regra de produto que ninguém decidiu; virar isso agora inchava a fatia e atrasa o ganho óbvio.

    Plano sugerido:

    1. src/lib/leaderboard.ts (novo, puro e testável): export function bestPerName(rows: LeaderRow[]): LeaderRow[] — dedupe por name.toLowerCase(), desempate wpm desc, created_at desc, ordem de saída por wpm desc. É a única peça com lógica; teste-a isolada (empate de WPM → estável; Caio/caio → 1 linha).
    2. src/lib/supabase.ts: getLeaderboard(limit = 25, filters?: { lang?: LangId; diff?: Difficulty; sinceISO?: string }) passa a consultar scores sempre, com .lte("wpm", MAX_PLAUSIBLE_WPM) + .eq/.gte condicionais, .order("wpm", {ascending:false}).limit(500), e devolve bestPerName(data).slice(0, limit).
    3. src/app/leaderboard/page.tsx:32-37: recebe searchParams, resolve lang/diff por resolveLang/resolveDifficulty (inválido → sem filtro), mapeia period → sinceISO, e repassa. Chips = <Link href={...}> acima da tabela.
    4. SEO: metadata.alternates.canonical é estático em page.tsx:15 e aponta para /leaderboard — mantenha assim de propósito (as variantes filtradas não devem competir por indexação). Não troque por generateMetadata nesta fatia; se um dia quiser indexá-las, é issue própria — e conversa com a Practice/Fatia 4 — Polish/SEO: metadata indexável de /practice + sitemap #84.
    5. Gate: pnpm typecheck · pnpm build · node scripts/validate-persistence.mjs · vitest (com o teste novo de bestPerName). Sem migration ⇒ sem pnpm db:migrate.
    6. No PR, diga explicitamente que o caminho sem filtro mudou de fonte (view → scores + dedupe em TS) e que isso conserta o nick apagado por score legado — é a única mudança de comportamento observável.
  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 28, 2026
  4. caioross commented on Jul 28, 2026

    @caioross
    OwnerAuthor

    Resolvida em #106 (non-draft, §7.2 quórum — toca src/lib/supabase.ts).

    Segui o 🏛️ Parecer na íntegra: sem migration (scores já é legível por anon), dedupe por
    lower(name) com desempate wpm desc, created_at desc, os dois caminhos unificados em scores
    e teto fixo de 500 linhas antes do dedupe. Rótulos de período viraram 24 h / 7 dias /
    sempre (janela deslizante), como o parecer pediu, para não depender de fuso.

    Uma divergência consciente: usei isValidLang/isValidDifficulty em vez de
    resolveLang/resolveDifficulty — os resolve* tratam campo ausente como fallback, o que
    aqui viraria filtro ativo em vez de "sem filtro". A allowlist consultada é a mesma.

    O caminho sem filtro mudou de fonte (view → scores + dedupe em TS) e isso conserta o nick
    apagado do ranking inteiro por um score legado implausível — está declarado no corpo da PR.

    Gate: typecheck ✅ · build ✅ · vitest 170/170 ✅ (27 novos) · validate-persistence 72/72 ✅ ·
    validate-metrics 45/45 ✅ · lint N/A. SSR verificado sem Supabase (4 URLs, incl. params
    inválidos → DOM idêntico ao sem filtro); a metade de dados não é provável sem credenciais e está
    coberta pelos testes de bestPerName.

  5. caioross commented on Aug 2, 2026

    @caioross
    OwnerAuthor

    Evidência de produção (01/08) — a persona O Competitivo mediu o efeito já materializado no ranking real, não em teoria: o topo da tabela é 76 · 56 · 56 WPM, e as quatro melhores marcas do líder (76, 67, 64, 62) são todas SQL · easy. Sem filtro por linguagem/dificuldade/período, um recorde de COBOL/Sênior e um de SQL/Júnior brigam na mesma coluna — o ranking mede escolha de bucket, não digitação. Post completo: Discussion #120.

    Estado (não muda nada aqui): a PR #106 segue DRAFT + decisao-dono, travada em #107 — os filtros como implementados lá moviam o dedupe para o TS e amplificavam a #34.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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