Skip to content

Live Drops - Documentacao Completa

O sistema de Live Drops exibe em tempo real todos os itens dropados no site, criando engajamento e FOMO (Fear of Missing Out). Qualquer visitante -- autenticado ou nao -- pode ver os drops acontecendo.


Sumario

  1. Visao Geral da Arquitetura
  2. LiveDropsService
  3. Delays de Emissao por Fonte
  4. Evento live:drop - Payload Completo
  5. LiveDropsController - Endpoints HTTP
  6. GetRecentDropsUseCase
  7. Fontes de Dados (UNION Query)
  8. Filtragem de Bots
  9. Top Drops e isTopDrop
  10. LiveDropDto
  11. Metodo getRecentOpenings e N+1 Query
  12. Modulo LiveDrops
  13. Fluxo Completo por Fonte
  14. Debugging e Troubleshooting

Visao Geral da Arquitetura

O sistema de Live Drops opera em duas camadas complementares:

1. Camada WebSocket (Tempo Real)

Cada use case que gera um drop (abertura de caixa, upgrade, swap, batalha, evento) chama diretamente o metodo emitLiveDrop() do WebSocketGatewayService com um setTimeout proporcional a duracao da animacao no frontend.

Use Case -> setTimeout(delay) -> WebSocketGateway.emitLiveDrop() -> Broadcast global

2. Camada HTTP (Historico)

Dois endpoints publicos permitem carregar drops recentes e top drops diretamente do banco de dados, usando uma UNION query que agrega dados de 5 tabelas diferentes.

GET /live-drops/recent -> GetRecentDropsUseCase -> CaseOpeningRepository.findAllRecentDrops()
GET /live-drops/top    -> GetRecentDropsUseCase -> CaseOpeningRepository.findAllTopDrops()

Diferenca entre as camadas

AspectoWebSocket (Tempo Real)HTTP (Historico)
TriggerUse case emite apos acaoCliente faz GET request
DadosObjetos construidos no use caseUNION query em 5 tabelas
DelaySim (baseado na animacao)Nao
PersistenciaNao (evento efemero)Sim (banco de dados)
UsoFeed em tempo real no frontendCarregamento inicial da pagina

LiveDropsService

Arquivo: src/application/services/live-drops.service.ts

Servico intermediario que encapsula a emissao dos eventos case:opened e upgrade:completed via WebSocket. Nao e responsavel pelo live:drop -- esse e emitido diretamente pelos use cases.

Dependencias

typescript
@Injectable()
export class LiveDropsService {
  constructor(
    private readonly websocketGateway: WebSocketGatewayService,
    private readonly moneyService: MoneyService,
  ) {}
}

broadcastCaseOpening(data)

Emite o evento case:opened globalmente. Formata o valor do item usando MoneyService.format() (formato "R$ X.XXX,XX").

typescript
async broadcastCaseOpening(data: {
  id: bigint;
  userId: bigint;
  username: string;
  userAvatar: string;
  caseName: string;
  caseImage: string;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  itemValueCents: bigint;
  isFlip: boolean;
}) {
  this.websocketGateway.emitCaseOpening({
    id: data.id,
    userId: data.userId,
    username: data.username,
    userAvatar: data.userAvatar,
    caseName: data.caseName,
    caseImage: data.caseImage,
    itemName: data.itemName,
    itemImage: data.itemImage,
    itemRarity: data.itemRarity,
    itemValue: this.moneyService.format(data.itemValueCents),
    isFlip: data.isFlip,
    timestamp: Date.now(),
  });
}

Observacao: O campo itemValue e formatado como "R$ 1.500,00" via MoneyService.format(). Este e um dos poucos pontos do sistema onde valores sao enviados formatados via WebSocket (diferente do balance:updated que envia centavos puros).

broadcastUpgrade(data)

Emite o evento upgrade:completed globalmente.

typescript
async broadcastUpgrade(data: {
  id: bigint;
  userId: bigint;
  username: string;
  success: boolean;
  targetItemName: string;
  targetItemImage: string;
  targetItemRarity: string;
  valueInvestedCents: bigint;
}) {
  this.websocketGateway.emitUpgrade({
    id: data.id,
    userId: data.userId,
    username: data.username,
    success: data.success,
    targetItem: {
      name: data.targetItemName,
      image: data.targetItemImage,
      rarity: data.targetItemRarity,
    },
    valueInvested: this.moneyService.format(data.valueInvestedCents),
    timestamp: Date.now(),
  });
}

getConnectedUsersCount(): number

Proxy para WebSocketGatewayService.getConnectedUsersCount().

typescript
getConnectedUsersCount(): number {
  return this.websocketGateway.getConnectedUsersCount();
}

Delays de Emissao por Fonte

O live:drop e emitido com delay via setTimeout em cada use case. O objetivo e garantir que o drop so aparece no feed global APOS a animacao do frontend terminar, evitando spoiler do resultado.

Tabela de Delays

FonteDelayRazaoArquivo
Abertura de caixa (normal)12.000ms (12s)Animacao da roleta (~10s) + revelacao (~2s)open-case.use-case.ts
Abertura de caixa (FLIP)30.000ms (30s)Primeira roleta (~10s) + animacao KNIFE (~8s) + segunda roleta (~10s) + revelacao (~2s)open-case.use-case.ts
Upgrade (sucesso)12.000ms (12s)Animacao do upgrade no frontendperform-upgrade.use-case.ts
Batalha (normal)12.000ms (12s)Animacao da roleta da rodadaexecute-battle.use-case.ts
Batalha (FLIP)30.000ms (30s)Mesma logica do FLIP de caixaexecute-battle.use-case.ts
Site Swap3.000ms (3s)Animacao curta de trocaperform-site-swap.use-case.ts
Evento (Atire na Caixa)3.000ms (3s)Animacao curta do tiroperform-event-shoot.use-case.ts

Logica no Codigo

Abertura de Caixa (delay dinamico baseado em FLIP):

typescript
const liveDropDelay = result.isFlip ? 30000 : 12000;
const user = await this.userRepository.findById(userId);
if (user) {
  setTimeout(() => {
    this.webSocketGateway.emitLiveDrop({
      userName: user.username,
      userAvatar: user.avatarUrl || undefined,
      itemName: result.item.name,
      itemImage: result.item.imageUrl,
      itemValueCents: result.item.valueCentsBrl,
      rarity: result.item.rarity,
      source: 'case',
      timestamp: new Date(),
      dropChance: result.dropChance,
      caseImage: caseData.imageUrl || undefined,
    });
  }, liveDropDelay);
}

Batalha (delay dinamico + filtragem de bots):

typescript
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
  const liveDropDelay = isFlip ? 30000 : 12000;
  setTimeout(() => {
    this.webSocketGateway.emitLiveDrop({
      userName: player.user.username,
      userAvatar: player.user.avatarUrl || undefined,
      itemName: wonItem.item.name,
      itemImage: wonItem.item.imageUrl,
      itemValueCents: wonItem.item.valueCentsBrl,
      rarity: wonItem.item.rarity,
      source: 'battle',
      timestamp: new Date(),
      dropChance: wonItem.dropChance,
    });
  }, liveDropDelay);
}

Upgrade (delay fixo, apenas sucesso):

typescript
if (success) {
  const user = await this.userRepository.findById(userId);
  if (user) {
    setTimeout(() => {
      this.webSocketGateway.emitLiveDrop({
        userName: user.username,
        userAvatar: user.avatarUrl || undefined,
        itemName: targetItem.name,
        itemImage: targetItem.imageUrl,
        itemValueCents: targetItem.valueCentsBrl,
        rarity: targetItem.rarity,
        source: 'upgrade',
        timestamp: new Date(),
        dropChance: chancePercent,
      });
    }, 12000);
  }
}

Site Swap (delay curto):

typescript
setTimeout(() => {
  if (user) {
    this.webSocketGateway.emitLiveDrop({
      userName: user.username,
      userAvatar: user.avatarUrl || undefined,
      itemName: targetItem!.name,
      itemImage: targetItem!.imageUrl,
      itemValueCents: BigInt(priceCentsBrl),
      rarity: targetItem!.rarity,
      source: 'swap',
      timestamp: new Date(),
      dropChance: 100,
    });
  }
}, 3000);

Evento Shoot (delay curto):

typescript
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: wonItem.dropChance,
  });
}, 3000);

Condicoes de Emissao

FonteCondicao para emitirdropChance
CaixaSempre (se user existe)result.dropChance (da CaseItem)
UpgradeApenas quando success = truechancePercent do upgrade
BatalhaApenas jogadores reais (!isBot)wonItem.dropChance
SwapSempre (se user existe)Fixo: 100
EventoSemprewonItem.dropChance

Evento live:drop - Payload Completo

O evento live:drop e emitido pelo metodo emitLiveDrop() do WebSocketGatewayService e recebe os seguintes campos:

Entrada (parametro do metodo)

typescript
emitLiveDrop(drop: {
  userName: string;
  userAvatar?: string;
  itemName: string;
  itemImage: string;
  itemValueCents: bigint | string;
  rarity: string;
  source: 'case' | 'upgrade' | 'swap' | 'battle' | 'event';
  timestamp: Date;
  dropChance: number;
  caseImage?: string;
})

Saida (payload emitido ao cliente)

typescript
{
  userName: string;         // Nome do usuario
  userAvatar?: string;      // URL do avatar Steam
  itemName: string;         // Nome do item (ex: "AWP | Dragon Lore")
  itemImage: string;        // URL da imagem do item
  itemValueCents: string;   // Valor em centavos BRL (convertido para string)
  rarity: string;           // Raridade do item (ex: "COVERT", "EXTRAORDINARY")
  source: string;           // Fonte: 'case', 'upgrade', 'swap', 'battle', 'event'
  timestamp: Date;          // Data/hora do drop
  dropChance: number;       // Chance de drop em porcentagem (ex: 0.05 = 0.05%)
  isTopDrop: boolean;       // Calculado: valueCents >= 100000 (R$1.000,00+)
  caseImage?: string;       // URL da imagem da caixa (apenas source='case')
}

Calculo do isTopDrop

typescript
const valueCents =
  typeof drop.itemValueCents === 'bigint' ? drop.itemValueCents : BigInt(drop.itemValueCents);
const isTopDrop = valueCents >= BigInt(100000);

O campo isTopDrop e true quando o valor do item e maior ou igual a R$1.000,00 (100.000 centavos BRL). Este campo e calculado no gateway e nao precisa ser informado pelo caller.


LiveDropsController - Endpoints HTTP

Arquivo: src/presentation/controllers/live-drops.controller.ts

Controller publico que fornece endpoints para carregar drops do banco de dados.

typescript
@ApiTags('Live Drops')
@Controller('live-drops')
export class LiveDropsController {
  constructor(private readonly getRecentDropsUseCase: GetRecentDropsUseCase) {}
}

GET /live-drops/recent

Retorna os drops mais recentes de todas as fontes.

ParametroTipoDefaultDescricao
limitQuery (opcional)10Numero de drops a retornar

Decorators:

  • @Public() -- Nao requer autenticacao
  • @ApiOperation({ summary: 'Busca os drops mais recentes globalmente (publico)' })
typescript
@Public()
@Get('recent')
async getRecentDrops(@Query('limit') limit?: string): Promise<LiveDropDto[]> {
  const limitNumber = limit ? parseInt(limit, 10) : 10;
  return this.getRecentDropsUseCase.execute(limitNumber, false);
}

Exemplo de request:

GET /live-drops/recent?limit=20

Exemplo de response:

json
[
  {
    "userName": "hala",
    "userAvatar": "https://avatars.steamstatic.com/...",
    "itemName": "AWP | Dragon Lore",
    "itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
    "itemValueCents": "150000",
    "rarity": "EXTRAORDINARY",
    "source": "case",
    "caseImage": "https://storage.procases.example/cases/emerald.webp",
    "timestamp": "2025-12-02T18:30:00.000Z",
    "dropChance": 0.05
  },
  {
    "userName": "player2",
    "itemName": "Glock-18 | Fade",
    "itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
    "itemValueCents": "85000",
    "rarity": "COVERT",
    "source": "upgrade",
    "timestamp": "2025-12-02T18:29:45.000Z",
    "dropChance": 15.5
  }
]

GET /live-drops/top

Retorna os top drops mais recentes. Filtra itens com value_cents_brl >= 100000 (R$1.000,00+).

ParametroTipoDefaultDescricao
limitQuery (opcional)10Numero de drops a retornar

Decorators:

  • @Public() -- Nao requer autenticacao
  • @ApiOperation({ summary: 'Busca os top drops mais recentes (chance <= 2%) (publico)' })
typescript
@Public()
@Get('top')
async getTopDrops(@Query('limit') limit?: string): Promise<LiveDropDto[]> {
  const limitNumber = limit ? parseInt(limit, 10) : 10;
  return this.getRecentDropsUseCase.execute(limitNumber, true);
}

Observacao: Apesar da descricao da API mencionar "chance <= 2%", a implementacao real filtra por value_cents_brl >= minValueCents (default: BigInt(100000) = R$1.000,00). O filtro e por VALOR, nao por chance.


GetRecentDropsUseCase

Arquivo: src/application/use-cases/live-drops/get-recent-drops.use-case.ts

Use case que busca drops recentes ou top drops do banco de dados.

typescript
@Injectable()
export class GetRecentDropsUseCase {
  constructor(private readonly caseOpeningRepository: CaseOpeningRepository) {}

  async execute(limit: number = 10, topOnly: boolean = false): Promise<LiveDropDto[]> {
    const recentDrops = topOnly
      ? await this.caseOpeningRepository.findAllTopDrops(limit, BigInt(100000))
      : await this.caseOpeningRepository.findAllRecentDrops(limit);

    return recentDrops.map((drop) => ({
      userName: drop.user.username,
      userAvatar: drop.user.avatarUrl ?? undefined,
      itemName: drop.item.name,
      itemImage: drop.item.imageUrl,
      itemValueCents: drop.itemValueCents.toString(),
      rarity: drop.item.rarity,
      source: drop.source,
      timestamp: drop.createdAt,
      dropChance: drop.dropChance,
      caseImage: drop.caseImageUrl ?? undefined,
    }));
  }
}

Fluxo de decisao

Parametro topOnlyMetodo chamadoFiltro
falsefindAllRecentDrops(limit)Nenhum (todos os drops)
truefindAllTopDrops(limit, BigInt(100000))value_cents_brl >= 100000

Mapeamento de campos

Campo do banco (raw query)Campo do DTO
usernameuserName
avatarUrluserAvatar
itemNameitemName
itemImageUrlitemImage
itemValueCents (bigint)itemValueCents (string)
itemRarityrarity
sourcesource
createdAttimestamp
dropChancedropChance
caseImageUrlcaseImage

Fontes de Dados (UNION Query)

Arquivo: src/infrastructure/database/repositories/case-opening.repository.ts

Os metodos findAllRecentDrops() e findAllTopDrops() usam uma raw SQL query com $queryRaw do Prisma que faz UNION ALL entre 5 tabelas.

Estrutura da Query

sql
(SELECT 'case' as source, ... FROM case_openings co JOIN users u ... JOIN items i ... JOIN cases c ... LEFT JOIN case_items ci ...)
UNION ALL
(SELECT 'upgrade' as source, ... FROM upgrades up JOIN users u ... JOIN items i ... WHERE up.success = true)
UNION ALL
(SELECT 'battle' as source, ... FROM battle_rounds br JOIN battle_players bp ... JOIN users u ... JOIN items i ... WHERE u.steam_id NOT LIKE 'BOT_%')
UNION ALL
(SELECT 'swap' as source, ... FROM site_swaps ss JOIN users u ... JOIN items i ...)
UNION ALL
(SELECT 'event' as source, ... FROM event_shoot_openings eso JOIN users u ... JOIN items i ... LEFT JOIN event_shoot_items esi ...)
ORDER BY "createdAt" DESC
LIMIT ${limit}

Detalhamento por Fonte

1. case_openings (source = 'case')

sql
SELECT
  'case' as source,
  co.created_at as "createdAt",
  co.user_id as "userId",
  u.username,
  u.avatar_url as "avatarUrl",
  i.name as "itemName",
  i.image_url as "itemImageUrl",
  i.rarity::text as "itemRarity",
  i.value_cents_brl as "itemValueCents",
  COALESCE(ci.drop_chance, 0)::float as "dropChance",
  c.image_url as "caseImageUrl"
FROM case_openings co
JOIN users u ON u.id = co.user_id
JOIN items i ON i.id = co.item_id
JOIN cases c ON c.id = co.case_id
LEFT JOIN case_items ci ON ci.case_id = co.case_id AND ci.item_id = co.item_id

Observacoes:

  • dropChance vem do JOIN com case_items (LEFT JOIN, pode ser null -> COALESCE para 0)
  • caseImageUrl vem da tabela cases
  • Usa value_cents_brl (BRL), nao value_cents (USD)

2. upgrades (source = 'upgrade')

sql
SELECT
  'upgrade' as source,
  up.created_at as "createdAt",
  up.user_id as "userId",
  u.username,
  u.avatar_url as "avatarUrl",
  i.name as "itemName",
  i.image_url as "itemImageUrl",
  i.rarity::text as "itemRarity",
  i.value_cents_brl as "itemValueCents",
  up.chance_percent::float as "dropChance",
  NULL as "caseImageUrl"
FROM upgrades up
JOIN users u ON u.id = up.user_id
JOIN items i ON i.id = up.target_item_id
WHERE up.success = true

Observacoes:

  • WHERE up.success = true: Apenas upgrades bem-sucedidos aparecem nos live drops
  • dropChance vem de chance_percent do upgrade (nao de uma tabela de itens)
  • caseImageUrl e sempre NULL (upgrades nao tem caixa associada)

3. battle_rounds (source = 'battle')

sql
SELECT
  'battle' as source,
  br.created_at as "createdAt",
  bp.user_id as "userId",
  u.username,
  u.avatar_url as "avatarUrl",
  br.item_name as "itemName",
  br.item_image as "itemImageUrl",
  br.item_rarity::text as "itemRarity",
  i.value_cents_brl as "itemValueCents",
  0::float as "dropChance",
  NULL as "caseImageUrl"
FROM battle_rounds br
JOIN battle_players bp ON bp.id = br.player_id
JOIN users u ON u.id = bp.user_id
JOIN items i ON i.id = br.item_id
WHERE u.steam_id NOT LIKE 'BOT_%'

Observacoes:

  • WHERE u.steam_id NOT LIKE 'BOT_%': Filtra bots (veja secao "Filtragem de Bots")
  • dropChance e fixo em 0 (batalhas nao tem conceito de chance individual por item)
  • item_name, item_image, item_rarity vem de battle_rounds (colunas desnormalizadas)
  • itemValueCents vem de items.value_cents_brl via JOIN

4. site_swaps (source = 'swap')

sql
SELECT
  'swap' as source,
  ss.created_at as "createdAt",
  ss.user_id as "userId",
  u.username,
  u.avatar_url as "avatarUrl",
  i.name as "itemName",
  i.image_url as "itemImageUrl",
  i.rarity::text as "itemRarity",
  i.value_cents_brl as "itemValueCents",
  0::float as "dropChance",
  NULL as "caseImageUrl"
FROM site_swaps ss
JOIN users u ON u.id = ss.user_id
JOIN items i ON i.id = ss.target_item_id

Observacoes:

  • dropChance e fixo em 0 (swaps sao deterministas, nao tem chance)
  • target_item_id e o item que o usuario RECEBEU no swap

5. event_shoot_openings (source = 'event')

sql
SELECT
  'event' as source,
  eso.created_at as "createdAt",
  eso.user_id as "userId",
  u.username,
  u.avatar_url as "avatarUrl",
  i.name as "itemName",
  i.image_url as "itemImageUrl",
  i.rarity::text as "itemRarity",
  i.value_cents_brl as "itemValueCents",
  COALESCE(esi.drop_chance, 0)::float as "dropChance",
  NULL as "caseImageUrl"
FROM event_shoot_openings eso
JOIN users u ON u.id = eso.user_id
JOIN items i ON i.id = eso.item_id
LEFT JOIN event_shoot_items esi ON esi.id = eso.event_shoot_item_id

Observacoes:

  • dropChance vem de event_shoot_items via LEFT JOIN
  • Evento "Atire na Caixa" tem seus proprios itens com probabilidades independentes

Resultado Mapeado

Ambos os metodos (findAllRecentDrops e findAllTopDrops) mapeiam o resultado raw para a seguinte estrutura:

typescript
return drops.map((drop) => ({
  source: drop.source as 'case' | 'upgrade' | 'battle' | 'swap' | 'event',
  createdAt: drop.createdAt,
  user: {
    id: drop.userId,
    username: drop.username,
    avatarUrl: drop.avatarUrl,
  },
  item: {
    name: drop.itemName,
    imageUrl: drop.itemImageUrl,
    rarity: drop.itemRarity,
  },
  itemValueCents: drop.itemValueCents,
  dropChance: drop.dropChance,
  caseImageUrl: drop.caseImageUrl,
}));

Diferenca entre findAllRecentDrops e findAllTopDrops

MetodoFiltro WHERE adicional
findAllRecentDrops(limit)Nenhum
findAllTopDrops(limit, minValueCents)WHERE i.value_cents_brl >= ${minValueCents} em cada sub-query

O findAllTopDrops adiciona o filtro WHERE i.value_cents_brl >= ${minValueCents} em CADA uma das 5 sub-queries do UNION, garantindo que apenas itens com valor acima do threshold aparecem.


Filtragem de Bots

Bots (jogadores artificiais em batalhas) NUNCA aparecem nos live drops. A filtragem acontece em dois pontos:

1. Emissao WebSocket (execute-battle.use-case.ts)

typescript
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
  const liveDropDelay = isFlip ? 30000 : 12000;
  setTimeout(() => {
    this.webSocketGateway.emitLiveDrop({ ... });
  }, liveDropDelay);
}

O emitLiveDrop so e chamado se o jogador NAO e um bot (steamId nao comeca com BOT_).

2. Query SQL (case-opening.repository.ts)

sql
FROM battle_rounds br
JOIN battle_players bp ON bp.id = br.player_id
JOIN users u ON u.id = bp.user_id
JOIN items i ON i.id = br.item_id
WHERE u.steam_id NOT LIKE 'BOT_%'

A clausula WHERE u.steam_id NOT LIKE 'BOT_%' garante que drops de bots nunca aparecem nos endpoints HTTP de historico.

Convencao de nomeacao de bots

Todos os bots tem steam_id com prefixo BOT_. Exemplos: BOT_001, BOT_ALPHA, BOT_EASY.


Top Drops e isTopDrop

isTopDrop (WebSocket)

O campo isTopDrop e calculado no emitLiveDrop() do gateway:

typescript
const isTopDrop = valueCents >= BigInt(100000); // >= R$1.000,00 (100.000 centavos BRL)

Este campo e adicionado ao payload automaticamente e pode ser usado pelo frontend para destacar drops de alto valor com animacoes especiais, efeitos visuais ou notificacoes.

Top Drops Endpoint (HTTP)

O endpoint GET /live-drops/top chama findAllTopDrops() com minValueCents = BigInt(100000):

typescript
await this.caseOpeningRepository.findAllTopDrops(limit, BigInt(100000));

Isso filtra apenas itens com value_cents_brl >= 100000 em todas as 5 fontes de dados.

Threshold

ConstanteValorSignificado
BigInt(100000)100.000 centavosR$ 1.000,00 BRL

LiveDropDto

Arquivo: src/application/dto/live-drop.dto.ts

DTO com decorators Swagger para documentacao automatica da API.

typescript
export class LiveDropDto {
  @ApiProperty({ example: 'hala', description: 'Nome do usuario' })
  userName: string;

  @ApiProperty({
    example: 'https://avatars.steamstatic.com/...',
    description: 'Avatar do usuario',
    required: false,
  })
  userAvatar?: string;

  @ApiProperty({ example: 'AWP | Dragon Lore', description: 'Nome do item' })
  itemName: string;

  @ApiProperty({
    example: 'https://community.cloudflare.steamstatic.com/economy/image/...',
    description: 'Imagem do item',
  })
  itemImage: string;

  @ApiProperty({ example: '150000', description: 'Valor do item em centavos' })
  itemValueCents: string;

  @ApiProperty({ example: 'legendary', description: 'Raridade do item' })
  rarity: string;

  @ApiProperty({
    example: 'case',
    description: 'Origem do drop',
    enum: ['case', 'upgrade', 'swap', 'battle', 'event'],
  })
  source: 'case' | 'upgrade' | 'swap' | 'battle' | 'event';

  @ApiProperty({
    example: 'https://storage.procases.example/cases/case1.webp',
    description: 'Imagem da caixa (apenas quando source=case)',
    required: false,
  })
  caseImage?: string;

  @ApiProperty({ example: '2025-12-02T18:30:00Z', description: 'Timestamp do drop' })
  timestamp: Date;

  @ApiProperty({ example: 0.5, description: 'Chance de drop em porcentagem' })
  dropChance: number;
}

Campos opcionais

CampoQuando e null/undefined
userAvatarQuando o usuario nao tem avatar configurado
caseImageQuando source nao e 'case' (upgrades, swaps, batalhas, eventos)

Tipos dos campos

CampoTipo TypeScriptTipo real no payload
itemValueCentsstringString de inteiro (centavos BRL). Ex: "150000"
dropChancenumberFloat. Ex: 0.05 para 0.05%, 100 para swap
timestampDateISO 8601 string quando serializado

