Por que dinheiro num jogo idle não pode ser número comum
O maior número que o JavaScript guarda é 1e308, e um idle chega lá em poucos dias. Quando chega, tudo vira infinito de uma vez, sem erro nenhum e sem aviso.
O teto que ninguém vê chegando
Todo número em JavaScript é um float de 64 bits, e o maior que ele consegue guardar é mais ou menos 1,8 × 10^308. É um número absurdo. É maior que a quantidade de átomos no universo observável elevada a três. Dá vontade de fechar a discussão aqui e usar número comum.
Só que jogo idle não cresce somando. Ele cresce multiplicando, e crescimento multiplicativo come qualquer teto em tempo bem menor do que a intuição sugere.
O problema de verdade não é chegar no teto: é o que acontece quando chega. JavaScript não reclama. Ele não lança exceção, não avisa no console, não retorna erro. O número simplesmente vira Infinity, e a partir dali toda conta que encostar nele também vira. O caixa é infinito, o preço é infinito, a diferença entre os dois é NaN, e a tela mostra "R$ NaN" para um jogador que passou três dias construindo aquilo.
Um save estourado não quebra na hora em que estoura: ele quebra na próxima vez que alguém faz uma subtração. O rastro já sumiu, e o save do jogador é irrecuperável, porque a informação que faltava foi apagada pelo próprio arredondamento.
Quanto tempo leva até estourar
A escada do FutCapitalist tem dez degraus, e o último custa cerca de 2,6 × 10^10 na primeira unidade. Parece longe de 10^308, e está. Mas cada unidade comprada encarece a próxima em 7%, e esse é o detalhe que muda tudo: comprar 500 unidades de um negócio multiplica o preço dele por 1,07^500, que é aproximadamente 10^14.
Multiplique isso pelos marcos (que multiplicam o rendimento), pela divisão do clube, pelos troféus e pela torcida acumulada de várias vidas, e o número que o jogador vê na tela passa de 10^308 sem nenhum evento especial acontecendo. É só terça-feira.
Quem joga idle há algum tempo conhece isso: existem jogos famosos do gênero cujo final de partida é, literalmente, a barreira do float. O jogo trava num número e não sobe mais, porque não tem mais para onde subir.
A solução é guardar o número em duas partes
A saída é não guardar o valor, e sim a mantissa e o expoente separados. Em vez de tentar armazenar o número inteiro, você guarda "1,234" e "308" em campos diferentes, e implementa as operações em cima desse par. Somar dois números com expoentes muito distantes vira uma operação barata (o menor simplesmente desaparece no arredondamento, como acontece na matemática de verdade), e multiplicar vira somar expoentes.
Com isso o teto sai de 10^308 para algo perto de 10^(10^9), que é um número grande o bastante para não ser alcançável por um jogador humano em nenhuma quantidade de vidas.
// o que acontece com número comum
const caixa = 1e308 * 10 // Infinity
caixa - 1e308 // Infinity
caixa - caixa // NaN, e a tela mostra "R$ NaN"
// o que acontece com Decimal
const caixa2 = d('1e308').times(10) // 1e309, e a vida segueO que isso custa
Nada é de graça. Um Decimal é um objeto, então cada operação aloca memória onde antes havia uma instrução de processador. Num jogo que avança o estado muitas vezes por segundo, isso importa, e a regra que sobrou foi simples: dinheiro, produção, custo, força e torcida são Decimal; tempo em milissegundos, contagem de unidade e índice de lista continuam sendo número comum.
Essa fronteira não é decorativa. Tempo precisa ser número comum porque ele é comparado e dividido milhares de vezes por segundo no avanço do relógio, e nunca chega perto do teto: 10^308 milissegundos são mais do que a idade do universo várias vezes.
A parte chata é que a fronteira não se defende sozinha. Um number onde deveria haver Decimal compila limpo, roda limpo, passa no teste e só aparece no save de alguém três semanas depois. Por isso o projeto roda a verificação de tipos como passo separado dos testes: o interpretador que roda os testes ignora os tipos, então sem esse passo o erro chegaria em produção sem nunca ter sido impedido.
O detalhe que quase passou
Tem uma armadilha a mais, e ela é sutil. A mantissa do Decimal continua sendo um float. Ela não estoura, mas ela arredonda.
Isso significa que somar oito horas de produção de uma vez não dá exatamente o mesmo resultado que somar 288.000 parcelas de 100 milissegundos. A diferença é minúscula, na casa de 10^-15 em termos relativos, e não cresce com o tempo. Mas ela existe.
No teste que compara o avanço offline com o avanço normal, a contagem de ciclos é comparada com igualdade exata, porque ciclo é número inteiro e tem que bater na vírgula. Já o caixa é comparado com tolerância. Trocar os dois critérios de lugar dá um teste que reprova código correto ou aprova código errado, e os dois são péssimos.
É o tipo de coisa que só aparece quando você tenta provar que duas rotas diferentes chegam no mesmo lugar. E é exatamente por isso que vale tentar provar.
Também dá pra só jogar.
Jogar grátis agora