Skip to content

WebSocket Gateway - Documentacao Completa

O WebSocket Gateway e o nucleo de comunicacao em tempo real do sistema. Ele gerencia conexoes publicas e autenticadas, distribui eventos para salas especificas e coordena toda a camada de real-time entre backend e frontend.


Sumario

  1. Configuracao do Gateway
  2. Gerenciamento de Conexao
  3. Extracao de Session ID
  4. Desconexao
  5. Eventos do Cliente (Incoming)
  6. Eventos do Servidor (Emitted)
  7. Estrutura de Salas
  8. Metodos Utilitarios
  9. BattleStateService
  10. Redis Adapter
  11. Modulo WebSocket
  12. Debugging e Troubleshooting

Configuracao do Gateway

Arquivo: src/infrastructure/websocket/websocket.gateway.ts

O gateway e decorado com @WebSocketGateway e configurado da seguinte forma:

typescript
@WebSocketGateway({
  cors: {
    origin: process.env.FRONTEND_URL || 'http://localhost:5173',
    credentials: true,
  },
  transports: ['websocket', 'polling'],
})
export class WebSocketGatewayService implements OnGatewayConnection, OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  private readonly logger = new Logger(WebSocketGatewayService.name);
  private connectedUsers: Map<string, bigint> = new Map();
  private battleStateService: BattleStateService | null = null;

  constructor(private readonly sessionService: SessionService) {}
}

Detalhes da Configuracao

ParametroValorDescricao
cors.originprocess.env.FRONTEND_URL ou http://localhost:5173Origem permitida para CORS
cors.credentialstruePermite cookies e headers de autenticacao
transports['websocket', 'polling']Transportes suportados (WebSocket preferido, polling como fallback)

Estado Interno

PropriedadeTipoDescricao
serverServer (Socket.io)Instancia do servidor Socket.io
connectedUsersMap<string, bigint>Mapa de socketId para userId
battleStateServiceBattleStateService | nullInjetado via setBattleStateService() no OnModuleInit

Dependencias

DependenciaTipoDescricao
SessionServiceInjetada via constructorValida sessions para autenticacao
BattleStateServiceInjetada via setterGerencia estado de batalhas no Redis

Gerenciamento de Conexao

handleConnection(client: Socket)

Metodo chamado automaticamente quando um cliente se conecta ao WebSocket.

Fluxo completo:

  1. Tenta extrair o sessionId do cliente
  2. Se nao ha sessionId: conexao publica
  3. Se ha sessionId: valida a session via SessionService.getSession()
  4. Se a session e invalida: conexao publica
  5. Se a session e valida: conexao autenticada, entra na sala user:{userId}
  6. Em caso de erro: conexao publica (fail-safe)
typescript
async handleConnection(client: Socket) {
  try {
    const sessionId = this.extractSessionId(client);

    if (!sessionId) {
      this.logger.log(`Client ${client.id} connected without session (public mode)`);
      client.emit('connected', {
        message: 'Connected to live drops (public)',
      });
      return;
    }

    const session = await this.sessionService.getSession(sessionId);

    if (!session) {
      this.logger.log(`Client ${client.id} connected with invalid session (public mode)`);
      client.emit('connected', {
        message: 'Connected to live drops (public)',
      });
      return;
    }

    const roomName = `user:${session.userId}`;
    this.connectedUsers.set(client.id, BigInt(session.userId));
    client.join(roomName);

    this.logger.log(`Client ${client.id} connected (User: ${session.userId}, Room: ${roomName})`);
    this.logger.log(`Client ${client.id} rooms: ${Array.from(client.rooms).join(', ')}`);

    client.emit('connected', {
      message: 'Connected to live drops',
      userId: session.userId.toString(),
      room: roomName,
    });
  } catch (error) {
    this.logger.error(`Error on connection: ${error.message}`);
    this.logger.log(`Client ${client.id} connected in public mode after error`);
    client.emit('connected', {
      message: 'Connected to live drops (public)',
    });
  }
}

Resposta do Evento connected

Conexao publica:

json
{
  "message": "Connected to live drops (public)"
}

Conexao autenticada:

json
{
  "message": "Connected to live drops",
  "userId": "12345",
  "room": "user:12345"
}

Pontos importantes

  • O gateway NUNCA desconecta um cliente por falha de autenticacao. Ele apenas entra em modo publico.
  • Isso e intencional: visitantes nao autenticados devem ver live drops e eventos globais.
  • O BigInt(session.userId) converte o userId da session (string) para bigint nativo.
  • O cliente e automaticamente adicionado a sala do seu proprio socketId pelo Socket.io (sala padrao).
  • Alem disso, clientes autenticados sao adicionados a sala user:{userId}.

Extracao de Session ID

extractSessionId(client: Socket): string | null

Metodo privado que tenta extrair o sessionId de duas fontes, em ordem de prioridade:

  1. handshake.auth.sessionId: Enviado pelo cliente na conexao
  2. Cookie sessionId: Parseado do header Cookie
typescript
private extractSessionId(client: Socket): string | null {
  const authSessionId = client.handshake.auth?.sessionId;
  if (authSessionId) return authSessionId;

  const cookies = client.handshake.headers.cookie;
  if (!cookies) return null;

  const sessionCookie = cookies.split(';').find((c) => c.trim().startsWith('sessionId='));

  if (!sessionCookie) return null;

  return sessionCookie.split('=')[1];
}

Ordem de prioridade

PrioridadeFonteExemplo
1client.handshake.auth.sessionIdio(URL, { auth: { sessionId: 'abc123' } })
2Cookie sessionIdHeader Cookie: sessionId=abc123; other=value

Se nenhuma fonte encontrar um sessionId, retorna null e a conexao sera publica.


Desconexao

handleDisconnect(client: Socket)

Metodo chamado automaticamente quando um cliente se desconecta.

typescript
handleDisconnect(client: Socket) {
  const userId = this.connectedUsers.get(client.id);
  this.connectedUsers.delete(client.id);

  this.logger.log(`Client ${client.id} disconnected (User: ${userId})`);
}

Comportamento

  • Remove o socketId do mapa connectedUsers
  • Loga a desconexao com o userId (ou undefined se era publico)
  • O Socket.io automaticamente remove o cliente de todas as salas ao desconectar

Eventos do Cliente (Incoming)

Eventos que o cliente envia ao servidor via @SubscribeMessage.

1. ping

Heartbeat simples para verificar conectividade.

Payload de entrada: nenhum

Resposta:

typescript
@SubscribeMessage('ping')
handlePing(@ConnectedSocket() client: Socket) {
  return { event: 'pong', data: { timestamp: Date.now() } };
}

Payload de resposta:

json
{
  "event": "pong",
  "data": {
    "timestamp": 1707667200000
  }
}

2. battle:join

Cliente solicita entrar na sala de uma batalha para receber eventos em tempo real.

Payload de entrada:

typescript
{ battleId: string }

Comportamento completo:

  1. Cliente entra na sala battle:{battleId}
  2. Se BattleStateService esta disponivel:
    • Busca o estado atual da batalha no Redis
    • Se a batalha esta IN_PROGRESS ou STARTING, envia o estado via battle:state
    • Inclui campos calculados: isPhaseExpired, timeRemainingMs, serverTime
  3. Retorna confirmacao via battle:joined
typescript
@SubscribeMessage('battle:join')
async handleJoinBattle(
  @ConnectedSocket() client: Socket,
  @MessageBody() data: { battleId: string },
) {
  const roomName = `battle:${data.battleId}`;
  client.join(roomName);
  this.logger.log(`Client ${client.id} joined battle room ${roomName}`);

  if (this.battleStateService) {
    const state = await this.battleStateService.getBattleState(data.battleId);
    if (state && (state.status === 'IN_PROGRESS' || state.status === 'STARTING')) {
      const isPhaseExpired = this.battleStateService.isPhaseExpired(state);
      const timeRemainingMs = this.battleStateService.getTimeRemainingMs(state);

      this.logger.log(
        `[Battle] Sending state to client ${client.id}: Round ${state.currentRound}, Phase ${state.currentPhase}, Expired: ${isPhaseExpired}, TimeLeft: ${timeRemainingMs}ms`,
      );

      client.emit('battle:state', {
        ...state,
        isPhaseExpired,
        timeRemainingMs,
        serverTime: Date.now(),
      });
    }
  }

  return { event: 'battle:joined', data: { battleId: data.battleId, room: roomName } };
}

Payload do evento battle:state emitido ao entrar:

json
{
  "battleId": "123",
  "status": "IN_PROGRESS",
  "currentRound": 3,
  "totalRounds": 5,
  "currentPhase": "SPINNING",
  "phaseEndsAt": 1707667210000,
  "hasFlipInCurrentRound": false,
  "spinStartedAt": 1707667200000,
  "currentRoundCaseId": "456",
  "currentRoundCaseName": "Star Case",
  "currentRoundResults": [],
  "completedRounds": [],
  "playerTotals": { "player1": 50000, "player2": 45000 },
  "isPhaseExpired": false,
  "timeRemainingMs": 8000,
  "serverTime": 1707667202000
}

Resposta de confirmacao:

json
{
  "event": "battle:joined",
  "data": {
    "battleId": "123",
    "room": "battle:123"
  }
}

3. battle:leave

Cliente solicita sair da sala de uma batalha.

Payload de entrada:

typescript
{ battleId: string }
typescript
@SubscribeMessage('battle:leave')
handleLeaveBattle(@ConnectedSocket() client: Socket, @MessageBody() data: { battleId: string }) {
  const roomName = `battle:${data.battleId}`;
  client.leave(roomName);
  this.logger.log(`Client ${client.id} left battle room ${roomName}`);
  return { event: 'battle:left', data: { battleId: data.battleId } };
}

Resposta:

json
{
  "event": "battle:left",
  "data": {
    "battleId": "123"
  }
}

4. battle:request_state

Cliente solicita o estado atual de uma batalha. Util para reconexao ou sincronizacao.

Payload de entrada:

typescript
{ battleId: string }

Comportamento:

  1. Se BattleStateService nao esta disponivel: retorna sem emitir nada (warn log)
  2. Se nao encontra estado para a batalha: emite battle:state com status: 'NOT_FOUND'
  3. Se encontra: emite battle:state com o estado completo + campos calculados
typescript
@SubscribeMessage('battle:request_state')
async handleRequestBattleState(
  @ConnectedSocket() client: Socket,
  @MessageBody() data: { battleId: string },
) {
  if (!this.battleStateService) {
    this.logger.warn(`[Battle] BattleStateService not available`);
    return;
  }

  const state = await this.battleStateService.getBattleState(data.battleId);
  if (!state) {
    this.logger.log(`[Battle] No state found for battle ${data.battleId}`);
    client.emit('battle:state', {
      battleId: data.battleId,
      status: 'NOT_FOUND',
    });
    return;
  }

  const isPhaseExpired = this.battleStateService.isPhaseExpired(state);
  const timeRemainingMs = this.battleStateService.getTimeRemainingMs(state);

  this.logger.log(
    `[Battle] State requested by client ${client.id}: Round ${state.currentRound}, Phase ${state.currentPhase}, Expired: ${isPhaseExpired}, TimeLeft: ${timeRemainingMs}ms`,
  );

  client.emit('battle:state', {
    ...state,
    isPhaseExpired,
    timeRemainingMs,
    serverTime: Date.now(),
  });
}

Resposta quando nao encontra:

json
{
  "battleId": "999",
  "status": "NOT_FOUND"
}

Eventos do Servidor (Emitted)

Todos os eventos que o backend emite para os clientes via WebSocket.


Eventos Privados

Eventos emitidos para a sala user:{userId}. Apenas o usuario autenticado com aquele userId recebe.


1. balance:updated

Emitido apos qualquer alteracao no saldo do usuario.

Metodo:

typescript
emitBalanceUpdate(userId: bigint, balanceCents: bigint) {
  const roomName = `user:${userId}`;
  this.logger.log(
    `[Balance Update] User: ${userId}, BalanceCents: ${balanceCents}, Room: ${roomName}`,
  );

  const room = this.server.sockets.adapter.rooms.get(roomName);
  const clientsInRoom = room ? room.size : 0;
  this.logger.log(`[Balance Update] Clients in room ${roomName}: ${clientsInRoom}`);

  if (clientsInRoom === 0) {
    this.logger.warn(
      `[Balance Update] No clients in room ${roomName}! User may not be connected via WebSocket.`,
    );
  }

  this.server.to(roomName).emit('balance:updated', { balanceCents: balanceCents.toString() });
  this.logger.log(`[Balance Update] Event emitted to room ${roomName}`);
}

Payload:

json
{
  "balanceCents": "186321"
}

Chamado por (use cases):

ArquivoContexto
open-case.use-case.tsApos abertura de caixa (debito)
perform-upgrade.use-case.tsApos upgrade (debito de saldo complementar)
create-battle.use-case.tsApos criar batalha (debito do custo)
join-battle.use-case.tsApos entrar em batalha (debito do custo)
sell-inventory-item.use-case.tsApos vender item (credito)
sell-all-items.use-case.tsApos vender todos os itens (credito)
deposit.service.tsApos deposito confirmado (credito)

REGRA CRITICA: O balanceCents e um bigint convertido para string. O frontend DEVE usar parseInt() para parsear, NUNCA parseFloat(). Veja a secao "Centavos Everywhere" no CLAUDE.md.


2. eventTickets:updated

Emitido apos alteracao nos tickets de evento do usuario.

Metodo:

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

Payload:

json
{
  "eventTickets": 5
}

Chamado por:

ArquivoContexto
perform-event-shoot.use-case.tsApos usar tickets no evento "Atire na Caixa"

3. deposit:confirmed

Emitido quando um deposito e confirmado com sucesso.

Metodo:

typescript
emitDepositConfirmed(
  userId: bigint,
  deposit: {
    id: string;
    amountCents: string;
    amountFormatted: string;
    method: string;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(
    `[Deposit] Confirmed for user ${userId}: ${deposit.amountFormatted} via ${deposit.method}`,
  );
  this.server.to(roomName).emit('deposit:confirmed', deposit);
}

Payload:

json
{
  "id": "dep_123456",
  "amountCents": "5000",
  "amountFormatted": "R$ 50,00",
  "method": "pix"
}

4. notification

Notificacao pessoal para um usuario especifico.

Metodo:

typescript
emitNotificationToUser(userId: bigint, notification: any) {
  this.server.to(`user:${userId}`).emit('notification', notification);
}

Payload (exemplo):

json
{
  "type": "info",
  "title": "Item Recebido",
  "message": "Voce recebeu AK-47 | Redline",
  "metadata": {
    "itemId": "456",
    "source": "battle"
  }
}

5. withdrawal:pending

Emitido quando uma solicitacao de saque e criada e esta aguardando processamento.

Metodo:

typescript
emitWithdrawalPending(
  userId: bigint,
  data: {
    inventoryItemId: string;
    itemName: string;
    projectId: string;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Pending for user ${userId}: ${data.itemName}`);
  this.server.to(roomName).emit('withdrawal:pending', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "itemName": "AWP | Dragon Lore",
  "projectId": "proj_001"
}

6. withdrawal:processing

Emitido quando o saque entra em processamento ativo.

Metodo:

typescript
emitWithdrawalProcessing(
  userId: bigint,
  data: {
    inventoryItemId: string;
    waxpeerId?: string;
    sendUntil?: number;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Processing for user ${userId}, waxpeerId: ${data.waxpeerId}`);
  this.server.to(roomName).emit('withdrawal:processing', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "waxpeerId": "wx_456",
  "sendUntil": 1707667200000
}

Observacao: Este metodo existe no gateway mas nao possui callers ativos no codigo atual. E considerado dead code, mantido para uso futuro na integracao com Waxpeer.


7. withdrawal:waiting_seller

Emitido quando o saque esta aguardando o vendedor enviar a trade.

Metodo:

typescript
emitWithdrawalWaitingSeller(
  userId: bigint,
  data: {
    inventoryItemId: string;
    sellerName?: string;
    sendUntil?: number;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Waiting seller for user ${userId}`);
  this.server.to(roomName).emit('withdrawal:waiting_seller', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "sellerName": "SteamSeller42",
  "sendUntil": 1707667200000
}

8. withdrawal:trade_sent

Emitido quando a trade offer e enviada ao usuario.

Metodo:

typescript
emitWithdrawalTradeSent(
  userId: bigint,
  data: {
    inventoryItemId: string;
    tradeId?: string;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Trade sent for user ${userId}, tradeId: ${data.tradeId}`);
  this.server.to(roomName).emit('withdrawal:trade_sent', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "tradeId": "trade_12345"
}

9. withdrawal:completed

Emitido quando o saque e concluido com sucesso.

Metodo:

typescript
emitWithdrawalCompleted(
  userId: bigint,
  data: {
    inventoryItemId: string;
    itemName: string;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Completed for user ${userId}: ${data.itemName}`);
  this.server.to(roomName).emit('withdrawal:completed', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "itemName": "AWP | Dragon Lore"
}

10. withdrawal:cancelled

Emitido quando o saque e cancelado.

Metodo:

typescript
emitWithdrawalCancelled(
  userId: bigint,
  data: {
    inventoryItemId: string;
    reason: string;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Cancelled for user ${userId}: ${data.reason}`);
  this.server.to(roomName).emit('withdrawal:cancelled', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "reason": "Item indisponivel no mercado"
}

11. withdrawal:failed

Emitido quando o saque falha por erro tecnico.

Metodo:

typescript
emitWithdrawalFailed(
  userId: bigint,
  data: {
    inventoryItemId: string;
    reason: string;
  },
) {
  const roomName = `user:${userId}`;
  this.logger.log(`[Withdrawal] Failed for user ${userId}: ${data.reason}`);
  this.server.to(roomName).emit('withdrawal:failed', data);
}

Payload:

json
{
  "inventoryItemId": "inv_789",
  "reason": "Timeout na API do marketplace"
}

Tabela Resumo - Eventos Privados

EventoPayload PrincipalContexto
balance:updated{ balanceCents: string }Qualquer alteracao de saldo
eventTickets:updated{ eventTickets: number }Uso de tickets de evento
deposit:confirmed{ id, amountCents, amountFormatted, method }Deposito confirmado
notification{ type, title, message, metadata }Notificacao pessoal
withdrawal:pending{ inventoryItemId, itemName, projectId }Saque criado
withdrawal:processing{ inventoryItemId, waxpeerId?, sendUntil? }Saque em processamento
withdrawal:waiting_seller{ inventoryItemId, sellerName?, sendUntil? }Aguardando vendedor
withdrawal:trade_sent{ inventoryItemId, tradeId? }Trade enviada
withdrawal:completed{ inventoryItemId, itemName }Saque concluido
withdrawal:cancelled{ inventoryItemId, reason }Saque cancelado
withdrawal:failed{ inventoryItemId, reason }Saque falhou

Eventos Globais

Eventos emitidos via this.server.emit() para TODOS os clientes conectados (publicos e autenticados).


1. live:drop

Drop em tempo real de qualquer acao no site (caixa, upgrade, swap, batalha, evento).

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;
}) {
  const valueCents =
    typeof drop.itemValueCents === 'bigint' ? drop.itemValueCents : BigInt(drop.itemValueCents);
  const isTopDrop = valueCents >= BigInt(100000);

  this.logger.log(
    `Emitting live drop: ${drop.userName} got ${drop.itemName} (${drop.dropChance}%) - isTopDrop: ${isTopDrop}`,
  );
  this.server.emit('live:drop', {
    userName: drop.userName,
    userAvatar: drop.userAvatar,
    itemName: drop.itemName,
    itemImage: drop.itemImage,
    itemValueCents: valueCents.toString(),
    rarity: drop.rarity,
    source: drop.source,
    timestamp: drop.timestamp,
    dropChance: drop.dropChance,
    isTopDrop,
    caseImage: drop.caseImage,
  });
}

Payload:

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",
  "timestamp": "2025-12-02T18:30:00.000Z",
  "dropChance": 0.05,
  "isTopDrop": true,
  "caseImage": "https://storage.procases.example/cases/emerald.webp"
}

Calculo do isTopDrop:

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

Chamado por (com delays):

ArquivoSourceDelayCondicao
open-case.use-case.tscase12.000ms (normal) / 30.000ms (FLIP)Sempre
perform-upgrade.use-case.tsupgrade12.000msApenas quando success = true
execute-battle.use-case.tsbattle12.000ms (normal) / 30.000ms (FLIP)Apenas jogadores reais (!isBot)
perform-site-swap.use-case.tsswap3.000msSempre
perform-event-shoot.use-case.tsevent3.000msSempre

Por que o delay? O drop so aparece no feed apos a animacao do frontend terminar, para evitar spoiler do resultado. Veja detalhes completos no documento live-drops.md.


2. case:opened

Emitido quando uma caixa e aberta. Diferente do live:drop, este evento carrega dados visuais para o feed de aberturas.

Metodo:

typescript
emitCaseOpening(opening: any) {
  this.server.emit('case:opened', {
    id: opening.id.toString(),
    userId: opening.userId.toString(),
    username: opening.username,
    userAvatar: opening.userAvatar,
    caseName: opening.caseName,
    caseImage: opening.caseImage,
    itemName: opening.itemName,
    itemImage: opening.itemImage,
    itemRarity: opening.itemRarity,
    itemValue: opening.itemValue,
    bgColor: opening.bgColor,
    bottomColor: opening.bottomColor,
    isFlip: opening.isFlip,
    timestamp: opening.timestamp,
  });
}

Payload:

json
{
  "id": "123456789",
  "userId": "12345",
  "username": "hala",
  "userAvatar": "https://avatars.steamstatic.com/...",
  "caseName": "Emerald Case",
  "caseImage": "https://storage.procases.example/cases/emerald.webp",
  "itemName": "AWP | Dragon Lore",
  "itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
  "itemRarity": "EXTRAORDINARY",
  "itemValue": "R$ 1.500,00",
  "bgColor": "#ff4444",
  "bottomColor": "#cc0000",
  "isFlip": true,
  "timestamp": 1707667200000
}

Chamado por:

ArquivoContexto
live-drops.service.ts via broadcastCaseOpening()Apos abertura de caixa

Observacao: O campo itemValue e formatado via MoneyService.format() no formato "R$ X.XXX,XX". Este e o unico lugar onde valores formatados sao enviados via WebSocket (diferente do balance:updated que envia centavos puros).


3. upgrade:completed

Emitido quando um upgrade e realizado (tanto sucesso quanto falha).

Metodo:

typescript
emitUpgrade(upgrade: any) {
  this.server.emit('upgrade:completed', {
    id: upgrade.id.toString(),
    userId: upgrade.userId.toString(),
    username: upgrade.username,
    success: upgrade.success,
    targetItem: upgrade.targetItem,
    valueInvested: upgrade.valueInvested,
    timestamp: upgrade.timestamp,
  });
}

Payload:

json
{
  "id": "987654321",
  "userId": "12345",
  "username": "hala",
  "success": true,
  "targetItem": {
    "name": "AWP | Dragon Lore",
    "image": "https://community.cloudflare.steamstatic.com/economy/image/...",
    "rarity": "EXTRAORDINARY"
  },
  "valueInvested": "R$ 500,00",
  "timestamp": 1707667200000
}

Chamado por:

ArquivoContexto
live-drops.service.ts via broadcastUpgrade()Apos upgrade (sucesso ou falha)

Observacao: O campo valueInvested e formatado via MoneyService.format().


4. notification:global

Notificacao global para todos os clientes conectados.

Metodo:

typescript
emitGlobalNotification(notification: any) {
  this.server.emit('notification:global', notification);
}

Payload (exemplo):

json
{
  "type": "announcement",
  "title": "Manutencao Programada",
  "message": "O site entrara em manutencao as 03:00 BRT",
  "metadata": {
    "scheduledAt": "2025-12-02T06:00:00Z"
  }
}

5. battle:created

Emitido quando uma nova batalha e criada. Broadcast global para atualizar a lista de batalhas.

Metodo:

typescript
emitBattleCreated(battle: {
  id: string;
  mode: string;
  type: string;
  status: string;
  totalValueCents: string;
  playersCount: number;
  maxPlayers: number;
  creator: {
    id: string;
    username: string;
    avatarUrl?: string;
  };
  cases: {
    id: string;
    name: string;
    imageUrl: string;
    order: number;
  }[];
  isPrivate: boolean;
}) {
  this.logger.log(`[Battle] New battle created: ${battle.id}`);
  this.server.emit('battle:created', battle);
}

Payload:

json
{
  "id": "555",
  "mode": "1v1",
  "type": "NORMAL",
  "status": "WAITING",
  "totalValueCents": "5000",
  "playersCount": 1,
  "maxPlayers": 2,
  "creator": {
    "id": "12345",
    "username": "hala",
    "avatarUrl": "https://avatars.steamstatic.com/..."
  },
  "cases": [
    {
      "id": "1",
      "name": "Emerald Case",
      "imageUrl": "https://storage.procases.example/cases/emerald.webp",
      "order": 1
    }
  ],
  "isPrivate": false
}

6. battle:updated

Emitido quando o status ou numero de jogadores de uma batalha muda. Broadcast global.

Metodo:

typescript
emitBattleUpdated(battle: {
  id: string;
  status: string;
  playersCount: number;
  maxPlayers: number;
}) {
  this.logger.log(`[Battle] Battle updated: ${battle.id} - Status: ${battle.status}`);
  this.server.emit('battle:updated', battle);
}

Payload:

json
{
  "id": "555",
  "status": "IN_PROGRESS",
  "playersCount": 2,
  "maxPlayers": 2
}

7. battle:list_update

Emitido para atualizar a lista de batalhas no lobby. Broadcast global.

Metodo:

typescript
emitBattleListUpdate(battle: {
  id: string;
  mode: string;
  type: string;
  status: string;
  totalValueCents: string;
  playersCount: number;
  maxPlayers: number;
}) {
  this.server.emit('battle:list_update', battle);
}

Payload:

json
{
  "id": "555",
  "mode": "1v1",
  "type": "NORMAL",
  "status": "WAITING",
  "totalValueCents": "5000",
  "playersCount": 1,
  "maxPlayers": 2
}

Tabela Resumo - Eventos Globais

EventoPayload PrincipalContexto
live:drop{ userName, itemName, itemValueCents, rarity, source, isTopDrop, ... }Drop em tempo real
case:opened{ id, userId, username, caseName, itemName, itemValue, isFlip, ... }Caixa aberta
upgrade:completed{ id, userId, username, success, targetItem, valueInvested, ... }Upgrade realizado
notification:global{ type, title, message, metadata }Notificacao global
battle:created{ id, mode, type, status, creator, cases, isPrivate, ... }Batalha criada
battle:updated{ id, status, playersCount, maxPlayers }Batalha atualizada
battle:list_update{ id, mode, type, status, totalValueCents, playersCount, maxPlayers }Lista de batalhas

Eventos de Batalha

Eventos emitidos para a sala battle:{battleId}. Apenas clientes que fizeram battle:join recebem.


1. battle:player_joined

Emitido quando um jogador entra em uma batalha.

Metodo:

typescript
emitBattlePlayerJoined(
  battleId: bigint,
  player: {
    odUserId: string;
    odUsername: string;
    odAvatarUrl?: string;
    team: number;
  },
) {
  const roomName = `battle:${battleId}`;
  this.logger.log(`[Battle] Player ${player.odUsername} joined battle ${battleId}`);
  this.server.to(roomName).emit('battle:player_joined', {
    battleId: battleId.toString(),
    player,
  });
}

Payload:

json
{
  "battleId": "555",
  "player": {
    "odUserId": "12345",
    "odUsername": "hala",
    "odAvatarUrl": "https://avatars.steamstatic.com/...",
    "team": 1
  }
}

2. battle:starting

Emitido quando a batalha esta prestes a comecar (countdown).

Metodo:

typescript
emitBattleStarting(battleId: bigint, startsAt: Date) {
  const roomName = `battle:${battleId}`;
  this.logger.log(`[Battle] Battle ${battleId} starting at ${startsAt}`);
  this.server.to(roomName).emit('battle:starting', {
    battleId: battleId.toString(),
    startsAt: startsAt.toISOString(),
  });
}

Payload:

json
{
  "battleId": "555",
  "startsAt": "2025-12-02T18:30:05.000Z"
}

3. battle:round_start

Emitido no inicio de cada rodada.

Metodo:

typescript
emitBattleRoundStart(battleId: bigint, roundNumber: number, caseId: string, caseName: string) {
  const roomName = `battle:${battleId}`;
  this.logger.log(`[Battle] Round ${roundNumber} starting for battle ${battleId}`);
  this.server.to(roomName).emit('battle:round_start', {
    battleId: battleId.toString(),
    roundNumber,
    caseId,
    caseName,
  });
}

Payload:

json
{
  "battleId": "555",
  "roundNumber": 1,
  "caseId": "1",
  "caseName": "Emerald Case"
}

4. battle:round_result

Emitido com os resultados de cada rodada.

Metodo:

typescript
emitBattleRoundResult(
  battleId: bigint,
  roundNumber: number,
  results: {
    odPlayerId: string;
    odUsername: string;
    itemId: string;
    itemName: string;
    itemImage: string;
    itemRarity: string;
    itemValueCents: string;
    roll: number;
    isFlip: boolean;
  }[],
) {
  const roomName = `battle:${battleId}`;
  this.logger.log(`[Battle] Round ${roundNumber} results for battle ${battleId}`);
  this.server.to(roomName).emit('battle:round_result', {
    battleId: battleId.toString(),
    roundNumber,
    results,
  });
}

Payload:

json
{
  "battleId": "555",
  "roundNumber": 1,
  "results": [
    {
      "odPlayerId": "12345",
      "odUsername": "hala",
      "itemId": "789",
      "itemName": "AK-47 | Redline",
      "itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
      "itemRarity": "CLASSIFIED",
      "itemValueCents": "5000",
      "roll": 4523000,
      "isFlip": false
    },
    {
      "odPlayerId": "67890",
      "odUsername": "player2",
      "itemId": "456",
      "itemName": "M4A4 | Howl",
      "itemImage": "https://community.cloudflare.steamstatic.com/economy/image/...",
      "itemRarity": "COVERT",
      "itemValueCents": "12000",
      "roll": 9850000,
      "isFlip": true
    }
  ]
}

5. battle:state_push

Push proativo do estado da batalha para sincronizacao de clientes durante a execucao.

Metodo:

typescript
async emitBattleStatePush(battleId: bigint) {
  if (!this.battleStateService) return;

  const state = await this.battleStateService.getBattleState(battleId.toString());
  if (!state) return;

  const roomName = `battle:${battleId}`;
  this.server.to(roomName).emit('battle:state_push', {
    currentPhase: state.currentPhase,
    phaseEndsAt: state.phaseEndsAt,
    currentRound: state.currentRound,
    hasFlipInCurrentRound: state.hasFlipInCurrentRound,
    spinStartedAt: state.spinStartedAt,
    serverTime: Date.now(),
  });
}

Payload:

json
{
  "currentPhase": "SPINNING",
  "phaseEndsAt": 1707667210000,
  "currentRound": 3,
  "hasFlipInCurrentRound": false,
  "spinStartedAt": 1707667200000,
  "serverTime": 1707667202000
}

Fases possiveis do currentPhase:

FaseDescricaoDuracao tipica
COUNTDOWNContagem regressiva antes do inicio3.000ms
ROUND_STARTINGTransicao para inicio da rodada500ms
SPINNINGAnimacao da roleta em andamento12.000ms (normal) / 30.000ms (FLIP)
ROUND_COMPLETERodada finalizada, transicao1.000ms
FINISHEDBatalha encerradaN/A

6. battle:finished

Emitido quando a batalha termina. Inclui vencedores e dados de settlement.

Metodo:

typescript
emitBattleFinished(
  battleId: bigint,
  winners: {
    odUserId: string;
    odUsername: string;
    odAvatarUrl?: string;
    team: number;
    totalWonCents: string;
  }[],
  totalPotCents: string,
  settlementData?: Record<
    string,
    {
      itemId: string;
      itemName: string;
      itemImage: string;
      itemRarity: string;
      valueCents: string;
      type: string;
    }[]
  >,
) {
  const roomName = `battle:${battleId}`;
  this.logger.log(
    `[Battle] Battle ${battleId} finished. Winners: ${winners.map((w) => w.odUsername).join(', ')}`,
  );
  this.server.to(roomName).emit('battle:finished', {
    battleId: battleId.toString(),
    winners,
    totalPotCents,
    settlementData,
  });
  this.server.emit('battle:completed', {
    battleId: battleId.toString(),
    winners,
    totalPotCents,
    settlementData,
  });
}

Payload (sala battle:{battleId}):

json
{
  "battleId": "555",
  "winners": [
    {
      "odUserId": "12345",
      "odUsername": "hala",
      "odAvatarUrl": "https://avatars.steamstatic.com/...",
      "team": 1,
      "totalWonCents": "25000"
    }
  ],
  "totalPotCents": "50000",
  "settlementData": {
    "12345": [
      {
        "itemId": "789",
        "itemName": "AK-47 | Redline",
        "itemImage": "https://...",
        "itemRarity": "CLASSIFIED",
        "valueCents": "5000",
        "type": "DROPPED"
      },
      {
        "itemId": "111",
        "itemName": "USP-S | Kill Confirmed",
        "itemImage": "https://...",
        "itemRarity": "COVERT",
        "valueCents": "4800",
        "type": "ADDED_FROM_POOL"
      }
    ]
  }
}

Observacao importante: Este metodo emite DOIS eventos:

  1. battle:finished para a sala battle:{battleId} (apenas espectadores da batalha)
  2. battle:completed como broadcast global (para todos, usado para atualizar listas)

Tipos de settlement:

TipoDescricao
DROPPEDItem dropado normalmente durante a batalha
REMOVED_TO_POOLItem removido por exceder o fair share (vai para a casa)
ADDED_FROM_POOLItem adicionado do pool para compensar deficit

7. battle:cancelled

Emitido quando uma batalha e cancelada.

Metodo:

typescript
emitBattleCancelled(battleId: bigint, reason: string) {
  const roomName = `battle:${battleId}`;
  this.logger.log(`[Battle] Battle ${battleId} cancelled: ${reason}`);
  this.server.to(roomName).emit('battle:cancelled', {
    battleId: battleId.toString(),
    reason,
  });
}

Payload:

json
{
  "battleId": "555",
  "reason": "Tempo limite de espera excedido"
}

Tabela Resumo - Eventos de Batalha

EventoSalaPayload Principal
battle:player_joinedbattle:{id}{ battleId, player: { odUserId, odUsername, odAvatarUrl, team } }
battle:startingbattle:{id}{ battleId, startsAt }
battle:round_startbattle:{id}{ battleId, roundNumber, caseId, caseName }
battle:round_resultbattle:{id}{ battleId, roundNumber, results[] }
battle:state_pushbattle:{id}{ currentPhase, phaseEndsAt, currentRound, hasFlipInCurrentRound, spinStartedAt, serverTime }
battle:finishedbattle:{id} + global{ battleId, winners[], totalPotCents, settlementData }
battle:cancelledbattle:{id}{ battleId, reason }

Eventos de Sorteio

Eventos de sorteio (raffle) sao emitidos como broadcast global via this.server.emit().


1. raffle:ticket_purchased

Emitido quando um usuario compra tickets em um sorteio.

Metodo:

typescript
emitRaffleTicketPurchased(
  raffleId: bigint,
  data: {
    userId: string;
    username: string;
    avatarUrl: string;
    quantity: number;
    totalTicketsSold: number;
    maxTickets: number;
  },
) {
  this.logger.log(
    `[Raffle] Ticket purchased: ${data.quantity} tickets for raffle ${raffleId} by ${data.username} (${data.totalTicketsSold}/${data.maxTickets})`,
  );
  this.server.emit('raffle:ticket_purchased', {
    raffleId: raffleId.toString(),
    ...data,
  });
}

Payload:

json
{
  "raffleId": "100",
  "userId": "12345",
  "username": "hala",
  "avatarUrl": "https://avatars.steamstatic.com/...",
  "quantity": 5,
  "totalTicketsSold": 150,
  "maxTickets": 500
}

2. raffle:drawing

Emitido quando o sorteio comeca a ser realizado.

Metodo:

typescript
emitRaffleDrawing(raffleId: bigint) {
  this.logger.log(`[Raffle] Drawing started for raffle ${raffleId}`);
  this.server.emit('raffle:drawing', {
    raffleId: raffleId.toString(),
  });
}

Payload:

json
{
  "raffleId": "100"
}

3. raffle:results

Emitido com os resultados completos do sorteio, incluindo dados para animacao.

Metodo:

typescript
emitRaffleResults(
  raffleId: bigint,
  prizeResults: {
    prizeId: string;
    displayOrder: number;
    itemId: string;
    itemName: string;
    itemImageUrl: string;
    itemRarity: string;
    valueCentsBrl: string;
    winnerId: string;
    winnerUsername: string;
    winnerAvatarUrl: string;
    winningTicketNumber: number;
    serverSeedHash: string;
    sequenceIndex: number;
    animationDelayMs: number;
  }[],
) {
  this.logger.log(`[Raffle] Results for raffle ${raffleId}: ${prizeResults.length} prizes drawn`);
  this.server.emit('raffle:results', {
    raffleId: raffleId.toString(),
    prizeResults,
  });
}

Payload:

json
{
  "raffleId": "100",
  "prizeResults": [
    {
      "prizeId": "p1",
      "displayOrder": 1,
      "itemId": "789",
      "itemName": "AWP | Dragon Lore",
      "itemImageUrl": "https://community.cloudflare.steamstatic.com/economy/image/...",
      "itemRarity": "EXTRAORDINARY",
      "valueCentsBrl": "150000",
      "winnerId": "12345",
      "winnerUsername": "hala",
      "winnerAvatarUrl": "https://avatars.steamstatic.com/...",
      "winningTicketNumber": 42,
      "serverSeedHash": "a1b2c3d4e5f6...",
      "sequenceIndex": 0,
      "animationDelayMs": 0
    },
    {
      "prizeId": "p2",
      "displayOrder": 2,
      "itemId": "456",
      "itemName": "M4A4 | Howl",
      "itemImageUrl": "https://community.cloudflare.steamstatic.com/economy/image/...",
      "itemRarity": "COVERT",
      "valueCentsBrl": "80000",
      "winnerId": "67890",
      "winnerUsername": "player2",
      "winnerAvatarUrl": "https://avatars.steamstatic.com/...",
      "winningTicketNumber": 187,
      "serverSeedHash": "f6e5d4c3b2a1...",
      "sequenceIndex": 1,
      "animationDelayMs": 5000
    }
  ]
}

Campos de animacao:

CampoDescricao
displayOrderOrdem em que o premio e exibido na UI
sequenceIndexIndice na sequencia de sorteio (0-based)
animationDelayMsDelay em ms antes de revelar este premio na animacao

4. raffle:completed

Emitido quando o sorteio e finalizado (apos distribuicao de premios).

Metodo:

typescript
emitRaffleCompleted(raffleId: bigint) {
  this.logger.log(`[Raffle] Completed raffle ${raffleId}`);
  this.server.emit('raffle:completed', {
    raffleId: raffleId.toString(),
  });
}

Payload:

json
{
  "raffleId": "100"
}

Tabela Resumo - Eventos de Sorteio

EventoPayload Principal
raffle:ticket_purchased{ raffleId, userId, username, avatarUrl, quantity, totalTicketsSold, maxTickets }
raffle:drawing{ raffleId }
raffle:results{ raffleId, prizeResults[] }
raffle:completed{ raffleId }

Estrutura de Salas

O sistema usa tres tipos de salas para direcionar eventos:

1. Sala do Usuario: user:{userId}

Formato: user:12345

Entram automaticamente: Clientes autenticados, ao conectar.

Eventos recebidos:

  • balance:updated
  • eventTickets:updated
  • deposit:confirmed
  • notification
  • withdrawal:pending
  • withdrawal:processing
  • withdrawal:waiting_seller
  • withdrawal:trade_sent
  • withdrawal:completed
  • withdrawal:cancelled
  • withdrawal:failed

Observacao: Um usuario pode ter multiplas conexoes (abas, dispositivos). Todas recebem os mesmos eventos porque estao na mesma sala.

2. Sala de Batalha: battle:{battleId}

Formato: battle:555

Entram manualmente: Clientes que enviam o evento battle:join.

Eventos recebidos:

  • battle:player_joined
  • battle:starting
  • battle:round_start
  • battle:round_result
  • battle:state_push
  • battle:finished
  • battle:cancelled
  • battle:state (resposta ao join/request_state)

3. Broadcast Global (sem sala)

Mecanismo: this.server.emit() (envia para TODOS os sockets conectados).

Eventos:

  • live:drop
  • case:opened
  • upgrade:completed
  • notification:global
  • battle:created
  • battle:updated
  • battle:list_update
  • battle:completed
  • raffle:ticket_purchased
  • raffle:drawing
  • raffle:results
  • raffle:completed

Metodos Utilitarios

getConnectedUsersCount(): number

Retorna o numero de usuarios autenticados conectados (nao inclui conexoes publicas).

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

getConnectedUserIds(): bigint[]

Retorna array com os IDs de todos os usuarios autenticados conectados.

typescript
getConnectedUserIds(): bigint[] {
  return Array.from(this.connectedUsers.values());
}

setBattleStateService(battleStateService: BattleStateService): void

Injeta o BattleStateService no gateway. Chamado pelo WebSocketModule.onModuleInit().

typescript
setBattleStateService(battleStateService: BattleStateService) {
  this.battleStateService = battleStateService;
}

Por que setter e nao constructor? O BattleStateService depende do RedisService que pode nao estar disponivel no momento da construcao do gateway. O setter garante que a injecao acontece apos a inicializacao do modulo.


BattleStateService

Arquivo: src/application/services/battle-state.service.ts

Gerencia o estado de batalhas em andamento usando Redis como store.

Interface BattleState

typescript
interface BattleState {
  battleId: string;
  status: 'WAITING' | 'STARTING' | 'IN_PROGRESS' | 'FINISHED';
  currentRound: number;
  totalRounds: number;
  currentPhase: 'COUNTDOWN' | 'ROUND_STARTING' | 'SPINNING' | 'ROUND_COMPLETE' | 'FINISHED';
  phaseEndsAt: number;
  hasFlipInCurrentRound: boolean;
  spinStartedAt?: number;
  currentRoundCaseId?: string;
  currentRoundCaseName?: string;
  currentRoundResults?: BattlePlayerResult[];
  completedRounds: CompletedRound[];
  playerTotals: Record<string, number>;
}

Interface BattlePlayerResult

typescript
interface BattlePlayerResult {
  odPlayerId: string;
  odUsername: string;
  itemId: string;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  itemValueCents: string;
  roll: number;
  isFlip: boolean;
  flipRoll?: number;
  flipItems?: any[];
  normalItems?: any[];
}

Interface CompletedRound

typescript
interface CompletedRound {
  roundNumber: number;
  caseId: string;
  caseName: string;
  results: BattlePlayerResult[];
  completedAt: number;
}

Metodos

MetodoDescricao
initializeBattleState(battleId, totalRounds, playerIds)Cria estado inicial no Redis (TTL: 3600s)
startRound(battleId, roundNumber, caseId, caseName)Marca inicio de rodada (fase ROUND_STARTING, 500ms)
setRoundSpinning(battleId, roundNumber, results, spinDurationMs)Marca fase SPINNING, salva resultados
completeRound(battleId, roundNumber)Move resultados para completedRounds (fase ROUND_COMPLETE, 1000ms)
finishBattle(battleId)Marca batalha como FINISHED
getBattleState(battleId)Busca estado do Redis (retorna null se nao existe)
deleteBattleState(battleId)Remove estado do Redis
isPhaseExpired(state)Verifica se Date.now() >= state.phaseEndsAt
getTimeRemainingMs(state)Calcula Math.max(0, state.phaseEndsAt - Date.now())

Chave Redis

battle:state:{battleId}

TTL: 3600 segundos (1 hora). O estado e automaticamente limpo apos 1 hora.


Redis Adapter

Arquivo: src/infrastructure/websocket/redis-io.adapter.ts

O Redis Adapter permite escalar o WebSocket horizontalmente entre multiplas instancias do backend.

Como funciona

  • Usa o adapter oficial @socket.io/redis-adapter
  • Todos os eventos emitidos sao automaticamente publicados no Redis
  • Outras instancias recebem os eventos via Redis Pub/Sub e os reencaminham para seus clientes locais
  • Salas (rooms) sao compartilhadas entre instancias

Configuracao no main.ts

typescript
const redisIoAdapter = new RedisIoAdapter(app);
await redisIoAdapter.connectToRedis();
app.useWebSocketAdapter(redisIoAdapter);

Status atual

Este wiring esta desativado (comentado) no src/main.ts. Hoje o Socket.io roda in-memory / single-node e nao ha app.useWebSocketAdapter(...) ativo. O RedisIoAdapter continua no codigo, com fallback graceful para o adapter in-memory quando REDIS_HOST/REDIS_PASSWORD estao ausentes. Para escalar horizontalmente basta reativar o bloco acima.

Implicacao pratica

Quando o Redis Adapter esta ativo, o metodo this.server.to('user:12345').emit('balance:updated', ...) chamado em uma instancia chega ao cliente mesmo que ele esteja conectado a outra instancia, porque o adapter propaga o evento automaticamente. Com o adapter desativado (estado atual), a entrega so acontece dentro do processo local.

Fan-out de crash entre containers

O fan-out de eventos de crash entre containers NAO usa o redis-adapter do Socket.io. Ele e feito por um relay dedicado (CrashWsRelayService, src/infrastructure/websocket/crash-ws-relay.service.ts) via Redis Pub/Sub puro, com prefixo de canal crash:ws:emit:. O container crash-engine publica os eventos e o container api os reencaminha (server.emit) para os clientes WebSocket locais.


Modulo WebSocket

Arquivo: src/infrastructure/websocket/websocket.module.ts

typescript
@Module({
  imports: [RedisModule],
  providers: [WebSocketGatewayService, SessionService, BattleStateService],
  exports: [WebSocketGatewayService, BattleStateService],
})
export class WebSocketModule implements OnModuleInit {
  constructor(
    private readonly webSocketGateway: WebSocketGatewayService,
    private readonly battleStateService: BattleStateService,
  ) {}

  onModuleInit() {
    this.webSocketGateway.setBattleStateService(this.battleStateService);
  }
}

Dependencias do modulo

ProviderExportadoDescricao
WebSocketGatewayServiceSimGateway principal
SessionServiceNaoValidacao de sessions
BattleStateServiceSimEstado de batalhas no Redis

Inicializacao

No onModuleInit, o BattleStateService e injetado no gateway via setter. Isso garante que o gateway pode atender requests de battle:join e battle:request_state com dados do Redis.


Debugging e Troubleshooting

Logs do Backend

O gateway gera logs detalhados em todos os pontos criticos:

Conexao:

[WebSocketGatewayService] Client abc123 connected (User: 12345, Room: user:12345)
[WebSocketGatewayService] Client abc123 rooms: abc123, user:12345

Conexao publica:

[WebSocketGatewayService] Client abc123 connected without session (public mode)

Desconexao:

[WebSocketGatewayService] Client abc123 disconnected (User: 12345)

Balance update:

[WebSocketGatewayService] [Balance Update] User: 12345, BalanceCents: 186321, Room: user:12345
[WebSocketGatewayService] [Balance Update] Clients in room user:12345: 1
[WebSocketGatewayService] [Balance Update] Event emitted to room user:12345

Balance update sem clientes (warning):

[WebSocketGatewayService] [Balance Update] No clients in room user:12345! User may not be connected via WebSocket.

Live drop:

[WebSocketGatewayService] Emitting live drop: hala got AWP | Dragon Lore (0.05%) - isTopDrop: true

Batalha:

[WebSocketGatewayService] [Battle] Player hala joined battle 555
[WebSocketGatewayService] [Battle] Battle 555 starting at 2025-12-02T18:30:05.000Z
[WebSocketGatewayService] [Battle] Round 1 starting for battle 555
[WebSocketGatewayService] [Battle] Round 1 results for battle 555
[WebSocketGatewayService] [Battle] Battle 555 finished. Winners: hala
[WebSocketGatewayService] [Battle] Battle 555 cancelled: Timeout

Sorteio:

[WebSocketGatewayService] [Raffle] Ticket purchased: 5 tickets for raffle 100 by hala (150/500)
[WebSocketGatewayService] [Raffle] Drawing started for raffle 100
[WebSocketGatewayService] [Raffle] Results for raffle 100: 3 prizes drawn
[WebSocketGatewayService] [Raffle] Completed raffle 100

Withdrawal:

[WebSocketGatewayService] [Withdrawal] Pending for user 12345: AWP | Dragon Lore
[WebSocketGatewayService] [Withdrawal] Completed for user 12345: AWP | Dragon Lore
[WebSocketGatewayService] [Withdrawal] Failed for user 12345: Timeout na API

Diagnostico: Eventos Nao Chegam ao Cliente

  1. Verificar se o cliente esta na sala correta:

    • Log de conexao deve mostrar Room: user:{userId}
    • O emitBalanceUpdate loga quantos clientes estao na sala
  2. Verificar a topologia de instancias:

    • No estado atual (single-node, Redis Adapter desativado) o evento so e entregue dentro do processo local; garanta que o cliente esta conectado a mesma instancia que emitiu
    • Se o Redis Adapter for reativado, clientes em outras instancias recebem via Redis Pub/Sub; verificar conectividade Redis
    • Eventos de crash usam o relay dedicado CrashWsRelayService (canais crash:ws:emit:*), independente do adapter
  3. Verificar se a session e valida:

    • Se a session expirou, o cliente esta em modo publico e nao recebe eventos privados
  4. Verificar se o evento esta sendo emitido:

    • Todos os metodos emit* tem logs. Se o log aparece mas o cliente nao recebe, o problema e na camada de transporte

Diagnostico: Saldo Exibido Incorretamente

Veja a secao "Centavos Everywhere" no CLAUDE.md. Resumo:

  1. Backend envia { balanceCents: "186321" } (string de centavos)
  2. Frontend parseia com parseInt(data.balanceCents, 10)
  3. Exibicao usa (balanceCents / 100).toLocaleString('pt-BR', { minimumFractionDigits: 2 })

Se o saldo aparece 100x maior, alguem esta multiplicando por 100 um valor que ja esta em centavos.

Diagnostico: Batalha Nao Sincroniza

  1. Verificar se o cliente fez battle:join (log deve aparecer)
  2. Verificar se BattleStateService esta inicializado (log de warning se nao)
  3. Verificar TTL do estado no Redis (expira em 3600s)
  4. Usar battle:request_state para forcar sincronizacao

Tabela Completa de Todos os Eventos

Eventos Recebidos pelo Servidor (Client -> Server)

EventoPayloadRespostaRequer Auth
pingnenhumpong + timestampNao
battle:join{ battleId }battle:joined + battle:stateNao
battle:leave{ battleId }battle:leftNao
battle:request_state{ battleId }battle:stateNao

Eventos Emitidos pelo Servidor (Server -> Client)

EventoDestinoPayload
connectedSocket individual{ message, userId?, room? }
balance:updateduser:{userId}{ balanceCents }
eventTickets:updateduser:{userId}{ eventTickets }
deposit:confirmeduser:{userId}{ id, amountCents, amountFormatted, method }
notificationuser:{userId}{ type, title, message, metadata }
withdrawal:pendinguser:{userId}{ inventoryItemId, itemName, projectId }
withdrawal:processinguser:{userId}{ inventoryItemId, waxpeerId?, sendUntil? }
withdrawal:waiting_selleruser:{userId}{ inventoryItemId, sellerName?, sendUntil? }
withdrawal:trade_sentuser:{userId}{ inventoryItemId, tradeId? }
withdrawal:completeduser:{userId}{ inventoryItemId, itemName }
withdrawal:cancelleduser:{userId}{ inventoryItemId, reason }
withdrawal:faileduser:{userId}{ inventoryItemId, reason }
live:dropBroadcast global{ userName, userAvatar?, itemName, itemImage, itemValueCents, rarity, source, timestamp, dropChance, isTopDrop, caseImage? }
case:openedBroadcast global{ id, userId, username, userAvatar, caseName, caseImage, itemName, itemImage, itemRarity, itemValue, bgColor, bottomColor, isFlip, timestamp }
upgrade:completedBroadcast global{ id, userId, username, success, targetItem, valueInvested, timestamp }
notification:globalBroadcast global{ type, title, message, metadata }
battle:createdBroadcast global{ id, mode, type, status, totalValueCents, playersCount, maxPlayers, creator, cases, isPrivate }
battle:updatedBroadcast global{ id, status, playersCount, maxPlayers }
battle:list_updateBroadcast global{ id, mode, type, status, totalValueCents, playersCount, maxPlayers }
battle:completedBroadcast global{ battleId, winners, totalPotCents, settlementData }
battle:stateSocket individual{ ...BattleState, isPhaseExpired, timeRemainingMs, serverTime }
battle:player_joinedbattle:{id}{ battleId, player }
battle:startingbattle:{id}{ battleId, startsAt }
battle:round_startbattle:{id}{ battleId, roundNumber, caseId, caseName }
battle:round_resultbattle:{id}{ battleId, roundNumber, results[] }
battle:state_pushbattle:{id}{ currentPhase, phaseEndsAt, currentRound, hasFlipInCurrentRound, spinStartedAt, serverTime }
battle:finishedbattle:{id}{ battleId, winners, totalPotCents, settlementData }
battle:cancelledbattle:{id}{ battleId, reason }
raffle:ticket_purchasedBroadcast global{ raffleId, userId, username, avatarUrl, quantity, totalTicketsSold, maxTickets }
raffle:drawingBroadcast global{ raffleId }
raffle:resultsBroadcast global{ raffleId, prizeResults[] }
raffle:completedBroadcast global{ raffleId }

Arquivos Relacionados

ArquivoDescricao
src/infrastructure/websocket/websocket.gateway.tsGateway principal com todos os metodos de emissao
src/infrastructure/websocket/websocket.module.tsModulo NestJS do WebSocket
src/infrastructure/websocket/redis-io.adapter.tsRedis Adapter para escalabilidade horizontal
src/application/services/battle-state.service.tsGerenciamento de estado de batalhas no Redis
src/application/services/live-drops.service.tsServico intermediario para case:opened e upgrade:completed
src/infrastructure/auth/session.service.tsValidacao de sessions para autenticacao

Documentação Técnica - ProCases