Skip to content

fix(snippets): revanche não sorteia o mesmo snippet da corrida anterior (#115) - #116

Merged
caioross merged 1 commit into
mainfrom
auto/issue-115-rematch-snippet
Jul 31, 2026
Merged

fix(snippets): revanche não sorteia o mesmo snippet da corrida anterior (#115)#116
caioross merged 1 commit into
mainfrom
auto/issue-115-rematch-snippet

Conversation

@caioross

Copy link
Copy Markdown
Owner

Contexto

"Jogar de novo" é o CTA primário e único do líder na tela de Resultado, e passa pela action start, que sorteava uniformemente sobre o bucket inteiro. Com 3–5 snippets por bucket, a revanche devolvia o código que os jogadores acabaram de digitar em 20% das partidas (33% nas 10 linguagens menores) — e o WPM dessa segunda corrida, já com o snippet memorizado, vai direto ao ranking global sem nenhuma marca. Não há forja aqui (o jogador digitou de verdade), mas a vítima é a mesma tabela da #28/#34.

Segui o plano do Parecer do Conselho (caminho (a), sem migração), incluindo as duas correções que o parecer fez sobre a issue original: a degradação é pelo pool filtrado, não pelo tamanho do bucket, e o bucket vazio era operador errado, não default faltando.

O que mudou e por quê

src/lib/snippets.ts

  • pickSnippet(language, difficulty, excludeTitle?) — o 3º parâmetro é opcional, então api/snippet/route.ts (Treino Livre) segue chamando com dois argumentos, inalterado.
  • Seleção do bucket extraída para resolvePool, escolhendo por .length em vez da cadeia langBucket[diff] || langBucket.medium || langBucket.easy || []: [] é truthy, então um bucket presente-porém-vazio atravessava os três || e chegava em pool.length === 0choice.title estourava TypeError. Esse caminho deixou de existir.
  • A exclusão é por título e a degradação acontece num lugar só, sobre o pool já filtrado: se filtrar esvaziar (bucket unitário, ou títulos repetidos dentro do bucket), repete em vez de falhar.
  • SNIPPETS passou a ser exportado para os testes versionarem os invariantes do pool. Nenhum componente de cliente importa este módulo — verifiquei no bundle buildado (abaixo).

src/app/api/rooms/[code]/route.ts

  • start passa room.snippet?.titleroom vem do SELECT do topo do handler, antes de qualquer update, então é o snippet da partida anterior. Primeira partida da sala → undefined → sorteio normal.
  • reset deixa de zerar snippet. Comentário no lugar explicando por que ele agora sobrevive ao reset, para a próxima rodada não "consertar" isso de volta. Auditei os três leitores: RoomView.tsx decide toda a fase por status (+ countdownN), Race.tsx:27 (room.snippet!) só monta sob status === "racing", e useRoom.ts:498 já é optional-chained e alimenta um maybeFinish guardado por status !== "racing". Zero derivação de nulidade — e zero migração, então o CTA continua funcionando no deploy sem o dono aplicar nada.

src/lib/snippets.test.ts (novo, 15 testes)
Invariantes do pool (os 72 buckets populados; títulos únicos por bucket — é o que torna "excluir por título" bem-definido; sem título/código vazio), cobertura do sorteio, exclusão exaustiva (24 langs × 3 dificuldades × cada título como exclusão × 60 sorteios), duas partidas consecutivas nunca repetindo, excludeTitle de outra linguagem como no-op (acontece de verdade: dá para trocar o idioma no lobby entre partidas), e os quatro casos de degradação com buckets injetados temporariamente (unitário, títulos repetidos, bucket vazio, linguagem inteira oca).

Validação (resultado real)

  • pnpm typecheck — verde.
  • pnpm build — verde (/room/[id] 13.8 kB / 230 kB First Load).
  • pnpm test (vitest) — 173 passaram / 7 arquivos, 15 deles novos.
  • node scripts/validate-persistence.mjs72 passaram, 0 falharam.
  • Lint: N/A — não há config ESLint no repo e a CI não roda lint.
  • Anti-teste-vazio: neutralizei a linha da exclusão e rodei o suite novo — 2 testes falharam exatamente onde deviam; restaurei e revalidei.
  • Pool não vaza para o cliente: grep -rl "Soma de array" .next/static → 0 ocorrências depois do build.

Riscos e limites honestos

  1. A memória é de UMA partida. A→B→A continua possível. É a fatia; não é regressão.
  2. Payload maior no lobby entre partidas. snippet é jsonb e rooms tem replica identity full, então a linha inteira já trafega em cada update — manter o snippet povoado engorda os eventos de postgres_changes do lobby em ~1 snippet. Durante a corrida esse peso já existe hoje. Não é custo zero; é pequeno e fora da área sagrada (roda no start, sem tecla em voo).
  3. Efeito colateral não-bloqueante: com a Segurança/RLS: "sala privada" é enumerável — policy using(true) em rooms expõe toda sala ao anon (0002_realtime_rooms.sql:26-29) #103 aberta (using(true) em rooms), um anônimo que leia uma sala em lobby passa a ver o snippet da corrida anterior em vez de null. É conteúdo já entregue a todos os jogadores daquela corrida, e o start o sobrescreve antes do countdown — ninguém antecipa o próximo alvo.
  4. resolvePool lança Error explícito se um dia nem a linguagem pedida nem javascript tiverem snippet. É inalcançável enquanto o invariante dos 72 buckets valer (testado), e é preferível a devolver undefined disfarçado de snippet.
  5. Fora de escopo, mantido de propósito: o fallback silencioso de linguagem/dificuldade (pickSnippet reporta a linguagem pedida mesmo caindo no pool de javascript) continua como está — a issue já o registra como armadilha futura, para issue própria.

Solicito quórum (HANDBOOK §7)

Closes #115

…or (#115)

O sorteio do `start` era uniforme sobre o bucket inteiro: com 3–5 snippets por
bucket, "jogar de novo" devolvia o código recém-digitado em 20% das partidas
(33% nas 10 linguagens menores), com o WPM já memorizado indo ao ranking global.

- `pickSnippet(language, difficulty, excludeTitle?)` tira o título da corrida
  anterior da urna; 3º parâmetro opcional mantém o Treino Livre intacto.
- Seleção do bucket por `.length` em vez da cadeia de `||`: `[]` é truthy, então
  um bucket presente-porém-vazio passava direto e estourava `TypeError` em
  `choice.title`. Degradação agora é pelo pool JÁ FILTRADO (bucket unitário ou
  títulos repetidos repetem em vez de falhar).
- `reset` deixa de zerar `snippet` — é a memória de uma partida que o `start` lê.
  Nenhuma tela deriva fase de `snippet == null` (RoomView/Race/useRoom decidem
  por `status`), e nada de migração: o CTA funciona no deploy sem o dono agir.
- `src/lib/snippets.test.ts` versiona os invariantes do pool (72 buckets
  populados, títulos únicos por bucket) e o contrato de exclusão.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Ready Ready Preview Jul 31, 2026 5:10pm

@caioross

Copy link
Copy Markdown
Owner Author

🩺 Parecer do PR Doctor — quórum §7.2 no SHA c54db74

Classificação: §7.2 (toca src/app/api/rooms/**), corpo traz a linha de quórum. Pré-requisitos conferidos: CI verde (Install, typecheck & build SUCCESS), mergeable: CLEAN, diff lido inteiro (3 arquivos, +262/-7). Gate reproduzido em worktree no SHA da PR: pnpm typecheck verde · pnpm test 173/173 em 7 arquivos · scripts/validate-persistence.mjs 72/72 · validadores das skills 45/45 e 45/45.

Veredito das 3 lentes adversariais — 3× APROVA

🔐 AppSec — APROVA. O export de SNIPPETS não vaza o pool: os únicos importadores são src/app/api/snippet/route.ts:2, src/app/api/rooms/[code]/route.ts:4 (ambos server, runtime = "nodejs") e o teste — nenhum módulo "use client" alcança o módulo, direta ou transitivamente. O excludeTitle não é envenenável: o único write de snippet é o próprio start (route.ts:156) com o retorno de pickSnippet, nunca do body, e a 0002_realtime_rooms.sql concede só select ao anon. O consumo é !== estrito (snippets.ts:3718) — sem SQL, sem regex, sem chave de objeto. service_role intocado.

⚔️ Ofensiva — APROVA. Antecipar o alvo não funciona: status: "racing", snippet e start_at vão num único UPDATE atômico (route.ts:154-159), então o que sobrevive no lobby é sempre o snippet passado — que é justamente o excluído do próximo sorteio (vantagem prática negativa). O throw novo é inalcançável por entrada de jogador: resolvePool cai para SNIPPETS.javascript antes dele e toda rota passa por allowlist (route.ts:122-135, snippet/route.ts:18-24). Os buracos encontrados — sem rate-limit no start, claim-leader sem autorização (route.ts:264-273) — são pré-existentes e declarados na #6/PR #12; esta PR não os amplifica.

🎯 Domínio — APROVA. Confirmada a afirmação "zero derivação de nulidade", leitor a leitor: RoomView.tsx:79-80 decide fase por status/countdownN; Race.tsx:27 (room.snippet!) só monta sob racing, e a PR torna essa asserção mais segura, não menos; useRoom.ts:498 alimenta maybeFinish, que sai cedo em status !== "racing"; room.ts:389 é null-safe; Lobby/Results/SpectatorView não leem snippet. resolvePool é equivalente-ou-melhor — provada a inclusão: o conjunto de entradas em que o novo lança é subconjunto das que o antigo já quebrava com TypeError em choice.title. comPoolFake usa try/finally, então não vaza estado entre testes. Treino Livre (snippet/route.ts:26) inalterado.

Registrado, não bloqueante (não abram PR por isto — vale issue própria)

  1. Colisão de títulos entre buckets. A exclusão é por título puro: "Soma de lista" ×9, "FizzBuzz" ×8, "Olá mundo" ×7, "Fatorial iterativo" em javascript/easy e java/easy. Trocar de linguagem no lobby entre partidas (route.ts:136-140 não limpa snippet) faz o filtro remover um snippet que ninguém digitou — degrada no sentido conservador (urna 5→4, com fallback), sem crash nem conteúdo errado. Comparar a tripla {title, language, difficulty} fecharia; os três campos já estão persistidos (room.ts:7-12). O teste snippets.test.ts:135 deveria se chamar "excludeTitle inexistente no bucket é no-op".
  2. Lacuna de cobertura: o elo reset preserva → start lê (route.ts:152route.ts:252-256) está provado só por leitura; os 15 testes cobrem pickSnippet, não a integração da rota.
  3. Defesa em profundidade: import "server-only" no topo de snippets.ts converteria um import de cliente futuro em erro de build, em vez de depender do comentário das linhas 12-14. E snippets.test.ts:106 (if (bucket.length < 2) continue) confia num invariante que snippets.test.ts:24 não impõe (> 0) — trocar para >= 2 fecha.

Aceite da #115 atendido item a item, incluindo "sem nenhuma migração aplicada pelo dono". Closes #115 correto: a armadilha do fallback silencioso de linguagem é explicitamente fora de escopo na própria issue.

Mergeando (squash).

@caioross
caioross merged commit 7197db1 into main Jul 31, 2026
3 checks passed
@caioross
caioross deleted the auto/issue-115-rematch-snippet branch July 31, 2026 21:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant