Executando verificação de segurança...
1

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!

Carregando publicação patrocinada...