Skip to content

Sistema de Eventos / Battle Pass

Documentacao completa do sistema de eventos, passe de batalha, sistema de XP, recompensas por nivel e mini-game "Atire na Caixa" (Event Shoot).


Indice

  1. Visao Geral
  2. Schema do Banco de Dados
  3. Sistema de XP
  4. Sistema de Niveis e Recompensas
  5. Claim de Recompensas
  6. Event Tickets
  7. Mini-Game: Atire na Caixa (Event Shoot)
  8. Endpoints da API
  9. Eventos WebSocket
  10. Arquivos do Sistema
  11. Edge Cases e Validacoes
  12. Debugging

Visao Geral

O sistema de eventos funciona como um Battle Pass. Cada evento possui:

  • Periodo de vigencia: data de inicio (startsAt) e fim (endsAt)
  • Niveis: cada nivel requer uma quantidade fixa de XP (xpPerLevel)
  • Nivel maximo: definido por maxLevel
  • Recompensas: cada nivel tem uma recompensa associada (item, saldo, tickets ou badge)
  • Status: UPCOMING -> ACTIVE -> ENDED (transicao automatica por cron ou manual)

O jogador acumula XP realizando acoes na plataforma (abrir caixas, ganhar batalhas, upgrades, etc). Ao atingir um nivel, pode reivindicar a recompensa correspondente.

Paralelamente, existe o mini-game "Atire na Caixa" (Event Shoot), que consome event tickets para dar ao jogador uma chance de ganhar itens exclusivos.


Schema do Banco de Dados

Enums

prisma
enum EventType {
  BATTLE_PASS
  SEASONAL_EVENT
  SPECIAL_EVENT
}

enum EventStatus {
  UPCOMING
  ACTIVE
  ENDED
}

enum RewardType {
  ITEM
  BALANCE
  TICKETS
  BADGE
}

Tabela: Event

prisma
model Event {
  id              BigInt       @id
  name            String
  description     String
  type            EventType
  status          EventStatus  @default(UPCOMING)
  imageUrl        String       @map("image_url")
  maxLevel        Int          @map("max_level")
  xpPerLevel      Int          @map("xp_per_level")
  startsAt        DateTime     @map("starts_at")
  endsAt          DateTime     @map("ends_at")
  createdAt       DateTime     @default(now()) @map("created_at")

  levels          EventLevel[]
  progress        EventProgress[]

  @@map("events")
  @@index([status, startsAt])
  @@index([endsAt])
}

Campos:

CampoTipoDescricao
idBigIntSnowflake ID
nameStringNome do evento (ex: "Passe de Inverno")
descriptionStringDescricao do evento
typeEventTypeBATTLE_PASS, SEASONAL_EVENT ou SPECIAL_EVENT
statusEventStatusUPCOMING, ACTIVE ou ENDED
imageUrlStringURL da imagem/banner do evento
maxLevelIntNivel maximo que o jogador pode atingir
xpPerLevelIntQuantidade de XP necessaria para subir cada nivel
startsAtDateTimeData/hora de inicio do evento
endsAtDateTimeData/hora de termino do evento

Tabela: EventLevel

prisma
model EventLevel {
  id                BigInt       @id
  eventId           BigInt       @map("event_id")
  level             Int
  requiredXp        Int          @map("required_xp")
  rewardType        RewardType   @map("reward_type")
  rewardItemId      BigInt?      @map("reward_item_id")
  rewardValueCents  BigInt?      @map("reward_value_cents")
  rewardTickets     Int?         @map("reward_tickets")
  rewardBadge       String?      @map("reward_badge")
  rewardDescription String       @map("reward_description")
  rewardImageUrl    String?      @map("reward_image_url")

  event             Event        @relation(fields: [eventId], references: [id], onDelete: Cascade)
  rewardItem        Item?        @relation(fields: [rewardItemId], references: [id])
  claimedRewards    EventReward[]

  @@unique([eventId, level])
  @@map("event_levels")
  @@index([eventId, level])
}

Campos:

CampoTipoDescricao
levelIntNumero do nivel (1, 2, 3, ...)
requiredXpIntXP total necessario para este nivel
rewardTypeRewardTypeTipo da recompensa (ITEM, BALANCE, TICKETS, BADGE)
rewardItemIdBigInt?ID do item (quando rewardType = ITEM)
rewardValueCentsBigInt?Valor em centavos BRL (quando rewardType = BALANCE)
rewardTicketsInt?Quantidade de tickets (quando rewardType = TICKETS)
rewardBadgeString?Identificador do badge (quando rewardType = BADGE)
rewardDescriptionStringDescricao legivel da recompensa
rewardImageUrlString?URL da imagem da recompensa

Constraint unico: @@unique([eventId, level]) -- cada nivel eh unico por evento.

Tabela: EventProgress

prisma
model EventProgress {
  id              BigInt   @id
  eventId         BigInt   @map("event_id")
  userId          BigInt   @map("user_id")
  currentXp       Int      @default(0) @map("current_xp")
  currentLevel    Int      @default(1) @map("current_level")
  updatedAt       DateTime @updatedAt @map("updated_at")

  event           Event    @relation(fields: [eventId], references: [id], onDelete: Cascade)
  user            User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([eventId, userId])
  @@map("event_progress")
  @@index([eventId, userId])
  @@index([userId])
}

Campos:

CampoTipoDescricao
currentXpIntXP total acumulado pelo jogador neste evento
currentLevelIntNivel atual do jogador (calculado como floor(currentXp / xpPerLevel) + 1, limitado a maxLevel)

Constraint unico: @@unique([eventId, userId]) -- um registro de progresso por usuario por evento.

Tabela: EventReward

prisma
model EventReward {
  id              BigInt      @id
  eventLevelId    BigInt      @map("event_level_id")
  userId          BigInt      @map("user_id")
  claimedAt       DateTime    @default(now()) @map("claimed_at")

  eventLevel      EventLevel  @relation(fields: [eventLevelId], references: [id], onDelete: Cascade)
  user            User        @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([eventLevelId, userId])
  @@map("event_rewards")
  @@index([userId])
  @@index([eventLevelId])
}

Registra quais recompensas ja foram reivindicadas. O constraint @@unique([eventLevelId, userId]) impede que o mesmo usuario reivindique a mesma recompensa duas vezes.

Tabela: EventShootItem

Legado: tabela de pool fixo. O fluxo atual do "Atire na Caixa" monta o pool dinamicamente a partir da tabela Item (ver Mini-Game abaixo); esta tabela nao e mais lida pelo tiro.

prisma
model EventShootItem {
  id              BigInt   @id
  itemId          BigInt   @map("item_id")
  weight          Int      @default(1)
  hashRangeStart  Int      @map("hash_range_start")
  hashRangeEnd    Int      @map("hash_range_end")
  dropChance      Float    @default(0) @map("drop_chance")

  item            Item     @relation(fields: [itemId], references: [id])
  openings        EventShootOpening[]

  @@map("event_shoot_items")
  @@index([itemId])
}

Campos:

CampoTipoDescricao
itemIdBigIntFK para a tabela Item
weightIntPeso para calculo de probabilidade
hashRangeStartIntInicio do range Provably Fair (1 a 10.000.000)
hashRangeEndIntFim do range Provably Fair (1 a 10.000.000)
dropChanceFloatChance de drop em porcentagem

Tabela: EventShootOpening

prisma
model EventShootOpening {
  id                BigInt   @id
  userId            BigInt   @map("user_id")
  eventShootItemId  BigInt?  @map("event_shoot_item_id")
  itemId            BigInt   @map("item_id")
  ticketsSpent      Int      @map("tickets_spent")
  itemValueCents    BigInt   @map("item_value_cents")

  serverSeed        String   @map("server_seed")
  serverSeedHash    String   @map("server_seed_hash")
  clientSeed        String   @map("client_seed")
  nonce             Int
  roll              Int

  alt1ItemId        BigInt   @map("alt1_item_id")
  alt2ItemId        BigInt   @map("alt2_item_id")

  createdAt         DateTime @default(now()) @map("created_at")

  user              User             @relation(fields: [userId], references: [id])
  eventShootItem    EventShootItem?  @relation(fields: [eventShootItemId], references: [id], onDelete: Restrict)
  item              Item             @relation("EventShootWonItem", fields: [itemId], references: [id])
  alt1Item          Item             @relation("EventShootAlt1Item", fields: [alt1ItemId], references: [id])
  alt2Item          Item             @relation("EventShootAlt2Item", fields: [alt2ItemId], references: [id])

  @@map("event_shoot_openings")
  @@index([userId])
}

Campos:

CampoTipoDescricao
ticketsSpentIntTickets gastos no tiro (250 a 5000, escolhido pelo jogador)
eventShootItemIdBigInt?Nullable; sempre null no fluxo de pool dinamico atual (legado)
itemValueCentsBigIntValor do item ganho em centavos BRL (valueCentsBrl)
serverSeedStringSeed do servidor para Provably Fair
serverSeedHashStringHash SHA-256 do serverSeed (revelado antes do roll)
clientSeedStringSeed do cliente
nonceIntNonce unico para este roll
rollIntResultado do roll (1 a 10.000.000)
alt1ItemIdBigIntItem alternativo 1 (exibido na animacao)
alt2ItemIdBigIntItem alternativo 2 (exibido na animacao)

Campo na Tabela User

prisma
model User {
  // ...
  eventTickets      Int         @default(0) @map("event_tickets")
  // ...
  eventProgress     EventProgress[]
  eventRewards      EventReward[]
  eventShootOpenings EventShootOpening[]
}

O campo eventTickets armazena a quantidade de tickets de evento do usuario, usados no mini-game "Atire na Caixa".


Sistema de XP

Servico: EventXpService

Arquivo: src/application/services/event-xp.service.ts

O EventXpService eh o ponto central de concessao de XP. Ele:

  1. Busca todos os eventos ativos
  2. Calcula a quantidade de XP baseada na acao realizada
  3. Concede XP para o usuario em cada evento ativo

Estado atual da integracao: grantXpForAction (e o AddEventXpUseCase que ele invoca) nao possui nenhum caller nos fluxos de gameplay hoje. O EventXpService esta registrado e exportado em event.module.ts, mas nenhum use case de jogo (abrir caixa, batalha, upgrade, swap, sorteio, deposito, login) chama grantXpForAction. Ou seja: o passe esta totalmente modelado e conectado, porem o XP nao acumula por gameplay ate que as chamadas sejam instrumentadas nos use cases. A tabela abaixo descreve os valores previstos quando essa instrumentacao existir.

typescript
@Injectable()
export class EventXpService {
  constructor(
    private readonly addEventXpUseCase: AddEventXpUseCase,
    @Inject('IEventRepository')
    private readonly eventRepository: IEventRepository,
  ) {}

  async grantXpForAction(userId: bigint, action: string, metadata?: any) {
    const activeEvents = await this.eventRepository.findActive();
    if (activeEvents.length === 0) {
      return;
    }

    const xpAmounts = this.calculateXpForAction(action, metadata);

    for (const event of activeEvents) {
      if (xpAmounts > 0) {
        await this.addEventXpUseCase.execute(event.id, userId, xpAmounts, action);
      }
    }
  }
}

Tabela de XP por Acao

AcaoConstanteXP ConcedidoDescricao
Abertura de caixaCASE_OPENING10Qualquer abertura de caixa
Abertura raraCASE_OPENING_RARE25Abertura que resulta em item raro
Upgrade com sucessoUPGRADE_SUCCESS15Upgrade que o jogador venceu
Upgrade falhouUPGRADE_FAIL5Upgrade que o jogador perdeu
Vitoria em batalhaBATTLE_WIN30Vencer uma batalha
Participacao em batalhaBATTLE_PARTICIPATION10Participar de uma batalha (independente do resultado)
Swap completadoSWAP_COMPLETED8Completar uma troca de itens
Vitoria em sorteioRAFFLE_WIN50Ganhar um sorteio
Compra de ticket de sorteioRAFFLE_TICKET_PURCHASE2Comprar ticket de sorteio
DepositoDEPOSITfloor(amountCents / 100000)1 XP a cada R$1.000,00 depositados
Login diarioDAILY_LOGIN5Fazer login no dia

Codigo completo do calculo:

typescript
private calculateXpForAction(action: string, metadata?: any): number {
  switch (action) {
    case 'CASE_OPENING':
      return 10;

    case 'CASE_OPENING_RARE':
      return 25;

    case 'UPGRADE_SUCCESS':
      return 15;

    case 'UPGRADE_FAIL':
      return 5;

    case 'BATTLE_WIN':
      return 30;

    case 'BATTLE_PARTICIPATION':
      return 10;

    case 'SWAP_COMPLETED':
      return 8;

    case 'RAFFLE_WIN':
      return 50;

    case 'RAFFLE_TICKET_PURCHASE':
      return 2;

    case 'DEPOSIT':
      const depositAmount = metadata?.amountCents || 0;
      return Math.floor(Number(depositAmount) / 100000);

    case 'DAILY_LOGIN':
      return 5;

    default:
      return 0;
  }
}

Use Case: AddEventXpUseCase

Arquivo: src/application/use-cases/event/add-event-xp.use-case.ts

Responsavel por efetivamente adicionar XP ao progresso do usuario em um evento especifico.

typescript
@Injectable()
export class AddEventXpUseCase {
  constructor(
    @Inject('IEventRepository')
    private readonly eventRepository: IEventRepository,
  ) {}

  async execute(eventId: bigint, userId: bigint, xpAmount: number, reason: string) {
    if (xpAmount <= 0) {
      throw new BadRequestException('XP amount must be positive');
    }

    const event = await this.eventRepository.findById(eventId);
    if (!event) {
      throw new NotFoundException('Event not found');
    }

    if (event.status !== EventStatus.ACTIVE) {
      throw new BadRequestException('Event is not active');
    }

    const oldProgress = await this.eventRepository.getUserProgress(eventId, userId);
    const oldLevel = oldProgress?.currentLevel || 1;

    const newProgress = await this.eventRepository.createOrUpdateProgress(
      eventId,
      userId,
      xpAmount,
    );

    const leveledUp = newProgress.currentLevel > oldLevel;

    return {
      success: true,
      xpAdded: xpAmount,
      reason,
      currentXp: newProgress.currentXp,
      currentLevel: newProgress.currentLevel,
      leveledUp,
      levelsGained: leveledUp ? newProgress.currentLevel - oldLevel : 0,
    };
  }
}

Validacoes:

  • xpAmount deve ser positivo (> 0)
  • O evento deve existir
  • O evento deve estar com status ACTIVE

Retorno:

typescript
{
  success: true,
  xpAdded: 10,             // Quantidade de XP adicionado
  reason: "CASE_OPENING",  // Acao que gerou o XP
  currentXp: 450,          // XP total acumulado
  currentLevel: 5,         // Nivel atual
  leveledUp: true,         // Se subiu de nivel com esta acao
  levelsGained: 1,         // Quantos niveis subiu (pode ser > 1 se XP for grande)
}

Calculo de Nivel

O calculo de nivel eh feito no repositorio (createOrUpdateProgress):

typescript
const newXp = existing.currentXp + xpToAdd;
const event = existing.event;
const newLevel = Math.min(Math.floor(newXp / event.xpPerLevel) + 1, event.maxLevel);

Formula: nivel = min(floor(xpTotal / xpPerLevel) + 1, maxLevel)

Exemplo: Se xpPerLevel = 100 e maxLevel = 50:

  • 0 XP -> Nivel 1
  • 99 XP -> Nivel 1
  • 100 XP -> Nivel 2
  • 199 XP -> Nivel 2
  • 4900 XP -> Nivel 50
  • 5000+ XP -> Nivel 50 (limitado pelo maxLevel)

Primeiro Acesso ao Evento

Se o usuario nunca participou do evento (sem registro em EventProgress), o registro eh criado automaticamente na primeira concessao de XP:

typescript
if (!existing) {
  return this.prisma.eventProgress.create({
    data: {
      id: this.snowflake.generate(),
      eventId,
      userId,
      currentXp: xpToAdd,
      currentLevel: 1,
    },
  });
}

Sistema de Niveis e Recompensas

Tipos de Recompensa

ITEM

O jogador recebe um item especifico no inventario.

Campos utilizados: rewardItemId (FK para tabela Item)

Fulfillment:

typescript
case RewardType.ITEM:
  if (eventLevel.rewardItemId) {
    await this.inventoryRepository.create({
      userId,
      itemId: eventLevel.rewardItemId,
      acquiredFrom: 'Evento',
    });
  }
  break;

O item eh adicionado ao UserInventory com acquiredFrom: 'Evento'.

BALANCE

O jogador recebe saldo em centavos BRL.

Campos utilizados: rewardValueCents (BigInt, centavos BRL)

Fulfillment:

typescript
case RewardType.BALANCE:
  if (eventLevel.rewardValueCents) {
    await this.userRepository.updateBalance(userId, eventLevel.rewardValueCents);
  }
  break;

TICKETS

O jogador recebe tickets de sorteio (raffle tickets).

Campos utilizados: rewardTickets (Int)

Fulfillment:

typescript
case RewardType.TICKETS:
  if (eventLevel.rewardTickets) {
    await this.userRepository.incrementTickets(userId, eventLevel.rewardTickets);
  }
  break;

BADGE

O jogador recebe um badge cosmetico. Atualmente o fulfillment nao tem logica adicional alem do registro do claim.

Campos utilizados: rewardBadge (String, identificador do badge)

Fulfillment:

typescript
case RewardType.BADGE:
  break;

O registro na tabela EventReward serve como prova de que o badge foi concedido.


Claim de Recompensas

Use Case: ClaimEventRewardUseCase

Arquivo: src/application/use-cases/event/claim-event-reward.use-case.ts

Responsavel por validar e executar a reivindicacao de recompensa de um nivel.

typescript
@Injectable()
export class ClaimEventRewardUseCase {
  constructor(
    @Inject('IEventRepository')
    private readonly eventRepository: IEventRepository,
    @Inject('IUserRepository')
    private readonly userRepository: IUserRepository,
    @Inject('IUserInventoryRepository')
    private readonly inventoryRepository: IUserInventoryRepository,
    private readonly prisma: PrismaService,
  ) {}

  async execute(eventId: bigint, userId: bigint, level: number) {
    const event = await this.eventRepository.findById(eventId);
    if (!event) {
      throw new NotFoundException('Event not found');
    }

    const eventLevel = event.levels.find((l: any) => l.level === level);
    if (!eventLevel) {
      throw new NotFoundException('Event level not found');
    }

    const progress = await this.eventRepository.getUserProgress(eventId, userId);
    if (!progress || progress.currentLevel < level) {
      throw new BadRequestException('You have not reached this level yet');
    }

    const alreadyClaimed = await this.eventRepository.hasClaimedReward(eventLevel.id, userId);
    if (alreadyClaimed) {
      throw new BadRequestException('Reward already claimed');
    }

    return this.prisma.$transaction(async (tx) => {
      await this.eventRepository.claimReward(eventLevel.id, userId);

      switch (eventLevel.rewardType) {
        case RewardType.ITEM:
          if (eventLevel.rewardItemId) {
            await this.inventoryRepository.create({
              userId,
              itemId: eventLevel.rewardItemId,
              acquiredFrom: 'Evento',
            });
          }
          break;

        case RewardType.BALANCE:
          if (eventLevel.rewardValueCents) {
            await this.userRepository.updateBalance(userId, eventLevel.rewardValueCents);
          }
          break;

        case RewardType.TICKETS:
          if (eventLevel.rewardTickets) {
            await this.userRepository.incrementTickets(userId, eventLevel.rewardTickets);
          }
          break;

        case RewardType.BADGE:
          break;
      }

      return {
        success: true,
        reward: {
          level,
          type: eventLevel.rewardType,
          description: eventLevel.rewardDescription,
        },
      };
    });
  }
}

Fluxo de Validacao

  1. Evento existe? -- NotFoundException se nao
  2. Nivel existe no evento? -- NotFoundException se nao
  3. Usuario atingiu o nivel? -- BadRequestException('You have not reached this level yet') se progress.currentLevel < level
  4. Ja reivindicou? -- BadRequestException('Reward already claimed') se ja existe registro em EventReward
  5. Executa em transacao: Cria o registro de claim + entrega a recompensa

Retorno do Claim

typescript
{
  success: true,
  reward: {
    level: 5,
    type: "ITEM",
    description: "AK-47 | Neon Rider (Factory New)"
  }
}

Event Tickets

O que sao Event Tickets?

Event tickets sao uma moeda secundaria usada exclusivamente no mini-game "Atire na Caixa" (Event Shoot). Cada disparo custa entre 250 e 5000 tickets, a escolha do jogador (EVENT_LIMITS.MIN_SHOOT_COST a EVENT_LIMITS.MAX_SHOOT_COST).

Como o Jogador Ganha Event Tickets

Event tickets sao concedidos automaticamente ao confirmar um deposito.

Calculo (em DepositService):

typescript
const ticketsToAdd = Math.floor(Number(deposit.amountCents) / 3000);

const eventTicketsToAdd = ticketsToAdd * 250;
if (eventTicketsToAdd > 0) {
  await this.userRepository.incrementEventTickets(deposit.userId, eventTicketsToAdd);
}

Formula: eventTickets = floor(depositCents / 3000) * 250

Exemplos:

Deposito (BRL)Deposito (centavos)Tickets de SorteioEvent Tickets
R$ 10,00100000
R$ 30,0030001250
R$ 60,0060002500
R$ 100,00100003750
R$ 300,0030000102.500
R$ 1.000,00100000338.250

O calculo esta atrelado aos tickets de sorteio: a cada R$ 30,00 depositados, o jogador ganha 1 ticket de sorteio e 250 event tickets.

Repositorio: User

Incremento:

typescript
async incrementEventTickets(id: bigint, amount: number): Promise<User> {
  return this.prisma.user.update({
    where: { id },
    data: {
      eventTickets: {
        increment: amount,
      },
    },
  });
}

Decremento (com validacao de saldo):

typescript
async decrementEventTickets(id: bigint, amount: number): Promise<User> {
  const user = await this.prisma.user.findUnique({
    where: { id },
    select: { eventTickets: true },
  });

  if (!user || user.eventTickets < amount) {
    throw new Error('Insufficient event tickets');
  }

  return this.prisma.user.update({
    where: { id },
    data: {
      eventTickets: {
        decrement: amount,
      },
    },
  });
}

Script Administrativo

Existe um script para adicionar event tickets manualmente:

Arquivo: scripts/add-event-tickets.ts

bash
npx ts-node scripts/add-event-tickets.ts <userId> <quantidade>
npx ts-node scripts/add-event-tickets.ts 2 250

Mini-Game: Atire na Caixa (Event Shoot)

Visao Geral

O "Atire na Caixa" eh um mini-game onde o jogador escolhe gastar entre 250 e 5000 event tickets para "atirar" e ganhar um item. O pool eh montado dinamicamente a partir da tabela Item conforme o valor gasto (quanto mais tickets, maior a faixa de valor disputada). O resultado eh determinado pelo sistema Provably Fair.

Constantes

typescript
// src/config/business-limits.config.ts
export const EVENT_LIMITS = {
  MIN_SHOOT_COST: 250,        // custo minimo por tiro (tickets)
  MAX_SHOOT_COST: 5000,       // custo maximo por tiro (tickets)
  VALUE_MIN_MULTIPLIER: 30,   // piso da faixa BRL = ratio * 30 (centavos)
  VALUE_MAX_MULTIPLIER: 300,  // teto da faixa BRL = ratio * 300 (centavos)
};

// ratio = ticketsToSpend / MIN_SHOOT_COST

Use Case: PerformEventShootUseCase

Arquivo: src/application/use-cases/event-shoot/perform-event-shoot.use-case.ts

Fluxo Completo

Tudo roda sob lock por usuario (withLock('user:event-shoot:${userId}')).

  1. Validacao de entrada: ticketsToSpend deve ser inteiro entre 250 e 5000
  2. Validacao do usuario: Busca o usuario e verifica se existe
  3. Validacao de tickets: Verifica se user.eventTickets >= ticketsToSpend
  4. Construcao do pool dinamico: Monta o pool a partir da tabela Item (faixa de valor derivada de ticketsToSpend); exige >= 3 itens
  5. Debito de tickets: Decrementa ticketsToSpend tickets do usuario
  6. Geracao Provably Fair: Gera serverSeed, serverSeedHash, clientSeed; nonce via NonceService; calcula o roll
  7. Selecao do item ganho: Encontra o item cujo range contem o roll
  8. Geracao dos itens alternativos: Dois rolls adicionais (nonce+1, nonce+2) determinam os itens que aparecem ao lado
  9. Registro no banco: Cria o registro EventShootOpening (eventShootItemId = null)
  10. Adicao ao inventario: Item ganho vai para o inventario (acquiredValueCents = valueCentsBrl)
  11. Registro financeiro: transactionService.logOnly grava o valor ganho sem mover saldo
  12. Emissao WebSocket: Atualiza tickets e emite live drop apos 3 segundos
  13. Auditoria: auditService.logUserAction('EVENT_SHOOT', ...)

Codigo Completo

typescript
async execute(userId: bigint, ticketsToSpend: number, clientSeed?: string) {
  if (
    !Number.isInteger(ticketsToSpend) ||
    ticketsToSpend < EVENT_LIMITS.MIN_SHOOT_COST ||
    ticketsToSpend > EVENT_LIMITS.MAX_SHOOT_COST
  ) {
    throw new BadRequestException(
      `Tickets must be an integer between ${EVENT_LIMITS.MIN_SHOOT_COST} and ${EVENT_LIMITS.MAX_SHOOT_COST}`,
    );
  }

  return this.lockService.withLock(`user:event-shoot:${userId}`, async () => {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      throw new BadRequestException('User not found');
    }

    if (user.eventTickets < ticketsToSpend) {
      throw new BadRequestException('Insufficient event tickets');
    }

    // Pool dinamico: montado a partir da tabela Item conforme o valor gasto
    const weightedItems = await this.buildDynamicPool(ticketsToSpend);
    if (weightedItems.length < 3) {
      throw new BadRequestException('Not enough items available for this ticket amount');
    }

    await this.userRepository.decrementEventTickets(userId, ticketsToSpend);

    const serverSeed = this.provablyFairService.generateServerSeed();
    const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
    const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
    const nonce = await this.nonceService.getNextNonce(userId);

    const roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce);

    let wonIndex = -1;
    for (let i = 0; i < weightedItems.length; i++) {
      if (roll >= weightedItems[i].hashRangeStart && roll <= weightedItems[i].hashRangeEnd) {
        wonIndex = i;
        break;
      }
    }
    if (wonIndex === -1) {
      wonIndex = weightedItems.length - 1;
    }

    const wonItem = weightedItems[wonIndex];

    // Item alternativo 1 (nonce + 1)
    const alt1Roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce + 1);
    let alt1Index = weightedItems.findIndex(
      (it) => alt1Roll >= it.hashRangeStart && alt1Roll <= it.hashRangeEnd,
    );
    if (alt1Index === -1) alt1Index = 0;
    if (alt1Index === wonIndex) {
      alt1Index = (alt1Index + 1) % weightedItems.length;
    }

    // Item alternativo 2 (nonce + 2)
    const alt2Roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce + 2);
    let alt2Index = weightedItems.findIndex(
      (it) => alt2Roll >= it.hashRangeStart && alt2Roll <= it.hashRangeEnd,
    );
    if (alt2Index === -1) alt2Index = weightedItems.length - 1;
    if (alt2Index === wonIndex || alt2Index === alt1Index) {
      const used = new Set([wonIndex, alt1Index]);
      for (let j = 0; j < weightedItems.length; j++) {
        if (!used.has(j)) {
          alt2Index = j;
          break;
        }
      }
    }

    const alt1Item = weightedItems[alt1Index];
    const alt2Item = weightedItems[alt2Index];

    const openingId = this.snowflakeService.generate();
    await this.eventShootRepository.createOpening({
      id: openingId,
      userId,
      eventShootItemId: null,        // pool dinamico: sem FK para EventShootItem
      itemId: wonItem.item.id,
      ticketsSpent: ticketsToSpend,
      itemValueCents: wonItem.item.valueCentsBrl,
      serverSeed,
      serverSeedHash,
      clientSeed: finalClientSeed,
      nonce,
      roll,
      alt1ItemId: alt1Item.item.id,
      alt2ItemId: alt2Item.item.id,
    });

    await this.userInventoryRepository.create({
      userId,
      itemId: wonItem.item.id,
      status: 'AVAILABLE',
      acquiredFrom: 'Evento - Atire na Caixa',
      acquiredValueCents: wonItem.item.valueCentsBrl,
    });

    // Registra o valor ganho SEM mover saldo (o premio e um item, nao dinheiro)
    await this.transactionService.logOnly(
      userId,
      wonItem.item.valueCentsBrl,
      `Evento: Ganhou ${wonItem.item.name} (${ticketsToSpend} tickets)`,
      { action: 'EVENT_SHOOT', ticketsSpent: ticketsToSpend, wonItemId: wonItem.item.id.toString() },
    );

    const updatedUser = await this.userRepository.findById(userId);
    this.webSocketGateway.emitEventTicketsUpdate(userId, updatedUser?.eventTickets ?? 0);

    const dropChance =
      (wonItem.weight / weightedItems.reduce((sum, i) => sum + i.weight, 0)) * 100;

    setTimeout(() => {
      this.webSocketGateway.emitLiveDrop({
        userName: user.username,
        userAvatar: user.avatarUrl || undefined,
        itemName: wonItem.item.name,
        itemImage: wonItem.item.imageUrl,
        itemValueCents: wonItem.item.valueCentsBrl,
        rarity: wonItem.item.rarity,
        source: 'event',
        timestamp: new Date(),
        dropChance,
      });
    }, 3000);

    await this.auditService.logUserAction(
      userId, 'EVENT_SHOOT', 'EventShoot', openingId.toString(),
      { itemWon: wonItem.item.name, roll, ticketsSpent: ticketsToSpend },
    );

    return {
      openingId: openingId.toString(),
      wonItem: {
        id: wonItem.item.id.toString(),
        name: wonItem.item.name,
        imageUrl: wonItem.item.imageUrl,
        rarity: wonItem.item.rarity,
        valueCents: wonItem.item.valueCentsBrl.toString(),
      },
      alt1: {
        id: alt1Item.item.id.toString(),
        name: alt1Item.item.name,
        imageUrl: alt1Item.item.imageUrl,
        rarity: alt1Item.item.rarity,
        valueCents: alt1Item.item.valueCentsBrl.toString(),
      },
      alt2: {
        id: alt2Item.item.id.toString(),
        name: alt2Item.item.name,
        imageUrl: alt2Item.item.imageUrl,
        rarity: alt2Item.item.rarity,
        valueCents: alt2Item.item.valueCentsBrl.toString(),
      },
      serverSeedHash,
      clientSeed: finalClientSeed,
      nonce,
      roll,
      ticketsSpent: ticketsToSpend,
      remainingTickets: updatedUser?.eventTickets ?? 0,
    };
  });
}

// Monta o pool a cada tiro a partir da tabela Item (nao ha pool fixo)
private async buildDynamicPool(ticketsToSpend: number): Promise<WeightedItem[]> {
  const ratio = ticketsToSpend / EVENT_LIMITS.MIN_SHOOT_COST;
  let minValueCentsBrl = BigInt(Math.floor(ratio * EVENT_LIMITS.VALUE_MIN_MULTIPLIER));
  let maxValueCentsBrl = BigInt(Math.floor(ratio * EVENT_LIMITS.VALUE_MAX_MULTIPLIER));

  // findAll filtra por valueCentsBrl (BRL)
  let items = await this.itemRepository.findAll({
    minValueCents: minValueCentsBrl,
    maxValueCents: maxValueCentsBrl,
  });

  // Fallback: alarga a faixa (min/2, max*2) se houver menos de 3 itens
  if (items.length < 3) {
    minValueCentsBrl = minValueCentsBrl / 2n;
    maxValueCentsBrl = maxValueCentsBrl * 2n;
    items = await this.itemRepository.findAll({
      minValueCents: minValueCentsBrl,
      maxValueCents: maxValueCentsBrl,
    });
  }

  if (items.length < 3) return [];

  const sorted = [...items].sort(
    (a, b) => Number(a.valueCentsBrl) - Number(b.valueCentsBrl),
  );

  // assignWeightsAndRanges: bandas de peso 40/30/15/10/5 por percentil (0.40/0.30/0.15/0.10/0.05),
  // hash ranges cumulativos sobre 10.000.000 (ultimo item termina em HASH_MAX)
  return this.assignWeightsAndRanges(sorted);
}

Selecao de Itens Alternativos

O mini-game exibe 3 itens ao jogador (o ganho + 2 alternativos). Os alternativos sao calculados com rolls adicionais usando nonce+1 e nonce+2, garantindo que os 3 itens sejam sempre distintos.

Regras de deduplicacao:

  • alt1 nao pode ser igual ao wonItem; se for, avanca para o proximo indice (circular)
  • alt2 nao pode ser igual ao wonItem nem ao alt1; se for, busca o primeiro indice nao utilizado

Provably Fair no Event Shoot

O roll segue o mesmo sistema Provably Fair do restante da plataforma:

typescript
calculateRoll(serverSeed: string, clientSeed: string, nonce: number, max: number = 10000000): number {
  const hmac = createHmac('sha256', serverSeed);
  hmac.update(`${clientSeed}:${nonce}`);
  const hash = hmac.digest('hex');

  const hashPrefix = hash.substring(0, 13);
  const decimal = parseInt(hashPrefix, 16);

  const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;

  let result = decimal;
  if (result >= maxSafeValue) {
    const hmac2 = createHmac('sha256', serverSeed);
    hmac2.update(`${clientSeed}:${nonce}:retry`);
    const hash2 = hmac2.digest('hex');
    result = parseInt(hash2.substring(0, 13), 16);
  }

  const roll = (result % max) + 1;
  return roll;
}

O nonce eh obtido via NonceService.getNextNonce(userId), que faz redis.incr('nonce:{userId}') -- um contador monotonico por usuario (compartilhado com os demais jogos: case, battle, upgrade).

Live Drop

O drop eh emitido via WebSocket com source: 'event' apos um delay de 3 segundos (para permitir animacao no frontend):

typescript
setTimeout(() => {
  this.webSocketGateway.emitLiveDrop({
    // ...
    source: 'event',
    // ...
  });
}, 3000);

Endpoints da API

GET /api/event/active

Descricao: Retorna todos os eventos ativos. Se o usuario estiver autenticado, inclui o progresso e recompensas ja reivindicadas.

Autenticacao: Opcional

Response (sem autenticacao):

json
[
  {
    "id": "123456789",
    "name": "Passe de Inverno",
    "description": "Colete XP e ganhe recompensas exclusivas!",
    "type": "BATTLE_PASS",
    "status": "ACTIVE",
    "imageUrl": "https://...",
    "maxLevel": 50,
    "xpPerLevel": 100,
    "startsAt": "2026-01-01T00:00:00.000Z",
    "endsAt": "2026-03-01T00:00:00.000Z",
    "levels": [
      {
        "id": "111",
        "level": 1,
        "requiredXp": 100,
        "rewardType": "BALANCE",
        "rewardItemId": null,
        "rewardValueCents": "500",
        "rewardTickets": null,
        "rewardBadge": null,
        "rewardDescription": "R$ 5,00 de saldo",
        "rewardImageUrl": "https://...",
        "rewardItem": null
      }
    ]
  }
]

Response (com autenticacao):

Inclui campos adicionais:

json
{
  "userProgress": {
    "currentXp": 450,
    "currentLevel": 5,
    "updatedAt": "2026-02-10T15:30:00.000Z"
  },
  "claimedRewards": ["111", "222", "333"]
}

claimedRewards contem os IDs dos EventLevel cujas recompensas ja foram reivindicadas.


GET /api/event/:id/progress

Descricao: Retorna o progresso detalhado do usuario em um evento especifico.

Autenticacao: Obrigatoria (AuthGuard)

Parametros: id (path) -- ID do evento (BigInt como string)

Response:

json
{
  "event": {
    "id": "123456789",
    "name": "Passe de Inverno",
    "xpPerLevel": 100,
    "maxLevel": 50,
    "levels": []
  },
  "currentXp": 450,
  "currentLevel": 5,
  "xpForNextLevel": 50,
  "progressPercent": 50.0,
  "claimedRewards": [
    {
      "levelId": "111",
      "level": 1,
      "claimedAt": "2026-02-01T12:00:00.000Z"
    },
    {
      "levelId": "222",
      "level": 2,
      "claimedAt": "2026-02-05T14:30:00.000Z"
    }
  ]
}

Campos calculados:

  • xpForNextLevel: event.xpPerLevel - (currentXp % event.xpPerLevel) -- XP faltante para o proximo nivel
  • progressPercent: ((currentXp % event.xpPerLevel) / event.xpPerLevel) * 100 -- Progresso percentual dentro do nivel atual

Se o usuario nunca participou do evento:

json
{
  "event": {},
  "currentXp": 0,
  "currentLevel": 1,
  "xpForNextLevel": 100,
  "progressPercent": 0,
  "claimedRewards": []
}

Erros:

StatusMensagemCausa
404Event not foundEvento nao existe
401UnauthorizedUsuario nao autenticado

POST /api/event/:id/claim

Descricao: Reivindica a recompensa de um nivel especifico.

Autenticacao: Obrigatoria (AuthGuard)

Parametros: id (path) -- ID do evento

Body:

json
{
  "level": 5
}

Response (sucesso):

json
{
  "success": true,
  "reward": {
    "level": 5,
    "type": "ITEM",
    "description": "AK-47 | Neon Rider (Factory New)"
  }
}

Erros:

StatusMensagemCausa
404Event not foundEvento nao existe
404Event level not foundO nivel informado nao existe no evento
400You have not reached this level yetprogress.currentLevel < level
400Reward already claimedJa existe registro em EventReward
401UnauthorizedUsuario nao autenticado

POST /api/event-shoot/shoot

Descricao: Executa um disparo no mini-game "Atire na Caixa". Custa entre 250 e 5000 event tickets (escolhido pelo jogador).

Autenticacao: Obrigatoria (AuthGuard)

Body:

json
{
  "ticketsToSpend": 2500,
  "clientSeed": "minha-seed-customizada"
}

O campo ticketsToSpend eh obrigatorio (inteiro entre 250 e 5000). O campo clientSeed eh opcional; se nao fornecido, eh gerado automaticamente.

Response (sucesso):

json
{
  "openingId": "987654321",
  "wonItem": {
    "id": "456",
    "name": "AK-47 | Elite Build (Field-Tested)",
    "imageUrl": "https://...",
    "rarity": "MIL_SPEC",
    "valueCents": "2650"
  },
  "alt1": {
    "id": "789",
    "name": "MP9 | Hot Rod (Factory New)",
    "imageUrl": "https://...",
    "rarity": "RESTRICTED",
    "valueCents": "1800"
  },
  "alt2": {
    "id": "101",
    "name": "Glock-18 | Water Elemental (Minimal Wear)",
    "imageUrl": "https://...",
    "rarity": "RESTRICTED",
    "valueCents": "900"
  },
  "serverSeedHash": "a1b2c3d4...",
  "clientSeed": "minha-seed-customizada",
  "nonce": 123456,
  "roll": 8745231,
  "ticketsSpent": 2500,
  "remainingTickets": 500
}

Campos da response:

CampoTipoDescricao
openingIdstringSnowflake ID da abertura
wonItemobjectItem ganho pelo jogador
alt1objectPrimeiro item alternativo (exibido na animacao)
alt2objectSegundo item alternativo (exibido na animacao)
serverSeedHashstringHash do server seed (verificacao Provably Fair)
clientSeedstringClient seed utilizado
noncenumberNonce utilizado no calculo
rollnumberResultado do roll (1 a 10.000.000)
ticketsSpentnumberTickets gastos no tiro (= ticketsToSpend, 250 a 5000)
remainingTicketsnumberTickets restantes apos o disparo

Todos os valueCents nos itens retornados sao em centavos BRL (valueCentsBrl).

Erros:

StatusMensagemCausa
400Tickets must be an integer between 250 and 5000ticketsToSpend invalido ou fora do intervalo
400User not foundUsuario nao existe (improvavel)
400Insufficient event ticketsuser.eventTickets < ticketsToSpend
400Not enough items available for this ticket amountPool dinamico (tabela Item) com menos de 3 itens na faixa
401UnauthorizedUsuario nao autenticado

Eventos WebSocket

eventTickets:updated

Direcao: Servidor -> Cliente (privado, sala user:{userId})

Emitido quando: Apos um disparo no Event Shoot

Payload:

typescript
{
  eventTickets: number  // Quantidade atualizada de event tickets
}

Codigo de emissao (no WebSocketGatewayService):

typescript
emitEventTicketsUpdate(userId: bigint, eventTickets: number) {
  const roomName = `user:${userId}`;
  this.server.to(roomName).emit('eventTickets:updated', { eventTickets });
}

Listener no frontend (em websocket-context.tsx):

typescript
socketInstance.on('eventTickets:updated', (data: { eventTickets: number }) => {
  const newEventTickets = Number(data.eventTickets);
  if (!isNaN(newEventTickets)) {
    setEventTickets(newEventTickets);
  }
});

live:drop (source: 'event')

Direcao: Servidor -> Todos os clientes (broadcast global)

Emitido quando: 3 segundos apos um disparo no Event Shoot (delay para animacao)

Payload:

typescript
{
  userName: string,
  userAvatar: string | undefined,
  itemName: string,
  itemImage: string,
  itemValueCents: string,     // Centavos BRL como string
  rarity: string,
  source: 'event',            // Identifica que veio do Event Shoot
  timestamp: Date,
  dropChance: number,
  isTopDrop: boolean,         // true se valueCents >= 100000 (R$ 1.000,00)
}

Arquivos do Sistema

Use Cases

ArquivoDescricao
src/application/use-cases/event/add-event-xp.use-case.tsAdiciona XP ao progresso do usuario em um evento
src/application/use-cases/event/claim-event-reward.use-case.tsReivindica recompensa de um nivel
src/application/use-cases/event/get-active-events.use-case.tsLista eventos ativos (com progresso se autenticado)
src/application/use-cases/event/get-event-progress.use-case.tsRetorna progresso detalhado do usuario em um evento
src/application/use-cases/event-shoot/perform-event-shoot.use-case.tsExecuta disparo no mini-game "Atire na Caixa"

Servicos

ArquivoDescricao
src/application/services/event-xp.service.tsCalcula e concede XP baseado em acoes do usuario

Controllers

ArquivoDescricao
src/presentation/controllers/event.controller.tsEndpoints de evento (active, progress, claim)
src/presentation/controllers/event-shoot.controller.tsEndpoint do mini-game (shoot)

Repositorios

ArquivoDescricao
src/domain/repositories/event.repository.interface.tsInterface do repositorio de eventos
src/domain/repositories/event-shoot.repository.interface.tsInterface do repositorio do Event Shoot
src/infrastructure/database/repositories/event.repository.tsImplementacao Prisma do repositorio de eventos
src/infrastructure/database/repositories/event-shoot.repository.tsImplementacao Prisma do repositorio do Event Shoot

Modulos

ArquivoDescricao
src/presentation/modules/event.module.tsModulo NestJS do sistema de eventos
src/presentation/modules/event-shoot.module.tsModulo NestJS do mini-game Event Shoot

Scripts

ArquivoDescricao
scripts/add-event-tickets.tsScript para adicionar event tickets manualmente

Edge Cases e Validacoes

Evento

  1. Evento UPCOMING nao aceita XP: O AddEventXpUseCase verifica event.status !== EventStatus.ACTIVE e rejeita com BadRequestException
  2. Evento ENDED nao aceita XP: Mesma validacao acima
  3. XP negativo ou zero: Rejeitado com BadRequestException('XP amount must be positive')
  4. Nivel maximo atingido: O calculo Math.min(..., event.maxLevel) impede que o nivel ultrapasse o maximo
  5. Subida de multiplos niveis: Se uma unica acao concede XP suficiente para pular niveis, o campo levelsGained reflete isso corretamente
  6. Sem progresso anterior: O registro de EventProgress eh criado automaticamente na primeira concessao de XP (upsert)
  7. Nenhum evento ativo: O EventXpService retorna silenciosamente sem erro se nao ha eventos ativos

Claim de Recompensas

  1. Nivel nao atingido: BadRequestException('You have not reached this level yet') se progress.currentLevel < level
  2. Recompensa ja reivindicada: BadRequestException('Reward already claimed') -- validado tanto em codigo quanto pelo constraint @@unique([eventLevelId, userId]) no banco
  3. Transacao atomica: Claim + fulfillment rodam dentro de prisma.$transaction, garantindo consistencia
  4. RewardType sem valor: Se rewardItemId, rewardValueCents ou rewardTickets forem null para seus respectivos tipos, o claim eh registrado mas nenhuma acao adicional eh executada (graceful degradation)

Event Shoot

  1. Valor invalido: BadRequestException('Tickets must be an integer between 250 and 5000') se ticketsToSpend nao for inteiro no intervalo
  2. Tickets insuficientes: BadRequestException('Insufficient event tickets') se user.eventTickets < ticketsToSpend
  3. Pool insuficiente: BadRequestException('Not enough items available for this ticket amount') se, mesmo apos alargar a faixa (min/2, max*2), houver menos de 3 itens na tabela Item
  4. Roll fora de range: Fallback para o ultimo item do pool (wonIndex = weightedItems.length - 1)
  5. Itens alternativos duplicados: Logica de deduplicacao garante que wonItem, alt1 e alt2 sejam sempre distintos
  6. ClientSeed nao fornecido: Gerado automaticamente pelo ProvablyFairService
  7. Nonce: Obtido de NonceService.getNextNonce(userId) (redis.incr('nonce:{userId}')), monotonico por usuario
  8. Concorrencia: Lock user:event-shoot:${userId} impede dois tiros simultaneos do mesmo jogador

Transicao de Status de Eventos

O repositorio possui dois metodos para transicao automatica:

typescript
async activateScheduledEvents(): Promise<number> {
  const now = new Date();
  const result = await this.prisma.event.updateMany({
    where: {
      status: EventStatus.UPCOMING,
      startsAt: { lte: now },
    },
    data: {
      status: EventStatus.ACTIVE,
    },
  });
  return result.count;
}

async endExpiredEvents(): Promise<number> {
  const now = new Date();
  const result = await this.prisma.event.updateMany({
    where: {
      status: EventStatus.ACTIVE,
      endsAt: { lte: now },
    },
    data: {
      status: EventStatus.ENDED,
    },
  });
  return result.count;
}

Estes metodos devem ser chamados por um cron job periodico.

Query de Eventos Ativos

A query de eventos ativos verifica TRES condicoes simultaneamente:

typescript
where: {
  status: EventStatus.ACTIVE,
  startsAt: { lte: new Date() },   // Ja comecou
  endsAt: { gte: new Date() },     // Ainda nao acabou
}

Debugging

Sistema de XP

Se XP nao esta sendo concedido:

  1. Verifique se ha eventos com status ACTIVE no banco
  2. Verifique se a acao esta na lista do calculateXpForAction (retorna 0 para acoes desconhecidas)
  3. Verifique se o grantXpForAction esta sendo chamado nos use cases relevantes (atualmente, o metodo existe mas precisa ser invocado em cada use case que concede XP)
  4. Verifique os logs do AddEventXpUseCase

Nivel nao sobe

  1. Verifique xpPerLevel do evento
  2. Calcule: floor(currentXp / xpPerLevel) + 1 deve ser o nivel esperado
  3. Verifique se maxLevel nao esta limitando

Claim falha

  1. Verifique se o nivel existe no evento: SELECT * FROM event_levels WHERE event_id = X AND level = Y
  2. Verifique o progresso do usuario: SELECT * FROM event_progress WHERE event_id = X AND user_id = Y
  3. Verifique se ja foi reivindicado: SELECT * FROM event_rewards WHERE event_level_id = Z AND user_id = Y

Event Shoot falha

  1. Verifique event tickets do usuario: SELECT event_tickets FROM users WHERE id = X
  2. Verifique se ha itens na faixa de valor: o pool vem de SELECT COUNT(*) FROM items WHERE value_cents_brl BETWEEN <min> AND <max> (com min = ratio*30, max = ratio*300, ratio = tickets/250); precisa de >= 3 (a faixa eh alargada para min/2..max*2 antes de falhar)
  3. ticketsToSpend deve ser inteiro entre 250 e 5000
  4. Lembre: o pool NAO vem mais de event_shoot_items (legado) -- eh montado dinamicamente a partir da tabela items

Event Tickets nao atualizando no frontend

  1. Verifique se o WebSocket esta conectado e o usuario esta na sala user:{userId}
  2. Verifique se emitEventTicketsUpdate esta sendo chamado no backend
  3. Verifique se o listener eventTickets:updated esta ativo no websocket-context.tsx
  4. Verifique se o valor esta sendo parseado corretamente: Number(data.eventTickets)

Documentação Técnica - ProCases