You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Anti-cheat/Leaderboard: recorde impossível (3596 WPM) no Ranking Global — sem teto de plausibilidade #28
O Ranking Global (/leaderboard) exibe um recorde fisicamente impossível no topo:
#
Jogador
WPM
Precisão
Lang
🥇
hackerman
3596
100%
KT
🥈
gtkatakura
44
100%
C++
🥉
yago_naodev
38
96%
C#
O 2º colocado tem 44 WPM. 3596 WPM não é humanamente possível (recorde mundial de digitação ~200 WPM, e digitar código é bem mais lento). Ou seja: uma partida com WPM absurdo passou pela persistência e agora polui o placar público.
Por que é grave (P1)
A justiça das métricas é o alicerce do produto (spec §0.3 — otimizamos pro Cético e pro Competitivo). Um recorde impossível no topo detona a confiança dos dois perfis mais exigentes.
Pro Iniciante (§0.3) é desmotivador: "nunca vou chegar perto" + "esse ranking é furado".
Sinaliza um gap de anti-cheat/validação: o WPM não é sanitizado contra teto plausível antes de persistir em matches/scores nem ao montar o leaderboard.
(Observado ao vivo em 2026-07-12. Não sei reproduzir como o registro foi criado — pode ser dado antigo pré-anti-cheat ou um vetor ainda aberto; de qualquer forma o placar público está poluído hoje.)
Sugestão de acceptance criteria
Definir um teto plausível de WPM (ex.: rejeitar/ignorar partidas com WPM acima de um limite tipo 250–300) na validação antes de persistir (área anti-cheat, area:engine).
Ao montar o Ranking Global, filtrar/ocultar entradas acima do teto (defesa em profundidade, area:multiplayer).
Sanear os dados existentes: remover/reduzir o registro atual de 3596 WPM do hackerman.
Considerar registrar (log) tentativas implausíveis pra auditoria de anti-cheat.
Contexto
Descoberto durante a rodada semanal da persona (O Iniciante). Feedback completo: discussion #27.
Entregue: defesa em profundidade nas leituras públicas (getLeaderboard + getRecentMatches em src/lib/supabase.ts) filtrando por MAX_PLAUSIBLE_WPM — o registro impossível de 3596 WPM some do Ranking Global sem tocar o dado. Complementa o guard de escrita sanitizeResults (#7).
Fica para o dono (HANDBOOK §7.1): sanear/remover o registro bruto em scores (delete from scores where wpm > 350;) — operação em dados de produção. Item "logar tentativas implausíveis" (AC "considerar") fora do escopo mínimo desta fatia.
Veredito: PRECISA DECISÃO DO DONO — aplico decisao-dono e aponto para a [Decisão] já aberta (#46).
Revisado contra origin/main (7236d47). Motivo do parecer: a issue é P1 e está sem label de bloqueio, então continua no topo do pool elegível e vai consumir a rodada de um Resolvedor que não tem o que entregar aqui. Este parecer existe para evitar essa rodada perdida e deixar o resto documentado.
Engenharia: situação real dos 4 AC hoje:
· AC1 (teto antes de persistir) — ✅ já na main:sanitizeResults descarta linha com wpm > MAX_PLAUSIBLE_WPM (350) — src/lib/room.ts:100,143. Descarta, não clampa (correto: clampar premiaria o trapaceiro).
· AC2 (filtrar na leitura) — ⏸ pronto, parado:getLeaderboard/getRecentMatches (src/lib/supabase.ts:48-61,64-79) são filtrados pela PR fix(leaderboard): oculta WPM implausível do ranking público (#28) #30, que está DRAFT + decisao-dono desde 18/07 (quórum 2 APROVA / 1 VETA).
· AC3 (sanear a linha bruta de 3596 WPM) — 🔒 dono:delete/update em dados de produção é núcleo irredutível, HANDBOOK §7.1.
· AC4 (log de tentativas implausíveis) — o único resto executável, e sozinho não muda uma linha do /leaderboard: fazer só isso deixaria a issue P1 aberta com o sintoma público intacto e daria falsa sensação de progresso.
Conclusão de engenharia: não existe fatia que o Resolvedor possa entregar hoje que mova o sintoma. O gargalo é uma resposta de uma linha em [Decisão] Leaderboard cap (#28/#30): defesa-em-profundidade + deletar linha bruta vs. teto na view #46, não investigação.
Segurança/Anti-cheat: o veto da lente Ofensiva no quórum da fix(leaderboard): oculta WPM implausível do ranking público (#28) #30 está correto e vale registrar como invariante da frota: matches/scores/leaderboard têm RLS using (true) + grant select to anon (supabase/migrations/0001_coderacer_init.sql:58-69) e a anon key viaja no bundle do cliente — logo supabaseBrowser.from("leaderboard").select().gt("wpm", 350) reexibe o dado bruto, contornando qualquer filtro de aplicação. Isto não é regressão da fix(leaderboard): oculta WPM implausível do ranking público (#28) #30: é a natureza de um leaderboard público sem login. Implicação prática: filtro em src/lib/supabase.ts é camada, não fechadura. Só duas coisas fecham de verdade — (a) o dado não existir (opção (i) de [Decisão] Leaderboard cap (#28/#30): defesa-em-profundidade + deletar linha bruta vs. teto na view #46: merge + delete from scores where wpm > 350;) ou (b) o teto morar na própria view (opção (ii): migração aditiva create or replace view leaderboard ... where wpm <= 350). As duas são compatíveis e o custo marginal de (ii) é um arquivo em supabase/migrations/ — o dono continua sendo quem aplica (§8).
UX/Game-feel: nada a construir; a tela do /leaderboard está correta, o dado é que mente. Enquanto hackerman · 3596 WPM estiver no topo, a mesma tela que a persona O Iniciante chamou de encantamento nº1 (ver o próprio nome no ranking, D#14/D#27) é a que queima o Cético e o Competitivo (spec §0.3).
Performance: irrelevante em qualquer opção — .lte()/where server-side sobre leitura já limitada por .limit() (supabase.ts:55,73); a variante (ii) roda no plano da view, sem custo perceptível.
Se sair (ii) ou "as duas": o Resolvedor entrega migração aditiva e idempotente com create or replace view public.leaderboard ... where wpm <= 350 (mantendo o restante do contrato da view de 0001), numerada por max(origin/main + PRs abertas) + 1 → hoje 0006_ (0004 está na PR fix(security): allowlist de language/difficulty nas rotas de sala (#35) #38 e 0005 na PR fix(security): kick com autoridade de servidor via kicked_ids (#39) #44); só cria o arquivo, o dono aplica (HANDBOOK §8). Ideal: derivar o 350 de um comentário apontando MAX_PLAUSIBLE_WPM (src/lib/room.ts:100) para o teto de escrita e o do banco nunca divergirem.
AC4 (log) só faz sentido depois, como fatia separada — não vale abrir PR por ele agora.
AC 📓 Diário de Bordo da Frota #4 (log de tentativas implausíveis) — ⬜ aberto. Independente do resto; cabe numa rodada.
Portanto o que resta aqui é: (a) o delete de uma linha em produção — ação do dono, indelegável; e (b) opcionalmente o AC #4. A parte de código endereçável por agente acabou. Se o dono rodar o delete, esta issue pode fechar e o AC #4 vira issue própria (P3, area:security).
Activity
caioross commentedon Jul 12, 2026
Aberta a PR #30 (fatia parcial →
Refs #28).Entregue: defesa em profundidade nas leituras públicas (
getLeaderboard+getRecentMatchesemsrc/lib/supabase.ts) filtrando porMAX_PLAUSIBLE_WPM— o registro impossível de 3596 WPM some do Ranking Global sem tocar o dado. Complementa o guard de escritasanitizeResults(#7).Fica para o dono (HANDBOOK §7.1): sanear/remover o registro bruto em
scores(delete from scores where wpm > 350;) — operação em dados de produção. Item "logar tentativas implausíveis" (AC "considerar") fora do escopo mínimo desta fatia.Gate local: typecheck ✅ · build ✅ · persistence 33/0 ✅ · metrics 24/0 ✅. PR pede quórum (§7.2).
test: instala Vitest + suite anti-cheat de sanitizeResults no CI (#31) (
caioross commentedon Jul 22, 2026
🏛️ Parecer do Conselho · 2026-07-22
Veredito: PRECISA DECISÃO DO DONO — aplico
decisao-donoe aponto para a [Decisão] já aberta (#46).· AC1 (teto antes de persistir) — ✅ já na
main:sanitizeResultsdescarta linha comwpm > MAX_PLAUSIBLE_WPM(350) —src/lib/room.ts:100,143. Descarta, não clampa (correto: clampar premiaria o trapaceiro).· AC2 (filtrar na leitura) — ⏸ pronto, parado:
getLeaderboard/getRecentMatches(src/lib/supabase.ts:48-61,64-79) são filtrados pela PR fix(leaderboard): oculta WPM implausível do ranking público (#28) #30, que está DRAFT +decisao-donodesde 18/07 (quórum 2 APROVA / 1 VETA).· AC3 (sanear a linha bruta de 3596 WPM) — 🔒 dono:
delete/updateem dados de produção é núcleo irredutível, HANDBOOK §7.1.· AC4 (log de tentativas implausíveis) — o único resto executável, e sozinho não muda uma linha do
/leaderboard: fazer só isso deixaria a issue P1 aberta com o sintoma público intacto e daria falsa sensação de progresso.Conclusão de engenharia: não existe fatia que o Resolvedor possa entregar hoje que mova o sintoma. O gargalo é uma resposta de uma linha em [Decisão] Leaderboard cap (#28/#30): defesa-em-profundidade + deletar linha bruta vs. teto na view #46, não investigação.
matches/scores/leaderboardtêm RLSusing (true)+grant select to anon(supabase/migrations/0001_coderacer_init.sql:58-69) e a anon key viaja no bundle do cliente — logosupabaseBrowser.from("leaderboard").select().gt("wpm", 350)reexibe o dado bruto, contornando qualquer filtro de aplicação. Isto não é regressão da fix(leaderboard): oculta WPM implausível do ranking público (#28) #30: é a natureza de um leaderboard público sem login. Implicação prática: filtro emsrc/lib/supabase.tsé camada, não fechadura. Só duas coisas fecham de verdade — (a) o dado não existir (opção (i) de [Decisão] Leaderboard cap (#28/#30): defesa-em-profundidade + deletar linha bruta vs. teto na view #46: merge +delete from scores where wpm > 350;) ou (b) o teto morar na própria view (opção (ii): migração aditivacreate or replace view leaderboard ... where wpm <= 350). As duas são compatíveis e o custo marginal de (ii) é um arquivo emsupabase/migrations/— o dono continua sendo quem aplica (§8)./leaderboardestá correta, o dado é que mente. Enquantohackerman · 3596 WPMestiver no topo, a mesma tela que a persona O Iniciante chamou de encantamento nº1 (ver o próprio nome no ranking, D#14/D#27) é a que queima o Cético e o Competitivo (spec §0.3)..lte()/whereserver-side sobre leitura já limitada por.limit()(supabase.ts:55,73); a variante (ii) roda no plano da view, sem custo perceptível.decisao-donoaplicado, o Resolvedor de amanhã pula limpo para Infra/UI: harness só-de-dev para verificar telas pós-corrida (Results/pódio) sem jogar #37/Crescimento: tela de Resultado não tem "compartilhar" — transformar cada corrida terminada em convite (loop viral) #22 (Top-3 do Curador) em vez de bater nesta porta.Plano sugerido:
create or replace view public.leaderboard ... where wpm <= 350(mantendo o restante do contrato da view de0001), numerada pormax(origin/main + PRs abertas) + 1→ hoje0006_(0004está na PR fix(security): allowlist de language/difficulty nas rotas de sala (#35) #38 e0005na PR fix(security): kick com autoridade de servidor via kicked_ids (#39) #44); só cria o arquivo, o dono aplica (HANDBOOK §8). Ideal: derivar o350de um comentário apontandoMAX_PLAUSIBLE_WPM(src/lib/room.ts:100) para o teto de escrita e o do banco nunca divergirem.caioross commentedon Jul 26, 2026
🧹 Poda de domingo — escopo restante desta issue mudou
Atualização factual (não é decisão, a
decisao-donocontinua de pé): 2 dos 4 acceptance criteria já estão namain, entregues por outro caminho.sanitizeResultsdescarta resultado com WPM fora de0..MAX_PLAUSIBLE_WPM(350) —src/lib/room.ts:213,256.550da7f):getLeaderboardusa.lte("wpm", MAX_PLAUSIBLE_WPM)egetRecentMatchesusa.or(winner_wpm.is.null, winner_wpm.lte.350)—src/lib/supabase.ts:59,81. Reusa a mesma constante do guard de escrita, então teto de leitura e de escrita não divergem. Era exatamente o que a PR fix(leaderboard): oculta WPM implausível do ranking público (#28) #30 (fechada) propunha; o efeito prático do garfo (i) da discussion [Decisão] Leaderboard cap (#28/#30): defesa-em-profundidade + deletar linha bruta vs. teto na view #46 já está em produção.delete from scores where wpm > 350;é operação em produção (HANDBOOK §7.1). O registro de 3596 WPM não aparece mais no/leaderboard, mas continua na tabela — e, como a lente Ofensiva apontou na [Decisão] Leaderboard cap (#28/#30): defesa-em-profundidade + deletar linha bruta vs. teto na view #46, a view temusing(true)+grant select to anon, então uma query direta com a anon key ainda o reexibe.Portanto o que resta aqui é: (a) o
deletede uma linha em produção — ação do dono, indelegável; e (b) opcionalmente o AC #4. A parte de código endereçável por agente acabou. Se o dono rodar odelete, esta issue pode fechar e o AC #4 vira issue própria (P3,area:security).