A nova ordem

  1. Projeto da grade pela IA: o modelo devolve só as coordenadas das casas pretas, sem letras. Pedimos simetria de 180°, de 40% a 60% de casas pretas e que evite listras, escadas longas e ilhas soltas.
  2. Validação e correção da grade: não confiamos na nota que o modelo dá a si mesmo; quem verifica é o programa. Ele confere se há simetria, se todas as casas brancas estão ligadas, se toda casa branca pertence a uma palavra de duas sílabas ou mais, se a sequência mais longa não passa do limite e se há sequências suficientes de quatro sílabas ou mais. Se algo não bate, ele corrige a grade cortando sequências longas, preenchendo as curtas e abrindo os pontos bloqueados.
  3. Preenchimento de palavras: preenche primeiro a sequência com menos candidatos compatíveis e, se travar, volta atrás e tenta outra palavra. Se a grade da IA não funcionar, usa uma grade desenhada de antemão (8 por tamanho), e se ainda assim não der, gera uma grade aleatória.
  4. Escrita das pistas pela IA: só a lista de palavras confirmadas na grade é passada ao modelo. Se ele acrescentar palavras fora da lista ou mudar a grafia, o resultado é descartado, e a resposta também não pode aparecer literalmente na pista.

O modelo é o gemma4 rodando no Ollama do Mac. Como ele é chamado por um endereço compatível com a OpenAI, dá para usar outros modelos só mudando a configuração.

Conhecimentos gerais e atualidades em vez de definições

Pistas que só perguntavam o significado da palavra eram fáceis demais. Por isso acrescentamos pistas no estilo de quiz, como estas.

  • A pessoa que disse que a vida, vista de longe, é uma comédia e, vista de perto, uma tragédia (Chaplin)
  • Um enorme buraco azul que se forma em áreas de calcário no mar (buraco azul)
  • O nome desta famosa ponte de São Francisco (Golden Gate)
  • O psicólogo que analisou os sonhos para falar do inconsciente (Freud)

Também acrescentamos pistas de atualidades: o país onde o desabamento de uma geleira causou uma grande enchente, a Copa do Mundo organizada em conjunto por Estados Unidos, México e Canadá, os Jogos Olímpicos de Inverno de Milão-Cortina e assim por diante. Toda vez que uma grade é criada, ele lê as manchetes do Google Notícias e traz mais palavras de artigos aleatórios da Wikipédia.

O banco de palavras tem hoje 1.102 palavras.

  • Comprimento: 568 de duas sílabas, 291 de três, 168 de quatro, 74 de cinco
  • Área: cotidiano 810, ciência 86, história 60, tecnologia 49, palavras antigas 34, cultura 30, geografia 21, atualidades 12
  • Pistas no estilo de quiz: 147
  • As palavras são classificadas como comuns, normais ou raras pela posição na frequência em legendas.

As 180 palavras que apareceram mais recentemente em um aparelho ficam guardadas e são excluídas da grade seguinte. Isso por causa do problema de as mesmas palavras aparecerem seguidas.

Dificuldade

  • Fácil: 8×8, 18% das casas brancas reveladas no início, respostas de até 5 sílabas
  • Médio: 10×10, 8% reveladas, até 6 sílabas
  • Difícil: 12×12, nada revelado, até 8 sílabas

Cada dificuldade também tem mínimos diferentes para o número de pistas horizontais e verticais e para os cruzamentos no meio. O difícil exige pelo menos 8 horizontais, 6 verticais e 6 cruzamentos no meio.

Saiu no meio? Continua de onde parou

No começo, o lobby tinha um botão de continuar. Se você escolhia o fácil enquanto estava no meio de uma grade difícil, aparecia o continuar, e ficava confuso se era uma grade nova ou uma que você estava retomando. Por isso mudamos o conceito do lobby.

  • No lobby só se criam jogos novos.
  • Se houver uma grade em andamento, você vai direto para a sala de jogo em vez de ficar no lobby.
  • Na sala de jogo, ao apertar lobby ou jogo novo, ele pergunta se você quer desistir do problema atual.

O problema seguinte era o registro sumir. Se você fazia a última entrada, fechava a janela e depois entrava no lobby, a grade que estava resolvendo era apagada do banco. Ela era apagada durante a limpeza das grades em andamento ao entrar no lobby. Agora, qualquer grade com pelo menos uma casa preenchida fica como "salvamento parcial", e só as grades sem nenhuma entrada são apagadas. A sala de jogo também tem um botão de salvar, e ao reabrir uma grade pela lista de registros você continua resolvendo.

A tela do celular

No celular, o teclado virtual cobria metade da tela. As pistas também eram mostradas com coordenadas como A1, o que as tornava difíceis de achar. Então mudamos.

  • Ao tocar no número da grade, aparece primeiro a pista horizontal, e as abas horizontal/vertical alternam entre elas.
  • Em vez de uma sílaba por vez, você escreve a resposta inteira e aperta enter, e as pistas horizontais são preenchidas na horizontal e as verticais na vertical.
  • Ao tocar em uma casa, o teclado não aparece de imediato, então dá para ver a grade primeiro.

O código real desta etapa

Esta é a API que cria uma grade nova. A ordem grade → palavras → pistas aparece claramente.

app/api/puzzle/route.tstypescript · 36 linhas
// 1. 모델이 글자 없는 판 구조를 먼저 제안한다.
const patternCandidates = await generateAiPatternCandidates(difficulty);
// 2. 판의 슬롯 길이가 정해진 뒤 후보 단어를 모아 실제로 채운다.
const dynamicBank = await loadDynamicBank(difficulty, avoidWords, { useLlm: false });
let puzzle: Puzzle | null = null;
let patternSource: "ai" | "curated" = "curated";
// AI의 판 초안을 목표 모양으로 삼고, 연결형 배치기가 실제 단어를 고른다.
for (const rows of patternCandidates) {
  puzzle = generatePuzzleFromRows(difficulty, rows, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
  if (puzzle) {
    patternSource = "ai";
    break;
  }
}
if (!puzzle) {
  puzzle = generatePuzzle(difficulty, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty, { extraBank: dynamicBank.entries });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty);
}

// 3. 최종 선택된 정답만 모델에 전달해 문제를 쓴다.
const selected = puzzle.across
  .concat(puzzle.down)
  .map(({ word, clue }) => ({ word, clue }));
const generatedClues = await generateCluesForWords(difficulty, selected);
puzzle = applyGeneratedClues(puzzle, generatedClues);

Esta é a parte que organiza as grades ao sair para o lobby. As grades vazias são apagadas, e as que têm entradas ficam como salvamento parcial.

lib/db.tstypescript · 12 linhas
for (const row of rows) {
  if (isEmptyBoard(row)) {
    d.prepare(`DELETE FROM moves WHERE code = ?`).run(row.code);
    d.prepare(`DELETE FROM games WHERE code = ?`).run(row.code);
    continue;
  }
  d.prepare(
    `UPDATE games
     SET status = 'abandoned', end_text = COALESCE(NULLIF(end_text, ''), '중간 저장'), updated_at = ?
     WHERE code = ?`,
  ).run(now, row.code);
}

O próximo post é "3. O processo de criação e seus problemas, revistos com estatísticas".