Como estruturei o sistema de sorteios do meu app (e por que ninguém ganhou ainda)
No meu app, o Zetho, criei uma nova funcionalidade de sorteios gratuita. Nela, os usuários utilizam Z-Coins (moedas conquistadas por meio de interações no app, bem parecido com as TabCoins aqui do TabNews) para participar de sorteios e concorrer a chaves de jogos.
Quando pensei em construir essa feature, minha principal preocupação era criar uma implementação que funcionasse de forma eficiente, justa e completamente honesta, onde os usuários tivessem probabilidades reais de ganhar.
A Regra de Negócio e a Lógica
A lógica por trás é simples: cada sorteio tem uma probabilidade base (basicProbability) que define o intervalo de números no qual o drawNumber (número sorteado) será gerado.
- O dilema da probabilidade: Inicialmente, pensei em uma probabilidade de 1 para 10.000, mas seria difícil demais. Decidi fixar o padrão em 1 para 1.000, adaptando para mais ou para menos dependendo da raridade do jogo.
- Evitando "baleias" (anti-farm): Cada usuário só pode comprar até 10 tickets por dia para o mesmo sorteio. Fiz isso para garantir que um usuário que farma pontos indefinidamente não tire a chance de quem interage menos. No final, cada ticket comprado dá ao usuário uma chance de 1 para
basicProbability.
Abaixo está o método em TypeScript (NestJS) que lida com a compra e a checagem do sorteio em tempo de execução:
async purchaseTickets(
raffleId: string,
userId: string,
dto: PurchaseTicketsDto,
) {
const raffle = await this.getRaffleOrThrow(raffleId);
if (!raffle.active) {
throw new BadRequestException('Sorteio não está ativo');
}
const basicProbability = raffle.basicProbability ?? 1000;
const ticketPrice = raffle.ticketPrice ?? 30;
const baseTotalCost = dto.ticketCount * ticketPrice;
await this.validateDailyTicketLimit(raffleId, userId, dto.ticketCount);
const profile = await this.getProfileByUserId(userId);
const currentBalance = Number(profile.zcoinBalance ?? 0);
const bonus = await this.getMonthlyInviteBonus(userId);
const discountPercent = bonus.bonusPercent;
const discountAmount = baseTotalCost * (discountPercent / 100);
const totalCost = Math.max(0, Math.floor(baseTotalCost - discountAmount));
if (currentBalance < totalCost) {
throw new BadRequestException(
`Saldo insuficiente. Necessário: ${totalCost} ZCoins, disponível: ${currentBalance}`,
);
}
// Gera os números da sorte do usuário e o número sorteado na mesma execução
const luckyNumbers = Array.from({ length: dto.ticketCount }, () =>
String(randomInt(1, basicProbability + 1)),
);
const drawNumber = String(randomInt(1, basicProbability + 1));
const won = luckyNumbers.includes(drawNumber);
const tx = await this.tablesDB.createTransaction();
const entryId = ID.unique();
const txId = ID.unique();
try {
await this.tablesDB.createOperations({
transactionId: tx.$id,
operations: [
{
action: 'decrement',
databaseId: RaffleService.DB_ID,
tableId: 'user_profiles',
rowId: profile.$id,
data: { value: totalCost, column: 'zcoinBalance' },
},
],
});
await this.tablesDB.createRow({
transactionId: tx.$id,
databaseId: RaffleService.DB_ID,
tableId: 'zcoin_transactions',
rowId: txId,
data: {
userId,
amount: -totalCost,
type: 'output',
reason: 'raffle_ticket_purchase',
},
});
await this.tablesDB.createRow<RaffleEntry>({
transactionId: tx.$id,
databaseId: RaffleService.DB_ID,
tableId: 'raffle_entries',
rowId: entryId,
data: {
raffleId,
userId,
ticketCount: dto.ticketCount,
totalCost,
luckyNumbers,
won,
drawNumber,
},
});
await this.tablesDB.updateTransaction({
transactionId: tx.$id,
commit: true,
});
} catch (error) {
await this.tablesDB.updateTransaction({
transactionId: tx.$id,
rollback: true,
});
throw error;
}
let wonKey: GameKey | null = null;
if (won) {
wonKey = await this.deliverKey(raffleId, userId);
if (wonKey) {
await this.handleRaffleClose(raffle);
} else {
await this.closeRaffle(raffleId);
}
}
const updatedProfile = await this.tablesDB.getRow<
Models.Row & { zcoinBalance?: number }
>({
databaseId: RaffleService.DB_ID,
tableId: 'user_profiles',
rowId: profile.$id,
});
return {
entryId,
raffleId,
userId,
ticketCount: dto.ticketCount,
baseTotalCost,
discountPercent,
discountAmount,
totalCost,
luckyNumbers,
drawNumber,
won,
wonKey: wonKey ? { platform: wonKey.platform, key: wonKey.key } : null,
newBalance: Number(updatedProfile.zcoinBalance ?? 0),
};
}
O problema real: Estatística vs. Realidade
Até agora, rodamos aproximadamente 34 sorteios e 129 tickets foram gerados no total. Ninguém ganhou ainda.
Olhando puramente para a matemática, com uma chance de 1/1000 por ticket e apenas 129 tentativas acumuladas no ecossistema, a probabilidade de termos um ganhador até agora era realmente baixa. Isso me fez pensar se a barreira inicial não ficou alta demais para o volume atual de usuários. Como reflexo disso, dei uma leve reduzida no basicProbability para tornar as coisas mais dinâmicas.
O que vocês acham dessa abordagem de gerar o drawNumber instantaneamente na compra do ticket em vez de acumular e rodar um cron posterior?
E, claro, se quiserem testar o app e me ajudar a encontrar os primeiros ganhadores dessas chaves, o Zetho está aberto. Feedbacks sobre o código e a lógica são super bem-vindos!