Metodo getRecentOpenings e N+1 Query

Arquivo: src/infrastructure/database/repositories/case-opening.repository.ts

Alem dos metodos findAllRecentDrops e findAllTopDrops (que usam raw UNION queries), existe o metodo getRecentOpenings que sofre de um problema de N+1 queries.

O problema

typescript
async getRecentOpenings(limit: number) {
  const openings = await this.prisma.caseOpening.findMany({
    include: { user: { ... }, case: { ... }, item: true },
    orderBy: { createdAt: 'desc' },
    take: limit,
  });

  const openingsWithDropChance = await Promise.all(
    openings.map(async (opening) => {
      const caseItem = await this.prisma.caseItem.findUnique({
        where: {
          caseId_itemId: {
            caseId: opening.caseId,
            itemId: opening.itemId,
          },
        },
        select: { dropChance: true },
      });
      return { ...opening, dropChance: caseItem?.dropChance ?? 0 };
    }),
  );

  return openingsWithDropChance;
}

Por que e N+1

Para cada abertura de caixa retornada (N resultados), uma query adicional e executada para buscar o dropChance na tabela case_items. Se limit = 20, sao executadas 1 + 20 = 21 queries.

Comparacao com findAllRecentDrops

MetodoQueriesFontesPerformance
getRecentOpenings(limit)1 + N (N+1 problem)Apenas case_openingsLenta para limits altos
findAllRecentDrops(limit)1 (raw UNION)5 tabelasRapida e otimizada

O findAllRecentDrops resolve o N+1 usando LEFT JOIN diretamente na raw query SQL, buscando o dropChance dentro da mesma query.

O mesmo problema existe em findRecentTopDrops, que tambem usa Promise.all com queries individuais.


Modulo LiveDrops

Arquivo: src/presentation/modules/live-drops.module.ts

typescript
@Module({
  imports: [DatabaseModule],
  controllers: [LiveDropsController],
  providers: [GetRecentDropsUseCase, CaseOpeningRepository],
  exports: [GetRecentDropsUseCase],
})
export class LiveDropsModule {}

Dependencias

ProviderDescricao
GetRecentDropsUseCaseUse case para buscar drops recentes
CaseOpeningRepositoryRepositorio com as UNION queries

Imports

ModuloDescricao
DatabaseModuleFornece o PrismaClient

Fluxo Completo por Fonte

Abertura de Caixa

1. Usuario clica "Abrir Caixa"
2. Frontend: POST /cases/:id/open
3. Backend: OpenCaseUseCase executa
4. Backend: Calcula resultado (Provably Fair)
5. Backend: Emite balance:updated imediatamente
6. Backend: Retorna resultado ao frontend
7. Frontend: Inicia animacao da roleta (~10s normal / ~28s FLIP)
8. Backend: setTimeout(12000 ou 30000) -> emitLiveDrop(source='case')
9. Todos os clientes WebSocket recebem live:drop
10. Feed de live drops atualiza em todas as telas

Upgrade

1. Usuario seleciona itens e item alvo
2. Frontend: POST /upgrades
3. Backend: PerformUpgradeUseCase executa
4. Backend: Calcula sucesso/falha (Provably Fair)
5. Backend: Emite balance:updated se houve debito de saldo
6. Backend: Se sucesso -> setTimeout(12000) -> emitLiveDrop(source='upgrade')
7. Se falha -> nao emite live:drop
8. Todos os clientes WebSocket recebem live:drop (apenas sucessos)

Batalha

1. Batalha em andamento
2. Backend: ExecuteBattleUseCase processa cada rodada
3. Para cada jogador na rodada:
   a. Calcula resultado (Provably Fair)
   b. Se jogador NAO e bot:
      setTimeout(12000 ou 30000) -> emitLiveDrop(source='battle')
   c. Se jogador e bot: nao emite
4. Emite battle:round_result para sala battle:{id}
5. Live drops aparecem gradualmente no feed global

Site Swap

1. Usuario seleciona item do inventario + item alvo
2. Frontend: POST /swap
3. Backend: PerformSiteSwapUseCase executa
4. Backend: Realiza troca
5. Backend: setTimeout(3000) -> emitLiveDrop(source='swap', dropChance=100)
6. Todos os clientes WebSocket recebem live:drop

Evento (Atire na Caixa)

1. Usuario usa tickets para atirar
2. Frontend: POST /events/shoot
3. Backend: PerformEventShootUseCase executa
4. Backend: Calcula resultado
5. Backend: Emite eventTickets:updated imediatamente
6. Backend: setTimeout(3000) -> emitLiveDrop(source='event')
7. Todos os clientes WebSocket recebem live:drop

Debugging e Troubleshooting

Drop Nao Aparece no Feed em Tempo Real

  1. Verificar se o use case emitiu o live drop:

    • O gateway loga: Emitting live drop: {userName} got {itemName} ({dropChance}%) - isTopDrop: {boolean}
    • Se este log nao aparece, o setTimeout ainda nao disparou OU o use case nao chamou emitLiveDrop
  2. Verificar se o delay esta correto:

    • Normal: 12.000ms
    • FLIP: 30.000ms
    • Swap/Event: 3.000ms
    • Se o drop aparece muito cedo, o delay pode estar errado
  3. Verificar se o cliente WebSocket esta conectado:

    • Log de conexao deve aparecer no backend
    • live:drop e broadcast global, entao QUALQUER cliente conectado deve receber
  4. Verificar se e um bot (batalha):

    • Bots nunca emitem live drops
    • Log de filtragem: isBot = true quando steamId.startsWith('BOT_')

Drop Nao Aparece no Endpoint HTTP

  1. Verificar se o drop esta no banco de dados:

    • A UNION query busca dados diretamente das tabelas transacionais
    • Se o registro nao existe na tabela de origem, nao aparecera
  2. Verificar o filtro de bots (batalhas):

    • A query tem WHERE u.steam_id NOT LIKE 'BOT_%'
    • Drops de bots nunca aparecem no endpoint
  3. Verificar o filtro de sucesso (upgrades):

    • A query tem WHERE up.success = true
    • Upgrades que falharam nunca aparecem
  4. Verificar o valor (endpoint /top):

    • O endpoint /top filtra value_cents_brl >= 100000
    • Itens abaixo de R$1.000,00 nao aparecem neste endpoint

Valores Exibidos Incorretamente

  1. Verificar qual campo de valor esta sendo usado:

    • A UNION query usa i.value_cents_brl (BRL)
    • Se estiver usando i.value_cents (USD), os valores estarao ~5x menores
  2. Verificar a conversao no frontend:

    • itemValueCents e uma string de inteiro em centavos
    • Para exibir: parseInt(itemValueCents, 10) / 100
    • Para formatar: (value / 100).toLocaleString('pt-BR', { minimumFractionDigits: 2 })

Performance Lenta no Endpoint /recent

  1. Verificar se esta usando findAllRecentDrops (UNION) ou getRecentOpenings (N+1):

    • O controller usa GetRecentDropsUseCase que chama findAllRecentDrops (UNION) -- correto
    • Se alguem trocar para getRecentOpenings, havera N+1 queries
  2. Verificar o limit:

    • Valores muito altos de limit podem deixar a UNION query lenta
    • Default e 10, recomendado maximo de 100
  3. Verificar indices:

    • As tabelas devem ter indice em created_at DESC
    • A tabela items deve ter indice em value_cents_brl para o endpoint /top

Arquivos Relacionados

ArquivoDescricao
src/infrastructure/websocket/websocket.gateway.tsMetodo emitLiveDrop() que faz broadcast
src/application/services/live-drops.service.tsServico intermediario para case:opened e upgrade:completed
src/presentation/controllers/live-drops.controller.tsEndpoints HTTP publicos
src/application/use-cases/live-drops/get-recent-drops.use-case.tsUse case de busca
src/application/dto/live-drop.dto.tsDTO com decorators Swagger
src/infrastructure/database/repositories/case-opening.repository.tsUNION queries SQL
src/presentation/modules/live-drops.module.tsModulo NestJS
src/application/use-cases/case-opening/open-case.use-case.tsCaller: abertura de caixa
src/application/use-cases/upgrade/perform-upgrade.use-case.tsCaller: upgrade
src/application/use-cases/battle/execute-battle.use-case.tsCaller: batalha
src/application/use-cases/swap/perform-site-swap.use-case.tsCaller: swap
src/application/use-cases/event-shoot/perform-event-shoot.use-case.tsCaller: evento shoot

Documentação Técnica - ProCases