…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.
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.tspickSnippet(language, difficulty, excludeTitle?)— o 3º parâmetro é opcional, entãoapi/snippet/route.ts(Treino Livre) segue chamando com dois argumentos, inalterado.resolvePool, escolhendo por.lengthem vez da cadeialangBucket[diff] || langBucket.medium || langBucket.easy || []:[]é truthy, então um bucket presente-porém-vazio atravessava os três||e chegava empool.length === 0—choice.titleestouravaTypeError. Esse caminho deixou de existir.SNIPPETSpassou 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.tsstartpassaroom.snippet?.title—roomvem do SELECT do topo do handler, antes de qualquer update, então é o snippet da partida anterior. Primeira partida da sala →undefined→ sorteio normal.resetdeixa de zerarsnippet. 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.tsxdecide toda a fase porstatus(+countdownN),Race.tsx:27(room.snippet!) só monta sobstatus === "racing", euseRoom.ts:498já é optional-chained e alimenta ummaybeFinishguardado porstatus !== "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,
excludeTitlede 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.mjs— 72 passaram, 0 falharam.grep -rl "Soma de array" .next/static→ 0 ocorrências depois do build.Riscos e limites honestos
snippetéjsonberoomstemreplica identity full, então a linha inteira já trafega em cada update — manter o snippet povoado engorda os eventos depostgres_changesdo lobby em ~1 snippet. Durante a corrida esse peso já existe hoje. Não é custo zero; é pequeno e fora da área sagrada (roda nostart, sem tecla em voo).using(true)emrooms), um anônimo que leia uma sala em lobby passa a ver o snippet da corrida anterior em vez denull. É conteúdo já entregue a todos os jogadores daquela corrida, e ostarto sobrescreve antes do countdown — ninguém antecipa o próximo alvo.resolvePoollançaErrorexplí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 devolverundefineddisfarçado de snippet.pickSnippetreporta 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