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
- Configuracao do Gateway
- Gerenciamento de Conexao
- Extracao de Session ID
- Desconexao
- Eventos do Cliente (Incoming)
- Eventos do Servidor (Emitted)
- Estrutura de Salas
- Metodos Utilitarios
- BattleStateService
- Redis Adapter
- Modulo WebSocket
- Debugging e Troubleshooting
Configuracao do Gateway
Arquivo: src/infrastructure/websocket/websocket.gateway.ts
O gateway e decorado com @WebSocketGateway e configurado da seguinte forma:
@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
| Parametro | Valor | Descricao |
|---|---|---|
cors.origin | process.env.FRONTEND_URL ou http://localhost:5173 | Origem permitida para CORS |
cors.credentials | true | Permite cookies e headers de autenticacao |
transports | ['websocket', 'polling'] | Transportes suportados (WebSocket preferido, polling como fallback) |
Estado Interno
| Propriedade | Tipo | Descricao |
|---|---|---|
server | Server (Socket.io) | Instancia do servidor Socket.io |
connectedUsers | Map<string, bigint> | Mapa de socketId para userId |
battleStateService | BattleStateService | null | Injetado via setBattleStateService() no OnModuleInit |
Dependencias
| Dependencia | Tipo | Descricao |
|---|---|---|
SessionService | Injetada via constructor | Valida sessions para autenticacao |
BattleStateService | Injetada via setter | Gerencia estado de batalhas no Redis |
Gerenciamento de Conexao
handleConnection(client: Socket)
Metodo chamado automaticamente quando um cliente se conecta ao WebSocket.
Fluxo completo:
- Tenta extrair o
sessionIddo cliente - Se nao ha
sessionId: conexao publica - Se ha
sessionId: valida a session viaSessionService.getSession() - Se a session e invalida: conexao publica
- Se a session e valida: conexao autenticada, entra na sala
user:{userId} - Em caso de erro: conexao publica (fail-safe)
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:
{
"message": "Connected to live drops (public)"
}Conexao autenticada:
{
"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
socketIdpelo 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:
- handshake.auth.sessionId: Enviado pelo cliente na conexao
- Cookie
sessionId: Parseado do headerCookie
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
| Prioridade | Fonte | Exemplo |
|---|---|---|
| 1 | client.handshake.auth.sessionId | io(URL, { auth: { sessionId: 'abc123' } }) |
| 2 | Cookie sessionId | Header 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.
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
socketIddo mapaconnectedUsers - Loga a desconexao com o
userId(ouundefinedse 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:
@SubscribeMessage('ping')
handlePing(@ConnectedSocket() client: Socket) {
return { event: 'pong', data: { timestamp: Date.now() } };
}Payload de resposta:
{
"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:
{ battleId: string }Comportamento completo:
- Cliente entra na sala
battle:{battleId} - Se
BattleStateServiceesta disponivel:- Busca o estado atual da batalha no Redis
- Se a batalha esta
IN_PROGRESSouSTARTING, envia o estado viabattle:state - Inclui campos calculados:
isPhaseExpired,timeRemainingMs,serverTime
- Retorna confirmacao via
battle:joined
@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:
{
"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:
{
"event": "battle:joined",
"data": {
"battleId": "123",
"room": "battle:123"
}
}3. battle:leave
Cliente solicita sair da sala de uma batalha.
Payload de entrada:
{ battleId: string }@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:
{
"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:
{ battleId: string }Comportamento:
- Se
BattleStateServicenao esta disponivel: retorna sem emitir nada (warn log) - Se nao encontra estado para a batalha: emite
battle:statecomstatus: 'NOT_FOUND' - Se encontra: emite
battle:statecom o estado completo + campos calculados
@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:
{
"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:
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:
{
"balanceCents": "186321"
}Chamado por (use cases):
| Arquivo | Contexto |
|---|---|
open-case.use-case.ts | Apos abertura de caixa (debito) |
perform-upgrade.use-case.ts | Apos upgrade (debito de saldo complementar) |
create-battle.use-case.ts | Apos criar batalha (debito do custo) |
join-battle.use-case.ts | Apos entrar em batalha (debito do custo) |
sell-inventory-item.use-case.ts | Apos vender item (credito) |
sell-all-items.use-case.ts | Apos vender todos os itens (credito) |
deposit.service.ts | Apos 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:
emitEventTicketsUpdate(userId: bigint, eventTickets: number) {
const roomName = `user:${userId}`;
this.server.to(roomName).emit('eventTickets:updated', { eventTickets });
}Payload:
{
"eventTickets": 5
}Chamado por:
| Arquivo | Contexto |
|---|---|
perform-event-shoot.use-case.ts | Apos usar tickets no evento "Atire na Caixa" |
3. deposit:confirmed
Emitido quando um deposito e confirmado com sucesso.
Metodo:
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:
{
"id": "dep_123456",
"amountCents": "5000",
"amountFormatted": "R$ 50,00",
"method": "pix"
}4. notification
Notificacao pessoal para um usuario especifico.
Metodo:
emitNotificationToUser(userId: bigint, notification: any) {
this.server.to(`user:${userId}`).emit('notification', notification);
}Payload (exemplo):
{
"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:
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:
{
"inventoryItemId": "inv_789",
"itemName": "AWP | Dragon Lore",
"projectId": "proj_001"
}6. withdrawal:processing
Emitido quando o saque entra em processamento ativo.
Metodo:
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:
{
"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:
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:
{
"inventoryItemId": "inv_789",
"sellerName": "SteamSeller42",
"sendUntil": 1707667200000
}8. withdrawal:trade_sent
Emitido quando a trade offer e enviada ao usuario.
Metodo:
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:
{
"inventoryItemId": "inv_789",
"tradeId": "trade_12345"
}9. withdrawal:completed
Emitido quando o saque e concluido com sucesso.
Metodo:
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:
{
"inventoryItemId": "inv_789",
"itemName": "AWP | Dragon Lore"
}10. withdrawal:cancelled
Emitido quando o saque e cancelado.
Metodo:
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:
{
"inventoryItemId": "inv_789",
"reason": "Item indisponivel no mercado"
}11. withdrawal:failed
Emitido quando o saque falha por erro tecnico.
Metodo:
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:
{
"inventoryItemId": "inv_789",
"reason": "Timeout na API do marketplace"
}Tabela Resumo - Eventos Privados
| Evento | Payload Principal | Contexto |
|---|---|---|
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:
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:
{
"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:
const isTopDrop = valueCents >= BigInt(100000); // >= R$1.000,00Chamado por (com delays):
| Arquivo | Source | Delay | Condicao |
|---|---|---|---|
open-case.use-case.ts | case | 12.000ms (normal) / 30.000ms (FLIP) | Sempre |
perform-upgrade.use-case.ts | upgrade | 12.000ms | Apenas quando success = true |
execute-battle.use-case.ts | battle | 12.000ms (normal) / 30.000ms (FLIP) | Apenas jogadores reais (!isBot) |
perform-site-swap.use-case.ts | swap | 3.000ms | Sempre |
perform-event-shoot.use-case.ts | event | 3.000ms | Sempre |
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:
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:
{
"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:
| Arquivo | Contexto |
|---|---|
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:
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:
{
"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:
| Arquivo | Contexto |
|---|---|
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:
emitGlobalNotification(notification: any) {
this.server.emit('notification:global', notification);
}Payload (exemplo):
{
"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:
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:
{
"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:
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:
{
"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:
emitBattleListUpdate(battle: {
id: string;
mode: string;
type: string;
status: string;
totalValueCents: string;
playersCount: number;
maxPlayers: number;
}) {
this.server.emit('battle:list_update', battle);
}Payload:
{
"id": "555",
"mode": "1v1",
"type": "NORMAL",
"status": "WAITING",
"totalValueCents": "5000",
"playersCount": 1,
"maxPlayers": 2
}Tabela Resumo - Eventos Globais
| Evento | Payload Principal | Contexto |
|---|---|---|
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:
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:
{
"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:
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:
{
"battleId": "555",
"startsAt": "2025-12-02T18:30:05.000Z"
}3. battle:round_start
Emitido no inicio de cada rodada.
Metodo:
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:
{
"battleId": "555",
"roundNumber": 1,
"caseId": "1",
"caseName": "Emerald Case"
}4. battle:round_result
Emitido com os resultados de cada rodada.
Metodo:
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:
{
"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:
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:
{
"currentPhase": "SPINNING",
"phaseEndsAt": 1707667210000,
"currentRound": 3,
"hasFlipInCurrentRound": false,
"spinStartedAt": 1707667200000,
"serverTime": 1707667202000
}Fases possiveis do currentPhase:
| Fase | Descricao | Duracao tipica |
|---|---|---|
COUNTDOWN | Contagem regressiva antes do inicio | 3.000ms |
ROUND_STARTING | Transicao para inicio da rodada | 500ms |
SPINNING | Animacao da roleta em andamento | 12.000ms (normal) / 30.000ms (FLIP) |
ROUND_COMPLETE | Rodada finalizada, transicao | 1.000ms |
FINISHED | Batalha encerrada | N/A |
6. battle:finished
Emitido quando a batalha termina. Inclui vencedores e dados de settlement.
Metodo:
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}):
{
"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:
battle:finishedpara a salabattle:{battleId}(apenas espectadores da batalha)battle:completedcomo broadcast global (para todos, usado para atualizar listas)
Tipos de settlement:
| Tipo | Descricao |
|---|---|
DROPPED | Item dropado normalmente durante a batalha |
REMOVED_TO_POOL | Item removido por exceder o fair share (vai para a casa) |
ADDED_FROM_POOL | Item adicionado do pool para compensar deficit |
7. battle:cancelled
Emitido quando uma batalha e cancelada.
Metodo:
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:
{
"battleId": "555",
"reason": "Tempo limite de espera excedido"
}Tabela Resumo - Eventos de Batalha
| Evento | Sala | Payload Principal |
|---|---|---|
battle:player_joined | battle:{id} | { battleId, player: { odUserId, odUsername, odAvatarUrl, team } } |
battle:starting | battle:{id} | { battleId, startsAt } |
battle:round_start | battle:{id} | { battleId, roundNumber, caseId, caseName } |
battle:round_result | battle:{id} | { battleId, roundNumber, results[] } |
battle:state_push | battle:{id} | { currentPhase, phaseEndsAt, currentRound, hasFlipInCurrentRound, spinStartedAt, serverTime } |
battle:finished | battle:{id} + global | { battleId, winners[], totalPotCents, settlementData } |
battle:cancelled | battle:{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:
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:
{
"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:
emitRaffleDrawing(raffleId: bigint) {
this.logger.log(`[Raffle] Drawing started for raffle ${raffleId}`);
this.server.emit('raffle:drawing', {
raffleId: raffleId.toString(),
});
}Payload:
{
"raffleId": "100"
}3. raffle:results
Emitido com os resultados completos do sorteio, incluindo dados para animacao.
Metodo:
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:
{
"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:
| Campo | Descricao |
|---|---|
displayOrder | Ordem em que o premio e exibido na UI |
sequenceIndex | Indice na sequencia de sorteio (0-based) |
animationDelayMs | Delay em ms antes de revelar este premio na animacao |
4. raffle:completed
Emitido quando o sorteio e finalizado (apos distribuicao de premios).
Metodo:
emitRaffleCompleted(raffleId: bigint) {
this.logger.log(`[Raffle] Completed raffle ${raffleId}`);
this.server.emit('raffle:completed', {
raffleId: raffleId.toString(),
});
}Payload:
{
"raffleId": "100"
}Tabela Resumo - Eventos de Sorteio
| Evento | Payload 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:updatedeventTickets:updateddeposit:confirmednotificationwithdrawal:pendingwithdrawal:processingwithdrawal:waiting_sellerwithdrawal:trade_sentwithdrawal:completedwithdrawal:cancelledwithdrawal: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_joinedbattle:startingbattle:round_startbattle:round_resultbattle:state_pushbattle:finishedbattle:cancelledbattle:state(resposta ao join/request_state)
3. Broadcast Global (sem sala)
Mecanismo: this.server.emit() (envia para TODOS os sockets conectados).
Eventos:
live:dropcase:openedupgrade:completednotification:globalbattle:createdbattle:updatedbattle:list_updatebattle:completedraffle:ticket_purchasedraffle:drawingraffle:resultsraffle:completed
Metodos Utilitarios
getConnectedUsersCount(): number
Retorna o numero de usuarios autenticados conectados (nao inclui conexoes publicas).
getConnectedUsersCount(): number {
return this.connectedUsers.size;
}getConnectedUserIds(): bigint[]
Retorna array com os IDs de todos os usuarios autenticados conectados.
getConnectedUserIds(): bigint[] {
return Array.from(this.connectedUsers.values());
}setBattleStateService(battleStateService: BattleStateService): void
Injeta o BattleStateService no gateway. Chamado pelo WebSocketModule.onModuleInit().
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
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
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
interface CompletedRound {
roundNumber: number;
caseId: string;
caseName: string;
results: BattlePlayerResult[];
completedAt: number;
}Metodos
| Metodo | Descricao |
|---|---|
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
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
@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
| Provider | Exportado | Descricao |
|---|---|---|
WebSocketGatewayService | Sim | Gateway principal |
SessionService | Nao | Validacao de sessions |
BattleStateService | Sim | Estado 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:12345Conexao 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:12345Balance 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: trueBatalha:
[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: TimeoutSorteio:
[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 100Withdrawal:
[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 APIDiagnostico: Eventos Nao Chegam ao Cliente
Verificar se o cliente esta na sala correta:
- Log de conexao deve mostrar
Room: user:{userId} - O
emitBalanceUpdateloga quantos clientes estao na sala
- Log de conexao deve mostrar
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(canaiscrash:ws:emit:*), independente do adapter
Verificar se a session e valida:
- Se a session expirou, o cliente esta em modo publico e nao recebe eventos privados
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
- Todos os metodos
Diagnostico: Saldo Exibido Incorretamente
Veja a secao "Centavos Everywhere" no CLAUDE.md. Resumo:
- Backend envia
{ balanceCents: "186321" }(string de centavos) - Frontend parseia com
parseInt(data.balanceCents, 10) - 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
- Verificar se o cliente fez
battle:join(log deve aparecer) - Verificar se
BattleStateServiceesta inicializado (log de warning se nao) - Verificar TTL do estado no Redis (expira em 3600s)
- Usar
battle:request_statepara forcar sincronizacao
Tabela Completa de Todos os Eventos
Eventos Recebidos pelo Servidor (Client -> Server)
| Evento | Payload | Resposta | Requer Auth |
|---|---|---|---|
ping | nenhum | pong + timestamp | Nao |
battle:join | { battleId } | battle:joined + battle:state | Nao |
battle:leave | { battleId } | battle:left | Nao |
battle:request_state | { battleId } | battle:state | Nao |
Eventos Emitidos pelo Servidor (Server -> Client)
| Evento | Destino | Payload |
|---|---|---|
connected | Socket individual | { message, userId?, room? } |
balance:updated | user:{userId} | { balanceCents } |
eventTickets:updated | user:{userId} | { eventTickets } |
deposit:confirmed | user:{userId} | { id, amountCents, amountFormatted, method } |
notification | user:{userId} | { type, title, message, metadata } |
withdrawal:pending | user:{userId} | { inventoryItemId, itemName, projectId } |
withdrawal:processing | user:{userId} | { inventoryItemId, waxpeerId?, sendUntil? } |
withdrawal:waiting_seller | user:{userId} | { inventoryItemId, sellerName?, sendUntil? } |
withdrawal:trade_sent | user:{userId} | { inventoryItemId, tradeId? } |
withdrawal:completed | user:{userId} | { inventoryItemId, itemName } |
withdrawal:cancelled | user:{userId} | { inventoryItemId, reason } |
withdrawal:failed | user:{userId} | { inventoryItemId, reason } |
live:drop | Broadcast global | { userName, userAvatar?, itemName, itemImage, itemValueCents, rarity, source, timestamp, dropChance, isTopDrop, caseImage? } |
case:opened | Broadcast global | { id, userId, username, userAvatar, caseName, caseImage, itemName, itemImage, itemRarity, itemValue, bgColor, bottomColor, isFlip, timestamp } |
upgrade:completed | Broadcast global | { id, userId, username, success, targetItem, valueInvested, timestamp } |
notification:global | Broadcast global | { type, title, message, metadata } |
battle:created | Broadcast global | { id, mode, type, status, totalValueCents, playersCount, maxPlayers, creator, cases, isPrivate } |
battle:updated | Broadcast global | { id, status, playersCount, maxPlayers } |
battle:list_update | Broadcast global | { id, mode, type, status, totalValueCents, playersCount, maxPlayers } |
battle:completed | Broadcast global | { battleId, winners, totalPotCents, settlementData } |
battle:state | Socket individual | { ...BattleState, isPhaseExpired, timeRemainingMs, serverTime } |
battle:player_joined | battle:{id} | { battleId, player } |
battle:starting | battle:{id} | { battleId, startsAt } |
battle:round_start | battle:{id} | { battleId, roundNumber, caseId, caseName } |
battle:round_result | battle:{id} | { battleId, roundNumber, results[] } |
battle:state_push | battle:{id} | { currentPhase, phaseEndsAt, currentRound, hasFlipInCurrentRound, spinStartedAt, serverTime } |
battle:finished | battle:{id} | { battleId, winners, totalPotCents, settlementData } |
battle:cancelled | battle:{id} | { battleId, reason } |
raffle:ticket_purchased | Broadcast global | { raffleId, userId, username, avatarUrl, quantity, totalTicketsSold, maxTickets } |
raffle:drawing | Broadcast global | { raffleId } |
raffle:results | Broadcast global | { raffleId, prizeResults[] } |
raffle:completed | Broadcast global | { raffleId } |
Arquivos Relacionados
| Arquivo | Descricao |
|---|---|
src/infrastructure/websocket/websocket.gateway.ts | Gateway principal com todos os metodos de emissao |
src/infrastructure/websocket/websocket.module.ts | Modulo NestJS do WebSocket |
src/infrastructure/websocket/redis-io.adapter.ts | Redis Adapter para escalabilidade horizontal |
src/application/services/battle-state.service.ts | Gerenciamento de estado de batalhas no Redis |
src/application/services/live-drops.service.ts | Servico intermediario para case:opened e upgrade:completed |
src/infrastructure/auth/session.service.ts | Validacao de sessions para autenticacao |
