Pular para o conteúdo
Sunstone Apps
← Voltar para o blog
Game Design

Quando validação prestativa vira trapaça: as notas de Sudoku que vazavam a resposta

Uma validação que rejeita input 'inválido' soa responsável. Num jogo de puzzle, ela pode entregar a solução ao jogador em silêncio, um toque rejeitado de cada vez.

Publicado: 7 min de leitura
Imagem de prévia OpenGraph deste artigo. Quando validação prestativa vira trapaça: as notas de Sudoku que vazavam a resposta

No Daily Sudoku: Offline Puzzle você pode fazer anotações de candidatos: números pequenos nos cantos de uma célula vazia para marcar o que talvez entre ali. É um gesto básico de Sudoku no papel, e por um tempo a nossa versão digital teve um bug que parecia exatamente uma feature.

Quando você tentava adicionar uma nota que já aparecia na mesma linha, coluna ou bloco, o app recusava. O toque não fazia nada. Na superfície isso parece uma proteção organizada: por que deixar alguém anotar um 7 se já existe um 7 na linha? Não pode ser a resposta, então bloquear parece útil.

Não está ajudando. Está jogando pelo jogador, e pior, está vazando a solução um toque rejeitado por vez.

Por que bloquear notas “impossíveis” é um exploit

Boa parte de resolver Sudoku na mão é descobrir quais candidatos são de fato possíveis em cada célula. Você varre a linha, a coluna, o bloco, e elimina. Essa eliminação é o puzzle. É o trabalho que o jogador deveria fazer.

Agora imagine que o app faz essa varredura por você, em silêncio, toda vez que você tenta escrever uma nota. Você toca 7 numa célula e nada acontece. Você acabou de aprender, de graça, que 7 já está usado em algum lugar da linha, coluna ou bloco daquela célula. Você não varreu nada. A rejeição te contou.

Multiplique isso pelo tabuleiro. Um jogador que nem sabe as regras pode extrair informação por força bruta, tocando cada dígito em cada célula e vendo quais “colam”. O conjunto de notas aceitas é exatamente o conjunto de candidatos legais, uma parte grande do trabalho dedutivo. O bloqueio transformou a ferramenta de notas numa calculadora de candidatos sob demanda, disponível até para quem nunca aprendeu a resolver.

Esse é o exploit: uma mecânica que deixa o jogador pular o desafio pretendido por um efeito colateral que o designer não quis. A parte desconfortável é que ele se escondeu dentro de uma checagem que, no code review, parece programação defensiva.

O código que parecia correto

A lógica problemática vivia na função de alternar nota. Simplificada:

export function toggleNote(state, index, value) {
  // ...guardas para células fixas/preenchidas...
  const cellNotes = new Set(state.notes[index]);
  if (cellNotes.has(value)) {
    cellNotes.delete(value);
  } else {
    if (!canPlaceCandidate(state.boardCurrent, index, value)) {
      return { ok: false, reason: "invalid_candidate", state };
    }
    cellNotes.add(value);
  }
  // ...
}

canPlaceCandidate checa se value conflita com algum peer já no tabuleiro. É uma função perfeitamente boa. O problema não é a função, é onde ela foi chamada. Submeter uma nota manual a essa checagem significa que as próprias anotações do jogador são silenciosamente curadas por um solver.

O conserto é uma deleção:

} else {
  // Notas manuais são livres: o jogador pode anotar qualquer 1-9, mesmo um valor
  // que conflita com um peer. Filtrar por candidato legal aqui vazaria informação
  // de solução (quais números ainda são possíveis) e é um exploit.
  cellNotes.add(value);
}

Agora qualquer dígito de 1 a 9 entra como nota, exatamente como um lápis de verdade num papel de verdade. Se você quer escrever um 7 ao lado de uma célula que já tem um 7 na linha, pode. Talvez você esteja errado, talvez no meio de uma dedução, talvez só queira ali. O jogo não julga, porque julgar é vazar.

O ponto sutil: a mesma checagem está certa em outro lugar

O que torna esse bug útil de estudar é que o filtro de candidato idêntico continua no código, continua sendo chamado, e continua certo, no recurso de auto notas:

export function applyAutoNotes(state) {
  const notes = createEmptyNotesBoard();
  for (let index = 0; index < 81; index += 1) {
    if (isFixedOrFilled(state, index)) continue;
    notes[index] = NOTE_VALUES.filter((value) =>
      canPlaceCandidate(state.boardCurrent, index, value),
    );
  }
  return { ...state, notes };
}

Auto notas é um botão explícito, opt-in. Quando você aperta, está pedindo ao jogo para preencher os candidatos legais por você. Isso é um auxílio que o jogador escolhe, sabendo que é um auxílio, do mesmo jeito que muitos apps de Sudoku oferecem. Filtrar por candidato legal ali é o objetivo inteiro.

Então o mesmíssimo helper, canPlaceCandidate, é um exploit num ponto de chamada e uma feature em outro. A diferença é consentimento e intenção. Nas auto notas o jogador pediu candidatos calculados. Nas notas manuais o jogador pediu para escrever um número, e o cálculo aconteceu pelas costas dele. Mesmo código, ética oposta, decidida inteiramente por quem iniciou e se sabe o que está recebendo.

Vale guardar isso como regra de design: auxílio tudo bem quando é pedido e legível, e é trapaça quando é silencioso e automático. A linha não é “o app sabe a resposta”. O app sempre sabe a resposta. A linha é “o jogador pediu para o app usar o que ele sabe”.

Protegendo com um teste

O teste unitário antigo na verdade afirmava o bug. Ele se chamava blocks notes that conflict with row, column, or block candidates e checava que notas conflitantes retornavam invalid_candidate. Essa é a armadilha de testes escritos a partir do comportamento da implementação, não da intenção do produto: eles travam o que o código fazia, inclusive a coisa errada, e depois defendem isso em refatorações futuras.

Invertemos para afirmar o comportamento pretendido:

it("allows manual notes that conflict with row, column, or block peers", () => {
  const state = createState();
  const noteSeven = toggleNote(state, asCellIndex(0), 7);
  expect(noteSeven.ok).toBe(true);
  if (!noteSeven.ok) return;
  expect(noteSeven.state.notes[0]).toEqual([7]);
});

Agora a rede de regressão pega o exploit voltando, não o conserto. O tipo de resultado invalid_candidate foi removido por inteiro, porque nada mais o produz, o que é uma pequena vitória contra ramos mortos apodrecendo na máquina de estado.

Como achar essa classe de bug no seu próprio jogo

O complicado é que um oráculo acidental quase nunca aparece como crash, teste quebrado ou linha vermelha no review. Ele aparece como uma feature conveniente demais. Algumas perguntas expõem a maioria deles.

Primeiro, liste tudo o que o jogo rejeita e pergunte o que cada rejeição ensina. Não “a rejeição está correta”, mas “se eu estivesse tentando vencer sem jogar, observar essa rejeição ajudaria?”. Se a resposta honesta é sim, você achou um oráculo. No nosso caso, “notas rejeitam candidatos ilegais” falha nesse teste na hora: observar as rejeições reconstrói a grade de candidatos.

Segundo, separe comportamento automático de comportamento pedido. Tudo que o jogo computa em nome do jogador deveria ser rastreável a um botão que ele apertou ou uma opção que ligou. Se um cálculo acontece como efeito colateral de uma ação não relacionada, como escrever uma nota, trate como suspeito por padrão.

Terceiro, tente trapacear de propósito. A forma mais rápida de termos pego isso antes era sentar com a intenção de um jogador preguiçoso: selecionar uma célula, tocar os nove dígitos e ver o que o app diz. Essa sondagem de trinta segundos revela o vazamento na hora, enquanto cem playtests honestos nunca o disparam, porque jogadores honestos não tocam dígitos que já sabem estar errados.

Quarto, fique de olho em validação que consulta estado oculto. canPlaceCandidate lê o tabuleiro para responder uma pergunta sobre o espaço de solução. Qualquer validador que consulta justamente aquilo que o jogador tenta deduzir merece uma segunda olhada sobre se o seu “não” está fazendo mais do que proteger integridade de dados.

A lição mais ampla: validação é um canal

Validação de input costuma ser enquadrada como pura defesa: rejeitar dado ruim, proteger o sistema. Mas toda rejeição também é uma mensagem para quem enviou o input. Num formulário, “e-mail já cadastrado” é uma mensagem útil e também um vazamento de enumeração de contas. Num puzzle, “você não pode anotar isso aqui” é uma conveniência e também um vazamento de solução.

Em qualquer fronteira de validação, pergunte também o que a mensagem de erro revela. Na maioria das vezes a resposta é inofensiva. Às vezes, como aqui, a rejeição é o próprio vazamento. Quando a coisa contra a qual você valida é exatamente o segredo que o usuário está tentando descobrir, a validação deixa de ser guarda e vira oráculo.

Num Sudoku isso é os candidatos e a solução. Num login é a existência da conta. Numa economia de jogo pode ser se um item é fabricável com ingredientes ocultos. O padrão generaliza: se um “não” prestativo vaza um fato que o usuário deveria conquistar, o auxílio é o exploit.

Nossas notas aceitam qualquer número agora, o botão de auto notas ainda calcula candidatos legais quando o jogador pede, e a única coisa que perdemos foi uma proteção que resolvia parte do puzzle em silêncio para qualquer um que tocasse rápido o bastante.

Falar com a Sunstone Apps