Neste post
  1. A separação
  2. Por que isso importa mais em idle que em outros gêneros
  3. O relógio é um parâmetro, não uma dependência
  4. O erro que apareceu por causa disso
  5. Ganho offline com teto: decisão de economia, não de código
  6. O que a separação custou
  7. Resumo

O Mewtopia Merge é um jogo de fundir gatinhos numa grade 5×6, com slots que destravam por nível, renda passiva que cresce exponencialmente, ganho offline com teto de duas horas, missões e recompensa diária com sequência de sete dias.

Descrito assim, parece um jogo. Descrito com honestidade de engenharia, é um conjunto de funções matemáticas com uma tela em cima.

Tratar dessa segunda forma mudou como o projeto foi construído — e encontrou um erro que a primeira forma esconderia por meses.

A separação

A regra foi simples e absoluta:

A lógica não sabe que existe tela. A tela não sabe como a lógica calcula.

Na prática, três camadas:

modelo/            Dart puro, sem import de Flutter
 ├─ economia.dart      renda por segundo, custo de upgrade
 ├─ merge.dart         regra de fusao, nivel resultante
 └─ offline.dart       ganho acumulado entre dois instantes

estado/            Riverpod, orquestra o modelo
apresentacao/      widgets e animacao

O detalhe que faz isso funcionar é a primeira linha da primeira camada: nenhum arquivo de modelo/ importa Flutter. Não é preferência de estilo — é uma restrição verificável. Se alguém importar, o teste do modelo passa a exigir ambiente de widget, e a propriedade se perde em silêncio.

Por que isso importa mais em idle que em outros gêneros

Num jogo de ação, o que decide a diversão é o feel — resposta ao toque, peso da animação, timing. Isso se avalia jogando, e teste automatizado ajuda pouco.

Num idle, o que decide tudo é a curva. Quanto rende cada nível, quanto custa o próximo, quanto tempo até o próximo marco. Essas são funções, e função se verifica com aritmética.

Pergunta de designComo responder
O nível 12 rende quanto por minuto?chamar a função
Em quanto tempo o jogador chega no nível 20?simular a curva
O ganho offline de 2 h supera 10 min ativo?comparar duas chamadas
Um upgrade fica bom demais em algum ponto?varrer a faixa inteira

Nenhuma dessas exige abrir o jogo. Todas são impossíveis se a fórmula estiver dentro de um widget.

O relógio é um parâmetro, não uma dependência

Este é o ponto que mais rende e o que mais se erra.

A versão intuitiva:

int ganhoOffline() {
  final agora = DateTime.now();          // <- aqui mora o problema
  final decorrido = agora.difference(ultimaSaida);
  ...
}

Ela é impossível de testar sem esperar de verdade, ou sem mexer no relógio do sistema.

A versão testável muda uma linha:

int ganhoOffline(DateTime agora, DateTime ultimaSaida, ...) {
  final decorrido = agora.difference(ultimaSaida);
  ...
}

Agora "duas horas depois" é um argumento. Testar o teto, testar o retorno de sete dias, testar o caso de relógio do aparelho andando para trás — tudo vira chamada de função.

Toda função que consulta o relógio por dentro é uma função que você não consegue testar. Vale para jogo, para assinatura, para promoção com prazo e para qualquer coisa que dependa de tempo. O relógio entra por parâmetro.

O erro que apareceu por causa disso

Com a economia isolada, escrevemos um teste que fazia uma coisa que ninguém faz jogando: somar o rendimento passo a passo por dez mil ciclos e comparar com o valor calculado direto pela fórmula fechada.

Os dois números não bateram.

A causa era arredondamento. O rendimento por ciclo era double, e o acumulado também. Somar milhares de valores fracionários em ponto flutuante acumula um erro que, individualmente, é invisível — e que ao longo de uma sessão longa produzia divergência entre o que a tela mostrava e o que o estado guardava.

A correção:

  • acumulado em inteiro, sempre — a menor unidade de moeda é a unidade
  • double só para taxa e multiplicador
  • arredondamento numa direção só, documentada, aplicado num ponto só

Esse erro não apareceria em teste de interface, não apareceria jogando dez minutos, e apareceria como reclamação de jogador semanas depois — no formato mais difícil de investigar que existe: "meu número tá errado".

Bug de arredondamento em economia de jogo não é bug de matemática. É bug de confiança, e ele chega até você como um jogador irritado, não como um erro no log.

Ganho offline com teto: decisão de economia, não de código

Vale explicar o teto de duas horas, porque parece arbitrário e não é.

Sem teto, quem volta depois de uma semana recebe um valor que torna todo o progresso anterior irrelevante. O jogo se resolve sozinho, e quem joga todo dia é efetivamente punido por jogar.

Com teto baixo demais, o retorno deixa de recompensar e o jogador não volta.

O teto é o botão que equilibra os dois, e é o tipo de número que precisa ser ajustável sem publicar versão nova — o que leva direto à conclusão do post sobre economia no servidor: número de balanceamento no cliente é número que você não pode corrigir.

O que a separação custou

Honestamente: pouco, mas não zero.

Mais arquivos e mais indireção. Uma alteração simples de fórmula toca duas camadas em vez de uma. Para quem está começando o projeto, parece burocracia.

Tentação de furar. A hora em que a lógica precisa de um dado que só a tela tem é a hora em que alguém importa Flutter no modelo "só dessa vez". A defesa é tratar isso como erro de compilação, e não como convenção.

Duplicação aparente. Às vezes o estado precisa de uma versão do dado formatada para exibição, e ela parece repetição do modelo. Não é — é tradução, e ela pertence à camada de apresentação.

Resumo

  • Em idle, o produto é a curva. Curva é função, e função se verifica com

aritmética.

  • Nenhum arquivo do modelo importa Flutter. A restrição precisa ser verificável,

não combinada.

  • Relógio entra por parâmetro. Toda função que chama o relógio por dentro é

intestável.

  • Acumulado em inteiro; double só para taxa. Soma repetida de ponto flutuante

acumula erro invisível.

  • Simular milhares de ciclos e comparar com a fórmula fechada encontra o que

jogar não encontra.

  • Teto de ganho offline é decisão de economia, e precisa ser ajustável sem

publicar versão.

Perguntas frequentes

Como testar ganho offline sem esperar duas horas?

Injetando o relógio. Se a função recebe o instante atual como parâmetro em vez de chamar o relógio do sistema por dentro, testar duas horas é passar um número diferente. Toda função que consulta o relógio internamente é uma função que você não consegue testar.

Por que teto no ganho offline?

Sem teto, quem volta depois de uma semana recebe tanto que o jogo perde sentido — e quem joga todo dia é punido por jogar. O teto mantém o retorno diário valendo a pena. É decisão de economia, não técnica.

double ou inteiro para moeda de jogo?

Inteiro para o valor acumulado, sempre. Ponto flutuante acumula erro em soma repetida, e num idle a soma acontece milhares de vezes. Use double só para taxa e multiplicador, e arredonde numa direção só, documentada.

Vale escrever teste para jogo casual?

Para a interface, raramente compensa. Para a economia, sempre — é a parte que, quando erra, ou quebra o balanceamento ou tira dinheiro de alguém. E é a parte mais barata de testar, porque é função pura.