Repository navigation
Description
O problema
"Jogar de novo" é o CTA primário da tela de Resultado (Results.tsx:141-150 → actions.resetToLobby → o líder inicia de novo). Toda revanche passa por start, que sorteia assim:
// src/lib/snippets.ts:3668-3680
export function pickSnippet(language: string, difficulty: string): PickedSnippet {
...
const pool = langBucket[diff] || langBucket.medium || langBucket.easy || [];
const choice = pool[Math.floor(Math.random() * pool.length)]; // ← uniforme, sem exclusãoSorteio uniforme sem excluir o snippet que a sala acabou de correr. Auditei a profundidade real do pool hoje em origin/main @ 098372e (300 snippets, 24 linguagens, todos os 72 buckets populados):
| Buckets | Linguagens | Snippets por dificuldade | P(repetir na revanche) |
|---|---|---|---|
| 14 langs | javascript typescript python java csharp cpp go rust sql bash ruby php kotlin swift |
5 | 20% |
| 10 langs | lua dart scala elixir erlang haskell julia clojure fortran cobol |
3 | 33% |
Ou seja: em 1 de cada 3 revanches nas 10 linguagens menores, os jogadores recebem o código que acabaram de digitar.
Por que importa (e não é só chateação)
- Game-feel: a revanche é o loop de retenção do produto; repetir o mesmo alvo é o pior resultado possível dele.
- Honestidade da métrica: quem repete o snippet já o memorizou — o WPM da 2ª corrida não mede a mesma coisa que o da 1ª. E esse WPM vai direto para o ranking global, sem nenhuma marca (
room.ts:399-412,buildScoreRows). Não é o furo da #34 nem o da #28 (não há forja aqui — o jogador digitou de verdade), mas a vítima é a mesma tabela, e o caminho é honesto o bastante para ninguém suspeitar.
A restrição que faz esta issue não ser trivial
No start o servidor não sabe qual foi o snippet anterior: o reset apaga a coluna.
// src/app/api/rooms/[code]/route.ts:242-247 (case "reset")
const res = await applyRoomUpdate(sb, code, "reset", {
status: "lobby",
snippet: null, // ← a memória do snippet anterior morre aqui
...Dois caminhos, e a recomendação é o (a):
- (a) Sem migração — preferido. Parar de zerar
snippetnoresete só substituí-lo nostart. O snippet anterior não é segredo (foi entregue a todos os jogadores na corrida que acabou de terminar), então mantê-lo na linha até a próxima partida não vaza nada. Antes de fazer, confirme que nada deriva fase/UI desnippet === nullno lobby (useRoom.ts,RoomView.tsx,Lobby.tsx) — se derivar, essa dependência é que precisa ser corrigida, não a solução. - (b) Coluna nova (
last_snippet_title) via migração aditiva idempotente. Se escolher esta, a rota tem de degradar sem a coluna: o dono aplica migrações manualmente e o deploy sai antes. O repo já aprendeu isso na pele — a lição está comentada 10 linhas acima, emroute.ts:232-238(a 0005/kicked_ids): "o reset da partida NÃO pode depender da coluna nova... 'jogar de novo' morreria em silêncio". Repetir esse erro aqui quebraria o CTA primário do produto.
Acceptance criteria
-
pickSnippetaceita um título a excluir (assinatura sugerida:pickSnippet(language, difficulty, excludeTitle?)) e continua pura e determinística o bastante para teste (injete o RNG ou teste por exaustão de sorteios). - Com bucket de ≥2 snippets, o título excluído nunca é retornado — teste percorrendo as 24 linguagens × 3 dificuldades, cada snippet como exclusão, N sorteios cada.
- Com bucket de 1 snippet, retorna esse snippet (degrada para repetir em vez de falhar). Hoje um
poolvazio fariachoice.titleestourarTypeError— a nova versão não pode ter esse caminho. - Duas partidas consecutivas na mesma sala nunca trazem o mesmo
snippet.title(teste no nível da actionstartou do helper que ela chama). - "Jogar de novo" continua funcionando sem nenhuma migração aplicada pelo dono (ver caminho (b) acima).
- Gate:
pnpm typecheck·pnpm build·vitest·node scripts/validate-persistence.mjs(tocasrc/app/api/rooms/**). - Classificação: toca
src/app/api/rooms/**→ HANDBOOK §7.2, PR non-draft com a linhaSolicito quórum (HANDBOOK §7).
Fora de escopo (observado hoje, não abra PR por isto)
Auditando o pickSnippet encontrei um segundo defeito, latente — registro aqui para não se perder, mas ele não entra nesta fatia:
const langBucket = SNIPPETS[lang] || SNIPPETS.javascript; // cai para JS
const pool = langBucket[diff] || langBucket.medium || langBucket.easy; // cai de dificuldade
return { ..., language: lang as LangId, difficulty: diff }; // mas REPORTA o pedidoSe um dia uma linguagem entrar em LANGUAGES sem snippets (a #67 mostra que linguagens são adicionadas de tempos em tempos), o jogador corre código JavaScript e matches/scores gravam a linguagem pedida — envenenando exatamente a dimensão que a #92 está prestes a expor como filtro do ranking. Idem para dificuldade. Hoje isso não acontece: medi os 72 buckets e todos estão populados. É uma armadilha para o futuro, sem guarda de teste — vale uma issue própria em outra rodada.
Activity
caioross commentedon Jul 31, 2026
🏛️ Parecer do Conselho · 2026-07-31
Veredito: APROVADA PARA EXECUÇÃO — caminho (a) ratificado com prova, mais 1 AC adicional (o critério de degradação está escrito no eixo errado).
Auditado contra
origin/main @ 098372e(o clone do dono está atrás daorigin; li a árvore daorigindireto).Engenharia: (a) é seguro — auditei todos os leitores de
snippet, são três.RoomView.tsx:95-138decide toda a fase porcompatRoom.status(+countdownNem:79-80); nenhum branch olhasnippet == null.Race.tsx:27fazroom.snippet!, mas só é montado sobshowRace(RoomView.tsx:80, exigestatus === "racing").useRoom.ts:498lêroom?.snippet?.code.length ?? 0— já é optional-chained e alimentashouldFinishRaceem:518, guardado por status. Zero derivação de nulidade → basta removersnippet: nullderoute.ts:243. OexcludeTitlesai de graça:roomjá está carregado emroute.ts:109, entãoroute.ts:148virapickSnippet(room.language, room.difficulty, room.snippet?.title)— a linha lida antes do update.Achado que corrige a premissa da AC: a AC pede degradação por tamanho do bucket ("com bucket de 1, retorna esse snippet"), mas o eixo real é o pool filtrado, não o bucket. A exclusão é por
titlee nada garante que títulos sejam únicos dentro de um bucket — se um dia dois snippets do mesmo bucket compartilharem título, filtrar esvazia um bucket de 2 e oTypeErroremchoice.titleque a AC quer matar volta inteiro. Medi hoje: 0 buckets com título duplicado, mínimo de 3 títulos únicos por bucket → seguro hoje, sem guarda amanhã.Segundo achado, no mesmo
pickSnippet: a cadeia de fallbacklangBucket[diff] || langBucket.medium || langBucket.easy || [](snippets.ts:3672) não protege contra bucket vazio —[]é truthy em JS, então um bucket presente-porém-vazio passa direto pelos três||e chega empool.length === 0. Não é "falta um default": é o operador errado. O fix ingênuo (|| []no fim) não resolve nada.Forma que fecha os três casos num lugar só:
Segundo consumidor:
src/app/api/snippet/route.ts:26(Treino Livre) também chamapickSnippet— 3º parâmetro opcional o mantém intacto. A repetição no practice é da Practice/Fatia 3 — Contra seu ghost (PB): replay do melhor run em localStorage #83, não desta fatia.Segurança/Anti-cheat: Sem vetor novo. O snippet mantido na linha durante o lobby é o da corrida que acabou — já foi entregue a todos, e o
start(route.ts:148-153) o sobrescreve antes do countdown, então nada antecipa o próximo alvo. Efeito colateral real, 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)emrooms), um anônimo que leia uma sala em lobby passa a ver o snippet anterior em vez denull— conteúdo público, não segredo. Vale uma linha na PR, não uma mudança de rumo. Nada aqui tocasanitizeResultsnem o teto de WPM.UX/Game-feel: "Jogar de novo" é o CTA primário e único do líder (
Results.tsx:141-150); os demais só veem// aguardando líder reiniciar...(:152-154). Receber o mesmo código é o pior desfecho possível desse botão — e ninguém tem como recusar. Mudança é invisível quando funciona, que é o certo aqui: sem copy nova, sem animação, semreduced-motionenvolvido.Performance: Fora da área sagrada (roda no
start, não há tecla em voo). Um custo real, pequeno:snippetéjsonb(0002_realtime_rooms.sql:13) eroomstemreplica identity full(:45), então a linha inteira já trafega em cada update — manter o snippet povoado engorda os payloads depostgres_changesdo lobby entre partidas em ~1 snippet. Durante a corrida esse peso já existe hoje.pool.filter()sobre ≤5 itens é ruído. Não instrumente isso; só não afirme na PR que é custo zero.Produto: Prioridade P2 está correta e é o P2 de maior retorno da fila. A persona "O Competitivo" tem literalmente "repetição sem repetir conteúdo" na coluna "o que o conquista" (
docs/UI-AAA-OVERHAUL.md§0.3), e a §0.3 fecha dizendo que o produto otimiza para ela e para o Cético Sênior. A revanche é o loop de retenção; 20%/33% de chance de matá-lo é caro. Confirmei a medição do 🗂️ Curador de forma independente, avaliandoSNIPPETS(snippets.ts:12) em vez de estimar: 24 linguagens · 72 buckets · 300 snippets · tamanhos {3: ×30, 5: ×42} — exatamente as 10 linguagens com 3 e as 14 com 5 da tabela da issue.Plano sugerido:
src/lib/snippets.ts:3668—pickSnippet(language, difficulty, excludeTitle?: string). Aplique o bloco acima: fallback por.length(não por truthiness), exclusão, e degradação por pool filtrado vazio, não por bucket de 1. Assinatura retrocompatível →api/snippet/route.tsnão muda.src/app/api/rooms/[code]/route.ts:148— passeroom.snippet?.title. Não reordene:roomvem do SELECT de:109, antes do update.src/app/api/rooms/[code]/route.ts:243— removasnippet: nulldo patch doreset. Mantenhastart_at: nulleresults: null. Atualize o comentário de:232-238dizendo por que o snippet agora sobrevive ao reset (senão a próxima rodada "conserta" isso de volta).src/lib/snippets.test.ts(novo, ao lado deroom.test.ts): (i) exaustivo — 24 langs × 3 dificuldades, cada título como exclusão, N sorteios, o excluído nunca sai; (ii) invariante de unicidade:new Set(bucket.map(s => s.title)).size === bucket.lengthnos 72 buckets — é a guarda que faz a exclusão-por-título continuar válida; (iii) todos os 72 buckets comlength > 0; (iv) pool de 1 candidato → devolve ele, sem lançar; (v)excludeTitlede outra linguagem → no-op (acontece de verdade: dá para trocar o idioma no lobby entre partidas viasettings,route.ts:117-140).pnpm typecheck && pnpm build,vitest,node scripts/validate-persistence.mjs. §7.2 — tocasrc/app/api/rooms/**: PR non-draft com a linhaSolicito quórum (HANDBOOK §7).Fora de escopo, confirmado: o fallback silencioso lang/difficulty que a issue já registra continua fora — mas note que ele e o bucket-vazio acima são defeitos distintos no mesmo
||. Uma issue só cobre os dois; quem abrir, citesnippets.ts:3671-3672.caioross commentedon Jul 31, 2026
Resolvida em PR #116 — segui o caminho (a) do 🏛️ Parecer, com as duas correções que ele fez sobre a issue: degradação pelo pool filtrado (não pelo tamanho do bucket) e o bucket vazio tratado como operador errado (
[]é truthy) em vez de default faltando.pickSnippet(language, difficulty, excludeTitle?)— 3º parâmetro opcional, Treino Livre intacto.startpassaroom.snippet?.title(lido do SELECT antes do update);resetdeixa de zerarsnippet. Auditei os três leitores — nenhum deriva fase desnippet == null. Sem migração.src/lib/snippets.test.ts(15 testes): invariantes do pool (72 buckets populados, títulos únicos por bucket), exclusão exaustiva 24 langs × 3 dificuldades, partidas consecutivas, e os 4 casos de degradação.Gate:
typecheck✅ ·build✅ ·vitest173/173 ✅ ·validate-persistence72/72 ✅ · lint N/A (sem config ESLint no repo). Tocasrc/app/api/rooms/**→ §7.2, PR non-draft solicitando quórum.fix(snippets): revanche não sorteia o mesmo snippet da corrida anteri…
fix(practice): "treinar de novo" não repete o snippet recém-digitado (#…