Skip to content

Conteúdo/Revanche: "Jogar de novo" pode sortear o MESMO snippet — 20% (33% em 10 linguagens) de repetir o código recém-digitado, com o WPM memorizado indo ao ranking (snippets.ts:3668-3680) #115

Closed

Description

@caioross

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ão

Sorteio 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)

  1. Game-feel: a revanche é o loop de retenção do produto; repetir o mesmo alvo é o pior resultado possível dele.
  2. 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 snippet no reset e só substituí-lo no start. 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 de snippet === null no 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, em route.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

  • pickSnippet aceita 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 pool vazio faria choice.title estourar TypeError — 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 action start ou 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 (toca src/app/api/rooms/**).
  • Classificação: toca src/app/api/rooms/** → HANDBOOK §7.2, PR non-draft com a linha Solicito 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 pedido

Se 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

  1. added
    P2Prioridade normal
    area:engineMotor de digitação, WPM, precisão, anti-cheat
    area:contentSnippets de código, linguagens, dificuldades
    on Jul 31, 2026
  2. caioross commented on Jul 31, 2026

    @caioross
    OwnerAuthor

    🏛️ 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 da origin; li a árvore da origin direto).

    • Engenharia: (a) é seguro — auditei todos os leitores de snippet, são três. RoomView.tsx:95-138 decide toda a fase por compatRoom.status (+ countdownN em :79-80); nenhum branch olha snippet == null. Race.tsx:27 faz room.snippet!, mas só é montado sob showRace (RoomView.tsx:80, exige status === "racing"). useRoom.ts:498 lê room?.snippet?.code.length ?? 0 — já é optional-chained e alimenta shouldFinishRace em :518, guardado por status. Zero derivação de nulidade → basta remover snippet: null de route.ts:243. O excludeTitle sai de graça: room já está carregado em route.ts:109, então route.ts:148 vira pickSnippet(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 title e 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 o TypeError em choice.title que 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 fallback langBucket[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 em pool.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ó:

      const pool = [langBucket[diff], langBucket.medium, langBucket.easy].find(p => p?.length) ?? [];
      if (!pool.length) throw new Error(`pool vazio: ${lang}/${diff}`); // ou fallback explícito
      const candidates = excludeTitle ? pool.filter(s => s.title !== excludeTitle) : pool;
      const from = candidates.length ? candidates : pool;   // ← degrada aqui, e só aqui

      Segundo consumidor: src/app/api/snippet/route.ts:26 (Treino Livre) também chama pickSnippet — 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) em rooms), um anônimo que leia uma sala em lobby passa a ver o snippet anterior em vez de null — conteúdo público, não segredo. Vale uma linha na PR, não uma mudança de rumo. Nada aqui toca sanitizeResults nem 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, sem reduced-motion envolvido.

    • 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) e rooms tem replica identity full (:45), então a linha inteira já trafega em cada update — manter o snippet povoado engorda os payloads de postgres_changes do 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, avaliando SNIPPETS (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:

    1. 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.ts não muda.
    2. src/app/api/rooms/[code]/route.ts:148 — passe room.snippet?.title. Não reordene: room vem do SELECT de :109, antes do update.
    3. src/app/api/rooms/[code]/route.ts:243 — remova snippet: null do patch do reset. Mantenha start_at: null e results: null. Atualize o comentário de :232-238 dizendo por que o snippet agora sobrevive ao reset (senão a próxima rodada "conserta" isso de volta).
    4. Testes em src/lib/snippets.test.ts (novo, ao lado de room.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.length nos 72 buckets — é a guarda que faz a exclusão-por-título continuar válida; (iii) todos os 72 buckets com length > 0; (iv) pool de 1 candidato → devolve ele, sem lançar; (v) excludeTitle de outra linguagem → no-op (acontece de verdade: dá para trocar o idioma no lobby entre partidas via settings, route.ts:117-140).
    5. Gate: pnpm typecheck && pnpm build, vitest, node scripts/validate-persistence.mjs. §7.2 — toca src/app/api/rooms/**: PR non-draft com a linha Solicito quórum (HANDBOOK §7).
    6. Na PR, declare os dois limites honestos: a exclusão é de 1 partida de memória (A→B→A continua possível), e o custo de payload do item Performance.

    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, cite snippets.ts:3671-3672.

  3. added
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 31, 2026
  4. caioross commented on Jul 31, 2026

    @caioross
    OwnerAuthor

    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.
    • start passa room.snippet?.title (lido do SELECT antes do update); reset deixa de zerar snippet. Auditei os três leitores — nenhum deriva fase de snippet == 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 ✅ · vitest 173/173 ✅ · validate-persistence 72/72 ✅ · lint N/A (sem config ESLint no repo). Toca src/app/api/rooms/** → §7.2, PR non-draft solicitando quórum.

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:contentSnippets de código, linguagens, dificuldadesarea:engineMotor de digitação, WPM, precisão, anti-cheat

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions