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
- Visao Geral
- Schema do Banco de Dados
- Sistema de XP
- Sistema de Niveis e Recompensas
- Claim de Recompensas
- Event Tickets
- Mini-Game: Atire na Caixa (Event Shoot)
- Endpoints da API
- Eventos WebSocket
- Arquivos do Sistema
- Edge Cases e Validacoes
- 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
enum EventType {
BATTLE_PASS
SEASONAL_EVENT
SPECIAL_EVENT
}
enum EventStatus {
UPCOMING
ACTIVE
ENDED
}
enum RewardType {
ITEM
BALANCE
TICKETS
BADGE
}Tabela: Event
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:
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID |
name | String | Nome do evento (ex: "Passe de Inverno") |
description | String | Descricao do evento |
type | EventType | BATTLE_PASS, SEASONAL_EVENT ou SPECIAL_EVENT |
status | EventStatus | UPCOMING, ACTIVE ou ENDED |
imageUrl | String | URL da imagem/banner do evento |
maxLevel | Int | Nivel maximo que o jogador pode atingir |
xpPerLevel | Int | Quantidade de XP necessaria para subir cada nivel |
startsAt | DateTime | Data/hora de inicio do evento |
endsAt | DateTime | Data/hora de termino do evento |
Tabela: EventLevel
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:
| Campo | Tipo | Descricao |
|---|---|---|
level | Int | Numero do nivel (1, 2, 3, ...) |
requiredXp | Int | XP total necessario para este nivel |
rewardType | RewardType | Tipo da recompensa (ITEM, BALANCE, TICKETS, BADGE) |
rewardItemId | BigInt? | ID do item (quando rewardType = ITEM) |
rewardValueCents | BigInt? | Valor em centavos BRL (quando rewardType = BALANCE) |
rewardTickets | Int? | Quantidade de tickets (quando rewardType = TICKETS) |
rewardBadge | String? | Identificador do badge (quando rewardType = BADGE) |
rewardDescription | String | Descricao legivel da recompensa |
rewardImageUrl | String? | URL da imagem da recompensa |
Constraint unico: @@unique([eventId, level]) -- cada nivel eh unico por evento.
Tabela: EventProgress
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:
| Campo | Tipo | Descricao |
|---|---|---|
currentXp | Int | XP total acumulado pelo jogador neste evento |
currentLevel | Int | Nivel 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
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.
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:
| Campo | Tipo | Descricao |
|---|---|---|
itemId | BigInt | FK para a tabela Item |
weight | Int | Peso para calculo de probabilidade |
hashRangeStart | Int | Inicio do range Provably Fair (1 a 10.000.000) |
hashRangeEnd | Int | Fim do range Provably Fair (1 a 10.000.000) |
dropChance | Float | Chance de drop em porcentagem |
Tabela: EventShootOpening
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:
| Campo | Tipo | Descricao |
|---|---|---|
ticketsSpent | Int | Tickets gastos no tiro (250 a 5000, escolhido pelo jogador) |
eventShootItemId | BigInt? | Nullable; sempre null no fluxo de pool dinamico atual (legado) |
itemValueCents | BigInt | Valor do item ganho em centavos BRL (valueCentsBrl) |
serverSeed | String | Seed do servidor para Provably Fair |
serverSeedHash | String | Hash SHA-256 do serverSeed (revelado antes do roll) |
clientSeed | String | Seed do cliente |
nonce | Int | Nonce unico para este roll |
roll | Int | Resultado do roll (1 a 10.000.000) |
alt1ItemId | BigInt | Item alternativo 1 (exibido na animacao) |
alt2ItemId | BigInt | Item alternativo 2 (exibido na animacao) |
Campo na Tabela User
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:
- Busca todos os eventos ativos
- Calcula a quantidade de XP baseada na acao realizada
- Concede XP para o usuario em cada evento ativo
Estado atual da integracao:
grantXpForAction(e oAddEventXpUseCaseque ele invoca) nao possui nenhum caller nos fluxos de gameplay hoje. OEventXpServiceesta registrado e exportado emevent.module.ts, mas nenhum use case de jogo (abrir caixa, batalha, upgrade, swap, sorteio, deposito, login) chamagrantXpForAction. 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.
@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
| Acao | Constante | XP Concedido | Descricao |
|---|---|---|---|
| Abertura de caixa | CASE_OPENING | 10 | Qualquer abertura de caixa |
| Abertura rara | CASE_OPENING_RARE | 25 | Abertura que resulta em item raro |
| Upgrade com sucesso | UPGRADE_SUCCESS | 15 | Upgrade que o jogador venceu |
| Upgrade falhou | UPGRADE_FAIL | 5 | Upgrade que o jogador perdeu |
| Vitoria em batalha | BATTLE_WIN | 30 | Vencer uma batalha |
| Participacao em batalha | BATTLE_PARTICIPATION | 10 | Participar de uma batalha (independente do resultado) |
| Swap completado | SWAP_COMPLETED | 8 | Completar uma troca de itens |
| Vitoria em sorteio | RAFFLE_WIN | 50 | Ganhar um sorteio |
| Compra de ticket de sorteio | RAFFLE_TICKET_PURCHASE | 2 | Comprar ticket de sorteio |
| Deposito | DEPOSIT | floor(amountCents / 100000) | 1 XP a cada R$1.000,00 depositados |
| Login diario | DAILY_LOGIN | 5 | Fazer login no dia |
Codigo completo do calculo:
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.
@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:
xpAmountdeve ser positivo (> 0)- O evento deve existir
- O evento deve estar com status
ACTIVE
Retorno:
{
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):
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:
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:
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:
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:
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:
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.
@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
- Evento existe? --
NotFoundExceptionse nao - Nivel existe no evento? --
NotFoundExceptionse nao - Usuario atingiu o nivel? --
BadRequestException('You have not reached this level yet')seprogress.currentLevel < level - Ja reivindicou? --
BadRequestException('Reward already claimed')se ja existe registro emEventReward - Executa em transacao: Cria o registro de claim + entrega a recompensa
Retorno do Claim
{
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):
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 Sorteio | Event Tickets |
|---|---|---|---|
| R$ 10,00 | 1000 | 0 | 0 |
| R$ 30,00 | 3000 | 1 | 250 |
| R$ 60,00 | 6000 | 2 | 500 |
| R$ 100,00 | 10000 | 3 | 750 |
| R$ 300,00 | 30000 | 10 | 2.500 |
| R$ 1.000,00 | 100000 | 33 | 8.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:
async incrementEventTickets(id: bigint, amount: number): Promise<User> {
return this.prisma.user.update({
where: { id },
data: {
eventTickets: {
increment: amount,
},
},
});
}Decremento (com validacao de saldo):
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
npx ts-node scripts/add-event-tickets.ts <userId> <quantidade>
npx ts-node scripts/add-event-tickets.ts 2 250Mini-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
// 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_COSTUse 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}')).
- Validacao de entrada:
ticketsToSpenddeve ser inteiro entre 250 e 5000 - Validacao do usuario: Busca o usuario e verifica se existe
- Validacao de tickets: Verifica se
user.eventTickets >= ticketsToSpend - Construcao do pool dinamico: Monta o pool a partir da tabela
Item(faixa de valor derivada deticketsToSpend); exige >= 3 itens - Debito de tickets: Decrementa
ticketsToSpendtickets do usuario - Geracao Provably Fair: Gera serverSeed, serverSeedHash, clientSeed; nonce via
NonceService; calcula o roll - Selecao do item ganho: Encontra o item cujo range contem o roll
- Geracao dos itens alternativos: Dois rolls adicionais (nonce+1, nonce+2) determinam os itens que aparecem ao lado
- Registro no banco: Cria o registro
EventShootOpening(eventShootItemId = null) - Adicao ao inventario: Item ganho vai para o inventario (
acquiredValueCents = valueCentsBrl) - Registro financeiro:
transactionService.logOnlygrava o valor ganho sem mover saldo - Emissao WebSocket: Atualiza tickets e emite live drop apos 3 segundos
- Auditoria:
auditService.logUserAction('EVENT_SHOOT', ...)
Codigo Completo
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:
alt1nao pode ser igual aowonItem; se for, avanca para o proximo indice (circular)alt2nao pode ser igual aowonItemnem aoalt1; 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:
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):
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):
[
{
"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:
{
"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:
{
"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 nivelprogressPercent:((currentXp % event.xpPerLevel) / event.xpPerLevel) * 100-- Progresso percentual dentro do nivel atual
Se o usuario nunca participou do evento:
{
"event": {},
"currentXp": 0,
"currentLevel": 1,
"xpForNextLevel": 100,
"progressPercent": 0,
"claimedRewards": []
}Erros:
| Status | Mensagem | Causa |
|---|---|---|
| 404 | Event not found | Evento nao existe |
| 401 | Unauthorized | Usuario 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:
{
"level": 5
}Response (sucesso):
{
"success": true,
"reward": {
"level": 5,
"type": "ITEM",
"description": "AK-47 | Neon Rider (Factory New)"
}
}Erros:
| Status | Mensagem | Causa |
|---|---|---|
| 404 | Event not found | Evento nao existe |
| 404 | Event level not found | O nivel informado nao existe no evento |
| 400 | You have not reached this level yet | progress.currentLevel < level |
| 400 | Reward already claimed | Ja existe registro em EventReward |
| 401 | Unauthorized | Usuario 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:
{
"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):
{
"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:
| Campo | Tipo | Descricao |
|---|---|---|
openingId | string | Snowflake ID da abertura |
wonItem | object | Item ganho pelo jogador |
alt1 | object | Primeiro item alternativo (exibido na animacao) |
alt2 | object | Segundo item alternativo (exibido na animacao) |
serverSeedHash | string | Hash do server seed (verificacao Provably Fair) |
clientSeed | string | Client seed utilizado |
nonce | number | Nonce utilizado no calculo |
roll | number | Resultado do roll (1 a 10.000.000) |
ticketsSpent | number | Tickets gastos no tiro (= ticketsToSpend, 250 a 5000) |
remainingTickets | number | Tickets restantes apos o disparo |
Todos os valueCents nos itens retornados sao em centavos BRL (valueCentsBrl).
Erros:
| Status | Mensagem | Causa |
|---|---|---|
| 400 | Tickets must be an integer between 250 and 5000 | ticketsToSpend invalido ou fora do intervalo |
| 400 | User not found | Usuario nao existe (improvavel) |
| 400 | Insufficient event tickets | user.eventTickets < ticketsToSpend |
| 400 | Not enough items available for this ticket amount | Pool dinamico (tabela Item) com menos de 3 itens na faixa |
| 401 | Unauthorized | Usuario nao autenticado |
Eventos WebSocket
eventTickets:updated
Direcao: Servidor -> Cliente (privado, sala user:{userId})
Emitido quando: Apos um disparo no Event Shoot
Payload:
{
eventTickets: number // Quantidade atualizada de event tickets
}Codigo de emissao (no WebSocketGatewayService):
emitEventTicketsUpdate(userId: bigint, eventTickets: number) {
const roomName = `user:${userId}`;
this.server.to(roomName).emit('eventTickets:updated', { eventTickets });
}Listener no frontend (em websocket-context.tsx):
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:
{
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
| Arquivo | Descricao |
|---|---|
src/application/use-cases/event/add-event-xp.use-case.ts | Adiciona XP ao progresso do usuario em um evento |
src/application/use-cases/event/claim-event-reward.use-case.ts | Reivindica recompensa de um nivel |
src/application/use-cases/event/get-active-events.use-case.ts | Lista eventos ativos (com progresso se autenticado) |
src/application/use-cases/event/get-event-progress.use-case.ts | Retorna progresso detalhado do usuario em um evento |
src/application/use-cases/event-shoot/perform-event-shoot.use-case.ts | Executa disparo no mini-game "Atire na Caixa" |
Servicos
| Arquivo | Descricao |
|---|---|
src/application/services/event-xp.service.ts | Calcula e concede XP baseado em acoes do usuario |
Controllers
| Arquivo | Descricao |
|---|---|
src/presentation/controllers/event.controller.ts | Endpoints de evento (active, progress, claim) |
src/presentation/controllers/event-shoot.controller.ts | Endpoint do mini-game (shoot) |
Repositorios
| Arquivo | Descricao |
|---|---|
src/domain/repositories/event.repository.interface.ts | Interface do repositorio de eventos |
src/domain/repositories/event-shoot.repository.interface.ts | Interface do repositorio do Event Shoot |
src/infrastructure/database/repositories/event.repository.ts | Implementacao Prisma do repositorio de eventos |
src/infrastructure/database/repositories/event-shoot.repository.ts | Implementacao Prisma do repositorio do Event Shoot |
Modulos
| Arquivo | Descricao |
|---|---|
src/presentation/modules/event.module.ts | Modulo NestJS do sistema de eventos |
src/presentation/modules/event-shoot.module.ts | Modulo NestJS do mini-game Event Shoot |
Scripts
| Arquivo | Descricao |
|---|---|
scripts/add-event-tickets.ts | Script para adicionar event tickets manualmente |
Edge Cases e Validacoes
Evento
- Evento UPCOMING nao aceita XP: O
AddEventXpUseCaseverificaevent.status !== EventStatus.ACTIVEe rejeita comBadRequestException - Evento ENDED nao aceita XP: Mesma validacao acima
- XP negativo ou zero: Rejeitado com
BadRequestException('XP amount must be positive') - Nivel maximo atingido: O calculo
Math.min(..., event.maxLevel)impede que o nivel ultrapasse o maximo - Subida de multiplos niveis: Se uma unica acao concede XP suficiente para pular niveis, o campo
levelsGainedreflete isso corretamente - Sem progresso anterior: O registro de
EventProgresseh criado automaticamente na primeira concessao de XP (upsert) - Nenhum evento ativo: O
EventXpServiceretorna silenciosamente sem erro se nao ha eventos ativos
Claim de Recompensas
- Nivel nao atingido:
BadRequestException('You have not reached this level yet')seprogress.currentLevel < level - Recompensa ja reivindicada:
BadRequestException('Reward already claimed')-- validado tanto em codigo quanto pelo constraint@@unique([eventLevelId, userId])no banco - Transacao atomica: Claim + fulfillment rodam dentro de
prisma.$transaction, garantindo consistencia - RewardType sem valor: Se
rewardItemId,rewardValueCentsourewardTicketsforem null para seus respectivos tipos, o claim eh registrado mas nenhuma acao adicional eh executada (graceful degradation)
Event Shoot
- Valor invalido:
BadRequestException('Tickets must be an integer between 250 and 5000')seticketsToSpendnao for inteiro no intervalo - Tickets insuficientes:
BadRequestException('Insufficient event tickets')seuser.eventTickets < ticketsToSpend - 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 tabelaItem - Roll fora de range: Fallback para o ultimo item do pool (
wonIndex = weightedItems.length - 1) - Itens alternativos duplicados: Logica de deduplicacao garante que
wonItem,alt1ealt2sejam sempre distintos - ClientSeed nao fornecido: Gerado automaticamente pelo
ProvablyFairService - Nonce: Obtido de
NonceService.getNextNonce(userId)(redis.incr('nonce:{userId}')), monotonico por usuario - 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:
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:
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:
- Verifique se ha eventos com status
ACTIVEno banco - Verifique se a acao esta na lista do
calculateXpForAction(retorna 0 para acoes desconhecidas) - Verifique se o
grantXpForActionesta sendo chamado nos use cases relevantes (atualmente, o metodo existe mas precisa ser invocado em cada use case que concede XP) - Verifique os logs do
AddEventXpUseCase
Nivel nao sobe
- Verifique
xpPerLeveldo evento - Calcule:
floor(currentXp / xpPerLevel) + 1deve ser o nivel esperado - Verifique se
maxLevelnao esta limitando
Claim falha
- Verifique se o nivel existe no evento:
SELECT * FROM event_levels WHERE event_id = X AND level = Y - Verifique o progresso do usuario:
SELECT * FROM event_progress WHERE event_id = X AND user_id = Y - Verifique se ja foi reivindicado:
SELECT * FROM event_rewards WHERE event_level_id = Z AND user_id = Y
Event Shoot falha
- Verifique event tickets do usuario:
SELECT event_tickets FROM users WHERE id = X - Verifique se ha itens na faixa de valor: o pool vem de
SELECT COUNT(*) FROM items WHERE value_cents_brl BETWEEN <min> AND <max>(commin = ratio*30,max = ratio*300,ratio = tickets/250); precisa de >= 3 (a faixa eh alargada paramin/2..max*2antes de falhar) ticketsToSpenddeve ser inteiro entre 250 e 5000- Lembre: o pool NAO vem mais de
event_shoot_items(legado) -- eh montado dinamicamente a partir da tabelaitems
Event Tickets nao atualizando no frontend
- Verifique se o WebSocket esta conectado e o usuario esta na sala
user:{userId} - Verifique se
emitEventTicketsUpdateesta sendo chamado no backend - Verifique se o listener
eventTickets:updatedesta ativo nowebsocket-context.tsx - Verifique se o valor esta sendo parseado corretamente:
Number(data.eventTickets)
