Battles - Sistema Completo de Batalhas PvP
Indice
- Visao Geral
- Tipos e Modos
- Modelo de Dados (Prisma)
- Fluxo de Criacao
- Fluxo de Entrada (Join)
- Sistema de Bots
- Fluxo de Execucao
- Settlement (Distribuicao Justa)
- Endpoints da API
- Eventos WebSocket
- Battle State Service (Redis)
- Provably Fair em Batalhas
- Casos de Borda e Validacoes
- Debugging
Visao Geral
Batalhas sao confrontos entre times de jogadores onde cada jogador abre as mesmas caixas simultaneamente. O resultado depende do modo da batalha: no modo NORMAL o time com maior valor total vence, no modo FLIP o time com menor valor total vence, e no modo SHARED todos ganham.
Cada batalha suporta de 1 a 10 caixas (rodadas). A cada rodada, todos os jogadores abrem a mesma caixa e recebem um item determinado pelo sistema Provably Fair. Ao final de todas as rodadas, o time vencedor recebe todos os itens dropados, distribuidos de forma justa entre os membros do time.
Arquivos principais do sistema:
| Arquivo | Responsabilidade |
|---|---|
src/application/use-cases/battle/create-battle.use-case.ts | Criacao de batalhas |
src/application/use-cases/battle/join-battle.use-case.ts | Entrada de jogadores |
src/application/use-cases/battle/execute-battle.use-case.ts | Execucao rodada-a-rodada e settlement |
src/application/use-cases/battle/add-bots-to-battle.use-case.ts | Adicao de bots |
src/application/use-cases/battle/get-active-battles.use-case.ts | Listagem de batalhas ativas |
src/application/use-cases/battle/get-battle-by-id.use-case.ts | Busca de batalha por ID |
src/application/use-cases/battle/get-battle-list.use-case.ts | Listagem paginada de batalhas |
src/presentation/controllers/battle.controller.ts | Controller HTTP |
src/application/dto/battle.dto.ts | DTOs de validacao e resposta |
src/application/services/battle-state.service.ts | Estado da batalha em Redis |
src/application/services/flip.service.ts | Logica FLIP (segunda roleta) |
src/application/services/provably-fair.service.ts | Calculo de rolls |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de eventos WebSocket |
src/domain/repositories/battle.repository.interface.ts | Interface do repositorio |
src/domain/repositories/battle-settlement.repository.interface.ts | Interface do settlement |
src/infrastructure/database/repositories/battle.repository.ts | Implementacao do repositorio |
Tipos e Modos
BattleType - Tamanho da Batalha
| Enum | Max Jogadores | Jogadores por Time | Times |
|---|---|---|---|
ONE_VS_ONE | 2 | 1 | 2 |
TWO_VS_TWO | 4 | 2 | 2 |
THREE_VS_THREE | 6 | 3 | 2 |
FOUR_VS_FOUR | 8 | 4 | 2 |
ONE_VS_ONE_VS_ONE | 3 | 1 | 3 |
ONE_VS_ONE_VS_ONE_VS_ONE | 4 | 1 | 4 |
Funcao de mapeamento utilizada em todos os use cases:
private getMaxPlayers(type: string): number {
switch (type) {
case 'ONE_VS_ONE':
return 2;
case 'TWO_VS_TWO':
return 4;
case 'THREE_VS_THREE':
return 6;
case 'FOUR_VS_FOUR':
return 8;
case 'ONE_VS_ONE_VS_ONE':
return 3;
case 'ONE_VS_ONE_VS_ONE_VS_ONE':
return 4;
default:
return 2;
}
}O numero de times de cada tipo vem de getNumTeams(battle.type): 2 para os modos padrao (ONE_VS_ONE, TWO_VS_TWO, THREE_VS_THREE, FOUR_VS_FOUR), 3 para ONE_VS_ONE_VS_ONE e 4 para ONE_VS_ONE_VS_ONE_VS_ONE. Nos modos de 2 times, cada time tem maxPlayers / 2 jogadores.
private getNumTeams(type: string): number {
switch (type) {
case 'ONE_VS_ONE_VS_ONE':
return 3;
case 'ONE_VS_ONE_VS_ONE_VS_ONE':
return 4;
default:
return 2;
}
}BattleMode - Modo de Jogo
| Enum | Descricao | Logica de Vencedor |
|---|---|---|
NORMAL | Maior valor total vence | Time com MAIOR total (times ordenados desc) |
FLIP | Menor valor total vence (invertido) | Time com MENOR total (times ordenados asc) |
SHARED | Todos vencem | winnerTeam = 0 (todos sao vencedores) |
Codigo real de determinacao do vencedor em execute-battle.use-case.ts (generalizado para N times via teamTotals):
const isShared = battle.mode === 'SHARED';
let winnerTeam: number;
let isTie = false;
if (isShared) {
winnerTeam = 0;
} else {
const entries = Array.from(teamTotals.entries());
if (battle.mode === 'FLIP') {
entries.sort((a, b) => Number(a[1] - b[1]));
} else {
entries.sort((a, b) => Number(b[1] - a[1]));
}
const topValue = entries[0][1];
const tied = entries.filter(([, v]) => v === topValue);
if (tied.length > 1) {
winnerTeam = 0;
isTie = true;
} else {
winnerTeam = entries[0][0];
}
}
const isSplitAll = isShared || isTie;Em caso de empate no topo (dois ou mais times com o mesmo total), winnerTeam = 0 e a distribuicao e dividida entre TODOS os jogadores (isSplitAll), sem aleatoriedade.
BattleStatus - Transicoes de Estado
WAITING -> IN_PROGRESS -> FINISHED
WAITING -> CANCELLED| Status | Descricao | Quando Muda |
|---|---|---|
WAITING | Aguardando jogadores | Criacao da batalha |
IN_PROGRESS | Batalha em execucao | Quando todos os slots sao preenchidos |
FINISHED | Batalha finalizada | Apos settlement |
CANCELLED | Batalha cancelada | Timeout ou cancelamento manual |
Modelo de Dados (Prisma)
Battle
model Battle {
id BigInt @id
mode BattleMode
type BattleType
status BattleStatus @default(WAITING)
creatorId BigInt @map("creator_id")
winnerId BigInt? @map("winner_id")
winnerTeam Int? @map("winner_team")
totalValueCents BigInt @map("total_value_cents")
isPrivate Boolean @default(false) @map("is_private")
joinCode String? @unique @map("join_code")
createdAt DateTime @default(now()) @map("created_at")
startedAt DateTime? @map("started_at")
finishedAt DateTime? @map("finished_at")
creator User @relation("BattleCreator", fields: [creatorId], references: [id])
winner User? @relation("BattleWinner", fields: [winnerId], references: [id])
players BattlePlayer[]
cases BattleCase[]
rounds BattleRound[]
settlements BattleSettlement[]
@@map("battles")
@@index([status, createdAt])
@@index([creatorId])
@@index([isPrivate])
}totalValueCents: Custo de entrada em centavos de BRL. Calculado como a soma dos precos de todas as caixas selecionadas.winnerId: ID do primeiro jogador do time vencedor. Setado no settlement.winnerTeam: Numero do time vencedor (1 ou 2). No modo SHARED, e 0.joinCode: Codigo de 8 caracteres hex (uppercase) gerado comrandomBytes(4).toString('hex').toUpperCase(). Apenas para batalhas privadas.
BattlePlayer
model BattlePlayer {
id BigInt @id
battleId BigInt @map("battle_id")
userId BigInt @map("user_id")
team Int
totalProfitCents BigInt @default(0) @map("total_profit_cents")
position Int?
joinedAt DateTime @default(now()) @map("joined_at")
battle Battle @relation(fields: [battleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
rounds BattleRound[]
@@unique([battleId, userId])
@@map("battle_players")
@@index([battleId])
@@index([userId])
}team: Sempre 1 ou 2.totalProfitCents: Calculado no settlement comofinalValue - battle.totalValueCentspara vencedores,-battle.totalValueCentspara perdedores.- A constraint
@@unique([battleId, userId])impede que o mesmo jogador entre duas vezes na mesma batalha.
BattleCase
model BattleCase {
id BigInt @id
battleId BigInt @map("battle_id")
caseId BigInt @map("case_id")
order Int
battle Battle @relation(fields: [battleId], references: [id], onDelete: Cascade)
case Case @relation(fields: [caseId], references: [id])
@@map("battle_cases")
@@index([battleId, order])
}order: Posicao da caixa na sequencia de rodadas (0-indexed). Uma batalha pode ter de 1 a 10 caixas.- Cada caixa corresponde a uma rodada.
battle.cases.length === totalRounds.
BattleRound
model BattleRound {
id BigInt @id
battleId BigInt @map("battle_id")
roundNumber Int @map("round_number")
playerId BigInt @map("player_id")
caseId BigInt @map("case_id")
itemId BigInt @map("item_id")
itemValueCents BigInt @map("item_value_cents")
itemName String @map("item_name")
itemImage String @map("item_image")
itemRarity String @map("item_rarity")
serverSeed String @map("server_seed")
serverSeedHash String @map("server_seed_hash")
clientSeed String @map("client_seed")
nonce Int
roll Int
isFlip Boolean @default(false) @map("is_flip")
flipServerSeed String? @map("flip_server_seed")
flipClientSeed String? @map("flip_client_seed")
flipNonce Int? @map("flip_nonce")
flipRoll Int? @map("flip_roll")
createdAt DateTime @default(now()) @map("created_at")
battle Battle @relation(fields: [battleId], references: [id], onDelete: Cascade)
player BattlePlayer @relation(fields: [playerId], references: [id])
case Case @relation(fields: [caseId], references: [id])
item Item @relation(fields: [itemId], references: [id])
@@map("battle_rounds")
@@index([battleId, roundNumber])
@@index([playerId])
}- Cada jogador tem um registro por rodada. Uma batalha 3v3 com 5 caixas gera 6 * 5 = 30 registros de BattleRound.
itemValueCents: Armazenaitem.valueCentsBrl(BRL, nao USD).playerId: Referencia aBattlePlayer.id, nao aoUser.iddiretamente.- Seeds sao gerados POR JOGADOR POR RODADA (cada jogador tem seus proprios seeds em cada rodada).
- Campos
flip*sao preenchidos quandoisFlip = true. OflipServerSeedeserverSeed + '_flip', oflipClientSeede o mesmoclientSeed, e oflipNonceenonce + 1.
BattleSettlement
enum BattleSettlementType {
DROPPED
REMOVED_TO_POOL
ADDED_FROM_POOL
}
model BattleSettlement {
id BigInt @id
battleId BigInt @map("battle_id")
odUserId BigInt @map("user_id")
itemId BigInt @map("item_id")
type BattleSettlementType
valueCents BigInt @map("value_cents")
roundNumber Int? @map("round_number")
createdAt DateTime @default(now()) @map("created_at")
battle Battle @relation(fields: [battleId], references: [id], onDelete: Cascade)
user User @relation(fields: [odUserId], references: [id])
item Item @relation(fields: [itemId], references: [id])
@@map("battle_settlements")
@@index([battleId])
@@index([odUserId])
@@index([type])
}Tipos de settlement:
| Tipo | Descricao |
|---|---|
DROPPED | Item dropado durante a batalha, distribuido para o vencedor |
REMOVED_TO_POOL | Item removido por ter valor maior que o fairShare; vai para a casa |
ADDED_FROM_POOL | Item adicionado da tabela Item para compensar deficit |
Fluxo de Criacao
Arquivo: src/application/use-cases/battle/create-battle.use-case.ts
Parametros de Entrada
async execute(
userId: bigint, // ID do criador
mode: BattleMode, // NORMAL | FLIP | SHARED
type: BattleType, // ONE_VS_ONE | TWO_VS_TWO | THREE_VS_THREE | FOUR_VS_FOUR
caseIds: bigint[], // Array de IDs de caixas (1 a 10)
isPrivate: boolean, // Se verdadeiro, gera joinCode
)Validacoes
caseIds.lengthdeve estar entre 1 e 10:
if (caseIds.length === 0 || caseIds.length > 10) {
throw new BadRequestException('Must select between 1 and 10 cases');
}- Todas as caixas devem existir e estar ativas:
for (const c of cases) {
if (!c) {
throw new BadRequestException('Case not found');
}
if (c.status !== 'ACTIVE') {
throw new BadRequestException(`Case ${c.name} is not active`);
}
}- Saldo do criador deve cobrir o custo total (soma dos precos de todas as caixas):
const totalValueCents = cases.reduce((sum, c) => sum + c!.priceCents, BigInt(0));
const realBalance = await this.transactionService.getUserBalance(userId);
if (realBalance < totalValueCents) {
throw new BadRequestException('Insufficient balance');
}Fluxo Completo
- Valida caixas (existencia e status ACTIVE).
- Calcula
totalValueCentscomo soma depriceCentsde todas as caixas. - Adquire lock de saldo do usuario com
lockService.withUserBalanceLock(userId, ...). - Dentro de transacao Prisma: a. Verifica saldo real via
transactionService.getUserBalance(). b. Debita saldo do criador viatransactionService.debit(). c. GerajoinCodeseisPrivate === truecomrandomBytes(4).toString('hex').toUpperCase(). d. Cria registroBattleno banco. e. Adiciona criador comoBattlePlayerno time 1. f. Adiciona todas as caixas comoBattleCasecomordersequencial (0-indexed). g. Emitebalance:updatedvia WebSocket para o criador. h. Emitebattle:createdvia WebSocket (broadcast global). - Retorna a batalha criada.
Retorno
return {
battleId: battle.id.toString(),
joinCode: battle.joinCode || undefined,
};Fluxo de Entrada (Join)
Arquivo: src/application/use-cases/battle/join-battle.use-case.ts
Parametros de Entrada
async execute(
userId: bigint,
battleId: bigint,
team: number, // 1 ou 2
joinCode?: string, // Obrigatorio para batalhas privadas
)Validacoes
- Batalha deve existir:
if (!battle) {
throw new NotFoundException('Battle not found');
}- Status deve ser WAITING:
if (battle.status !== 'WAITING') {
throw new BadRequestException('Battle is not waiting for players');
}- Codigo de entrada para batalhas privadas:
if (battle.isPrivate && battle.joinCode !== joinCode) {
throw new BadRequestException('Invalid join code');
}- Jogador nao pode estar na batalha:
const alreadyJoined = battle.players.some((p) => p.userId === userId);
if (alreadyJoined) {
throw new BadRequestException('Already joined this battle');
}- Batalha nao pode estar cheia:
const maxPlayers = this.getMaxPlayers(battle.type);
if (battle.players.length >= maxPlayers) {
throw new BadRequestException('Battle is full');
}- Time especifico nao pode estar cheio:
const playersPerTeam = maxPlayers / 2;
const teamPlayers = battle.players.filter((p) => p.team === team).length;
if (teamPlayers >= playersPerTeam) {
throw new BadRequestException('Team is full');
}- Saldo suficiente para entrada (custo e
battle.totalValueCents):
const realBalance = await this.transactionService.getUserBalance(userId);
if (realBalance < battle.totalValueCents) {
throw new BadRequestException('Insufficient balance');
}Fluxo Completo
- Busca batalha pelo ID.
- Executa todas as validacoes listadas acima.
- Adquire lock de saldo do usuario.
- Dentro de transacao Prisma: a. Verifica saldo real novamente (dentro do lock). b. Debita saldo via
transactionService.debit()com descricaoBattle entry fee - Battle #{battleId}. c. Emitebalance:updatedpara o jogador. d. Adiciona jogador comoBattlePlayer. e. Emitebattle:player_joinedpara a sala da batalha. f. Emitebattle:updated(broadcast global). g. Se a batalha ficou cheia (players.length === maxPlayers):- Atualiza status para
IN_PROGRESS. - Emite
battle:updatedcom statusIN_PROGRESS. - Inicia execucao da batalha com
setImmediate():
- Atualiza status para
setImmediate(() => {
this.executeBattleUseCase.execute(battleId).catch((err) => {
console.error(`[Battle] Failed to execute battle ${battleId}:`, err);
});
});O uso de setImmediate() permite que a resposta HTTP retorne imediatamente ao cliente. A execucao da batalha acontece de forma assincrona.
Sistema de Bots
Arquivo: src/application/use-cases/battle/add-bots-to-battle.use-case.ts
Nomes e Avatars dos Bots
const BOT_NAMES = [
'Battle Bot #1',
'Battle Bot #2',
'Battle Bot #3',
'Battle Bot #4',
'Battle Bot #5',
'Battle Bot #6',
'Battle Bot #7',
'Battle Bot #8',
'Battle Bot #9',
'Battle Bot #10',
];
const BOT_AVATARS = [
'/avatar/1.png',
'/avatar/2.png',
'/avatar/3.png',
'/avatar/4.png',
'/avatar/5.png',
];Identificacao de Bots
Bots sao identificados pelo prefixo BOT_ no campo steamId:
const botSteamId = `BOT_${botName.replace(/\s+/g, '_').toUpperCase()}`;Exemplos: BOT_BATTLE_BOT_#1, BOT_BATTLE_BOT_#2, etc.
A verificacao se um jogador e bot e feita com:
const isBot = player.user.steamId.startsWith('BOT_');Validacoes
- Batalha deve existir e estar com status
WAITING. - Apenas o criador da batalha pode adicionar bots:
if (battle.creatorId !== requestingUserId) {
throw new BadRequestException('Only the battle creator can add bots');
}- A batalha nao pode estar cheia.
Fluxo de Adicao
- Calcula
botsNeeded = maxPlayers - currentPlayers. - Para cada bot necessario: a. Busca ou cria usuario bot no banco (com steamId
BOT_*). b. Determina time: preenche time 1 primeiro, depois time 2. c. Adiciona comoBattlePlayer. d. Emitebattle:player_joined. - Atualiza status para
IN_PROGRESS. - Emite
battle:updated. - Inicia execucao com
setImmediate().
Comportamento de Bots na Execucao
- Bots participam normalmente de todas as rodadas (recebem rolls e itens).
- Bots NAO recebem live drops:
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({ ... });
}, liveDropDelay);
}- Bots vencedores NAO recebem itens no inventario:
if (!isBot) {
for (const item of itemsToAdd) {
await this.userInventoryRepository.create({ ... });
}
}Fluxo de Execucao
Arquivo: src/application/use-cases/battle/execute-battle.use-case.ts
Visao Geral
A execucao e dividida em:
- Inicializacao do estado em Redis.
- Delay de countdown (3 segundos).
- Loop de rodadas (1 rodada por caixa).
- Settlement (distribuicao de itens).
- Finalizacao e broadcast de resultado.
Fase 1: Inicializacao
const playerData: Map<bigint, PlayerRoundData> = new Map();
for (const player of battle.players) {
playerData.set(player.userId, {
odUserId: player.userId,
odUsername: player.user.username,
odAvatarUrl: player.user.avatarUrl,
team: player.team,
totalValueCents: BigInt(0),
});
}
await this.battleStateService.initializeBattleState(
battleId.toString(),
battle.cases.length,
battle.players.map((p) => p.userId.toString()),
);
this.webSocketGateway.emitBattleStarting(battleId, new Date());
await this.delay(3000);Fase 2: Loop de Rodadas
Cada rodada corresponde a uma caixa na posicao roundIndex da lista battle.cases:
for (let roundIndex = 0; roundIndex < battle.cases.length; roundIndex++) {
const battleCase = battle.cases[roundIndex];
const roundNumber = roundIndex + 1;
// ...
}Fase 2.1: Inicio da Rodada
this.webSocketGateway.emitBattleRoundStart(
battleId,
roundNumber,
battleCase.caseId.toString(),
battleCase.case.name,
);
await this.battleStateService.startRound(
battleId.toString(),
roundNumber,
battleCase.caseId.toString(),
battleCase.case.name,
);Carrega itens da caixa e dados de FLIP:
const caseItems = await this.caseRepository.getCaseItems(battleCase.caseId);
const flipThreshold = await this.flipService.getFlipThreshold(battleCase.caseId);
const flipItemsRaw = await this.flipService.getFlipItems(battleCase.caseId);
const allItemsData = this.prepareItemsForRoulette(caseItems);
const flipItemsData = this.prepareItemsForRoulette(flipItemsRaw);Fase 2.2: Geracao de Roll por Jogador
Para CADA jogador em CADA rodada, sao gerados seeds INDEPENDENTES:
for (const player of battle.players) {
const serverSeed = this.provablyFairService.generateServerSeed();
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
const clientSeed = this.provablyFairService.generateClientSeed();
const nonce = await this.nonceService.getNextNonce(player.userId);
const roll = this.provablyFairService.calculateRoll(serverSeed, clientSeed, nonce);
// ...
}O nonce vem do NonceService, um contador monotonico por usuario em Redis (INCR nonce:{userId}), compartilhado entre todos os jogos que usam esse servico (abertura de caixa, batalha e upgrade):
async getNextNonce(userId: bigint): Promise<number> {
return this.redis.incr(`nonce:${userId}`);
}O roll e calculado com range padrao de 1 a 10.000.000:
calculateRoll(
serverSeed: string,
clientSeed: string,
nonce: number,
max: number = 10000000,
): number {
const hmac = createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
const hashPrefix = hash.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
let result = decimal;
if (result >= maxSafeValue) {
const hmac2 = createHmac('sha256', serverSeed);
hmac2.update(`${clientSeed}:${nonce}:retry`);
const hash2 = hmac2.digest('hex');
result = parseInt(hash2.substring(0, 13), 16);
}
const roll = (result % max) + 1;
return roll;
}Fase 2.3: Deteccao de FLIP e Selecao de Item
const isFlip = this.flipService.shouldTriggerFlip(roll, flipThreshold);O flipThreshold e lido diretamente da coluna flipThreshold da caixa (nullable; null significa que a caixa nao tem FLIP). Os itens de FLIP sao os que possuem hashRangeStart >= flipThreshold:
async getFlipThreshold(caseId: bigint): Promise<number | null> {
const caseData = await this.caseRepository.findById(caseId);
return caseData?.flipThreshold ?? null;
}
shouldTriggerFlip(roll: number, flipThreshold: number | null): boolean {
if (flipThreshold === null) {
return false;
}
return roll >= flipThreshold;
}
async getFlipItems(caseId: bigint, flipThreshold: number | null = null) {
const threshold = flipThreshold ?? await this.getFlipThreshold(caseId);
if (threshold === null) return [];
const caseItems = await this.caseRepository.getCaseItems(caseId);
return caseItems.filter((ci) => ci.hashRangeStart >= threshold);
}Quando isFlip e verdadeiro, o item e primeiro buscado entre os itens de FLIP via selectFlipWinner; havendo match, actualIsFlip = true. Caso contrario, cai no getItemByRoll sobre todos os itens da caixa. O actualIsFlip (nao o isFlip bruto) e o que determina a animacao e os campos de FLIP persistidos:
let wonItem;
let actualIsFlip = false;
if (isFlip && flipItemsRaw.length > 0) {
const flipWinner = this.flipService.selectFlipWinner(flipItemsRaw, roll);
if (flipWinner) {
wonItem = flipWinner;
actualIsFlip = true;
}
}
if (!wonItem) {
wonItem = this.flipService.getItemByRoll(caseItems, roll);
}getItemByRoll seleciona o item cujo range cobre o roll:
getItemByRoll(items: any[], roll: number) {
const matchedItem = items.find(
(item) => roll >= item.hashRangeStart && roll <= item.hashRangeEnd,
);
if (!matchedItem) {
throw new Error(`No item found for roll ${roll}`);
}
return matchedItem;
}Fase 2.4: Persistencia da Rodada
Cada resultado e salvo no banco com todos os dados de Provably Fair:
await this.battleRepository.addRound({
battleId,
roundNumber,
playerId: player.id,
caseId: battleCase.caseId,
itemId: wonItem.item.id,
itemValueCents: wonItem.item.valueCentsBrl,
itemName: wonItem.item.name,
itemImage: wonItem.item.imageUrl,
itemRarity: wonItem.item.rarity,
serverSeed,
serverSeedHash,
clientSeed,
nonce,
roll,
isFlip: actualIsFlip,
flipServerSeed: actualIsFlip ? serverSeed + '_flip' : undefined,
flipClientSeed: actualIsFlip ? clientSeed : undefined,
flipNonce: actualIsFlip ? nonce + 1 : undefined,
flipRoll: actualIsFlip ? roll : undefined,
});O valor acumulado do jogador e atualizado em memoria:
const pd = playerData.get(player.userId)!;
pd.totalValueCents += wonItem.item.valueCentsBrl;Fase 2.5: Live Drop (apenas jogadores reais)
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
const liveDropDelay = actualIsFlip ? 30000 : 12000;
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: player.user.username,
userAvatar: player.user.avatarUrl || undefined,
itemName: wonItem.item.name,
itemImage: wonItem.item.imageUrl,
itemValueCents: wonItem.item.valueCentsBrl,
rarity: wonItem.item.rarity,
source: 'battle',
timestamp: new Date(),
dropChance: wonItem.dropChance,
});
}, liveDropDelay);
}Delays de live drop:
- Rodada normal: 12.000ms
- Rodada com FLIP: 30.000ms
Fase 2.6: Emissao de Resultados da Rodada
this.webSocketGateway.emitBattleRoundResult(battleId, roundNumber, roundResults);Cada roundResults[i] contem:
{
odPlayerId: player.userId.toString(),
odUsername: player.user.username,
itemId: wonItem.item.id.toString(),
itemName: wonItem.item.name,
itemImage: wonItem.item.imageUrl,
itemRarity: wonItem.item.rarity,
itemValueCents: wonItem.item.valueCentsBrl.toString(),
roll,
isFlip: actualIsFlip,
flipRoll: actualIsFlip ? roll : undefined,
flipItems: actualIsFlip ? flipItemsData : undefined,
normalItems: allItemsData,
}Os normalItems e flipItems sao preparados com a funcao:
private prepareItemsForRoulette(items: any[]): RoundResultItem[] {
if (!items || items.length === 0) {
return [];
}
const sortedItems = [...items].sort(
(a, b) => Number(b.item.valueCentsBrl) - Number(a.item.valueCentsBrl),
);
return sortedItems.map((item) => ({
id: item.id.toString(),
itemId: item.item.id.toString(),
itemName: item.item.name,
itemImage: item.item.imageUrl,
rarity: item.item.rarity,
valueCents: item.item.valueCentsBrl.toString(),
valueFormatted: (Number(item.item.valueCentsBrl) / 100).toFixed(2),
dropChance: item.dropChance,
weight: item.weight,
weaponCategory: item.item.weaponCategory,
exterior: item.item.exterior ?? undefined,
isStatTrak: item.item.isStatTrak,
}));
}Fase 2.7: Calculo do Delay entre Rodadas
const hasFlipInRound = roundResults.some((r) => r.isFlip);
const spinDurationMs = 6000;
const flipSpinDurationMs = 6000;
const postSpinNormalMs = 1050;
const postSpinFlipMs = 8550;
const bufferMs = 1500;
const roundDelay = hasFlipInRound
? spinDurationMs + postSpinFlipMs + flipSpinDurationMs + bufferMs
: spinDurationMs + postSpinNormalMs + bufferMs;| Cenario | Calculo | Duracao Total |
|---|---|---|
| Rodada SEM flip | 6000 + 1050 + 1500 | 8.550ms |
| Rodada COM flip | 6000 + 8550 + 6000 + 1500 | 22.050ms |
O delay e consumido em intervalos de 2 segundos, emitindo battle:state_push a cada intervalo para manter os clientes sincronizados:
const pushIntervalMs = 2000;
const endTime = Date.now() + roundDelay;
while (Date.now() < endTime) {
const waitMs = Math.min(pushIntervalMs, endTime - Date.now());
if (waitMs <= 0) break;
await this.delay(waitMs);
if (Date.now() < endTime) {
await this.webSocketGateway.emitBattleStatePush(battleId);
}
}Apos o delay, a rodada e marcada como completa no Redis:
await this.battleStateService.completeRound(battleId.toString(), roundNumber);Settlement (Distribuicao Justa)
Metodo: settleBattle() em execute-battle.use-case.ts
O settlement distribui TODOS os itens dropados durante a batalha igualmente entre os vencedores.
Passo 1: Calculo dos Totais por Time
const numTeams = this.getNumTeams(battle.type);
const teamTotals = new Map<number, bigint>();
for (let t = 1; t <= numTeams; t++) {
teamTotals.set(t, BigInt(0));
}
for (const [, data] of playerData) {
teamTotals.set(data.team, (teamTotals.get(data.team) || BigInt(0)) + data.totalValueCents);
}Passo 2: Determinacao do Time Vencedor
Ja documentado na secao de modos. Em caso de empate no topo (dois ou mais times com o mesmo total), o vencedor nao e aleatorio: winnerTeam = 0, isTie = true, e a distribuicao e dividida entre TODOS os jogadores:
const topValue = entries[0][1];
const tied = entries.filter(([, v]) => v === topValue);
if (tied.length > 1) {
winnerTeam = 0;
isTie = true;
} else {
winnerTeam = entries[0][0];
}
const isSplitAll = isShared || isTie;Passo 3: Calculo do Fair Share
const winnerPlayers = isSplitAll
? battle.players
: battle.players.filter((p) => p.team === winnerTeam);
const winnerUserIds = winnerPlayers.map((p) => p.userId);
const numWinners = winnerUserIds.length;
const totalItemsValue = allRounds.reduce((sum, r) => sum + r.item.valueCentsBrl, BigInt(0));
const fairSharePerWinner = totalItemsValue / BigInt(numWinners);Exemplo: 6 jogadores (3v3), 5 caixas, total dropado R$900,00. Time 1 vence.
numWinners = 3fairSharePerWinner = 90000 / 3 = 30000(R$300,00)
Passo 4: Separar Itens Maiores que o Fair Share
Itens com valor superior ao fairShare sao removidos para o pool da casa ANTES da distribuicao:
const distributableItems: typeof droppedItems = [];
for (const item of droppedItems) {
if (item.valueCents > fairSharePerWinner) {
itemsRemovedToPool.push({
itemId: item.itemId,
itemName: item.itemName,
itemImage: item.itemImage,
itemRarity: item.itemRarity,
valueCents: item.valueCents,
type: BattleSettlementType.REMOVED_TO_POOL,
roundNumber: item.roundNumber,
removedFromUserId: winnerUserIds[0],
});
} else {
distributableItems.push(item);
}
}Os itens distribuiveis sao ordenados por valor decrescente antes da distribuicao:
droppedItems.sort((a, b) => Number(b.valueCents) - Number(a.valueCents));Passo 5: Distribuicao de Itens (Greedy para Menor Valor)
Cada item e atribuido ao vencedor com o MENOR valor acumulado:
for (const item of distributableItems) {
let minValueWinner = winnerUserIds[0];
let minValue = playerSettlements.get(minValueWinner)!.totalValue;
for (const odUserId of winnerUserIds) {
const currentValue = playerSettlements.get(odUserId)!.totalValue;
if (currentValue < minValue) {
minValue = currentValue;
minValueWinner = odUserId;
}
}
const settlement = playerSettlements.get(minValueWinner)!;
settlement.items.push({
itemId: item.itemId,
itemName: item.itemName,
itemImage: item.itemImage,
itemRarity: item.itemRarity,
valueCents: item.valueCents,
type: BattleSettlementType.DROPPED,
roundNumber: item.roundNumber,
});
settlement.totalValue += item.valueCents;
}Passo 6: Balanceamento - Remocao de Excesso
Apos a distribuicao inicial, se algum vencedor ficou com valor acima do fairShare, itens sao removidos:
for (const [odUserId, settlement] of playerSettlements) {
while (settlement.totalValue > fairSharePerWinner) {
const excess = settlement.totalValue - fairSharePerWinner;
const playerDroppedItems = settlement.items
.filter((i) => i.type === BattleSettlementType.DROPPED)
.sort((a, b) => Number(a.valueCents) - Number(b.valueCents));
if (playerDroppedItems.length === 0) break;
const itemsUnderExcess = playerDroppedItems.filter((i) => i.valueCents <= excess);
if (itemsUnderExcess.length === 0) break;
const itemToRemove = itemsUnderExcess[itemsUnderExcess.length - 1];
const itemIndex = settlement.items.findIndex(
(i) => i.itemId === itemToRemove.itemId && i.type === BattleSettlementType.DROPPED,
);
if (itemIndex !== -1) {
settlement.items.splice(itemIndex, 1);
settlement.totalValue -= itemToRemove.valueCents;
itemsRemovedToPool.push({
...itemToRemove,
type: BattleSettlementType.REMOVED_TO_POOL,
removedFromUserId: odUserId,
});
}
}
}A logica remove o MAIOR item que ainda esteja dentro do excesso (itemsUnderExcess[length - 1]), repetindo ate que o jogador esteja abaixo do fairShare ou nao haja mais itens removiveis.
Passo 7: Compensacao do Pool
Jogadores com valor abaixo do fairShare recebem itens de compensacao da tabela Item:
for (const [odUserId, settlement] of playerSettlements) {
const deficit = fairSharePerWinner - settlement.totalValue;
if (deficit > BigInt(0)) {
const compensationItems = await this.findCompensationItems(deficit);
for (const compItem of compensationItems) {
settlement.items.push({
itemId: compItem.id,
itemName: compItem.name,
itemImage: compItem.imageUrl,
itemRarity: compItem.rarity,
valueCents: compItem.valueCentsBrl,
type: BattleSettlementType.ADDED_FROM_POOL,
});
settlement.totalValue += compItem.valueCentsBrl;
}
}
}Algoritmo de Busca de Itens de Compensacao
private async findCompensationItems(targetValue: bigint): Promise<any[]> {
const items: any[] = [];
let remainingValue = targetValue;
const allItems = await this.itemRepository.findAll({});
const eligibleItems = allItems
.filter((item) => item.valueCentsBrl > BigInt(0) && item.valueCentsBrl <= targetValue + BigInt(1000))
.sort((a, b) => Number(b.valueCentsBrl) - Number(a.valueCentsBrl));
if (eligibleItems.length === 0) {
return [];
}
while (remainingValue > BigInt(0) && items.length < 200) {
const bestItem = eligibleItems.find((item) => item.valueCentsBrl <= remainingValue);
if (bestItem && bestItem.valueCentsBrl > BigInt(0)) {
items.push(bestItem);
remainingValue -= bestItem.valueCentsBrl;
continue;
}
break;
}
return items;
}Detalhes:
- Apenas itens com
valueCentsBrl > 0sao elegiveis; tolerancia de R$10,00 (BigInt(1000)centavos) acima do deficit na filtragem. - Itens elegiveis sao ordenados por valor decrescente (maior primeiro).
- Algoritmo greedy: sempre escolhe o maior item que caiba no valor restante.
- Para quando nao encontra mais itens que caibam ou ao atingir 200 itens.
Passo 8: Persistencia dos Settlements
Todos os registros sao salvos de uma vez:
const settlementRecords = [];
// Itens distribuidos para vencedores
for (const [odUserId, settlement] of playerSettlements) {
for (const item of settlement.items) {
settlementRecords.push({
battleId: battle.id,
odUserId,
itemId: item.itemId,
type: item.type,
valueCents: item.valueCents,
roundNumber: item.roundNumber,
});
}
}
// Itens removidos para o pool
for (const removed of itemsRemovedToPool) {
settlementRecords.push({
battleId: battle.id,
odUserId: removed.removedFromUserId,
itemId: removed.itemId,
type: BattleSettlementType.REMOVED_TO_POOL,
valueCents: removed.valueCents,
roundNumber: removed.roundNumber,
});
}
await this.battleSettlementRepository.createMany(settlementRecords);Passo 9: Adicao ao Inventario
Apenas jogadores reais (nao bots) recebem itens no inventario:
for (const [odUserId, settlement] of playerSettlements) {
const player = winnerPlayers.find((p) => p.userId === odUserId)!;
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
const itemsToAdd = settlement.items.filter(
(i) =>
i.type === BattleSettlementType.DROPPED ||
i.type === BattleSettlementType.ADDED_FROM_POOL,
);
for (const item of itemsToAdd) {
await this.userInventoryRepository.create({
userId: odUserId,
itemId: item.itemId,
status: 'AVAILABLE',
acquiredFrom: 'Batalha',
});
}
}
}Passo 10: Atualizacao de Lucro dos Jogadores
for (const [userId, data] of playerData) {
const isWinner = isSplitAll || data.team === winnerTeam;
if (isWinner) {
const settlement = playerSettlements.get(userId);
const finalValue = settlement ? settlement.totalValue : BigInt(0);
await this.battleRepository.updatePlayerProfit(
battle.players.find((p) => p.userId === userId)!.id,
finalValue - battle.totalValueCents,
);
} else {
await this.battleRepository.updatePlayerProfit(
battle.players.find((p) => p.userId === userId)!.id,
-battle.totalValueCents,
);
}
}Passo 11: Finalizacao
const winnerId = winners[0]?.odUserId;
if (winnerId) {
await this.battleRepository.updateWinner(battle.id, winnerId, isSplitAll ? 0 : winnerTeam);
}
await this.battleRepository.updateStatus(battle.id, 'FINISHED');
await this.battleStateService.finishBattle(battle.id.toString());O updateStatus com FINISHED tambem seta finishedAt:
async updateStatus(id: bigint, status: BattleStatus) {
return this.prisma.battle.update({
where: { id },
data: {
status,
...(status === 'IN_PROGRESS' && { startedAt: new Date() }),
...(status === 'FINISHED' && { finishedAt: new Date() }),
},
});
}Endpoints da API
Arquivo: src/presentation/controllers/battle.controller.ts
Todos os endpoints estao sob o prefixo /battles e protegidos por AuthGuard (exceto os marcados com @Public()).
POST /battles/create
Cria uma nova batalha.
Autenticacao: Obrigatoria
Body (CreateBattleDto):
{
mode: 'NORMAL' | 'FLIP' | 'SHARED', // obrigatorio
type: 'ONE_VS_ONE' | 'TWO_VS_TWO' | 'THREE_VS_THREE' | 'FOUR_VS_FOUR', // obrigatorio
caseIds: string[], // obrigatorio, min 1, max 10
isPrivate?: boolean // opcional, default false
}Response (200):
{
"battleId": "1234567890",
"joinCode": "A1B2C3D4"
}O joinCode so esta presente se isPrivate === true.
Erros:
| Status | Mensagem | Condicao |
|---|---|---|
| 400 | Must select between 1 and 10 cases | caseIds.length === 0 ou > 10 |
| 400 | Case not found | Caixa nao existe |
| 400 | Case {name} is not active | Caixa inativa |
| 400 | Insufficient balance | Saldo insuficiente |
POST /battles/:id/join
Entra em uma batalha existente.
Autenticacao: Obrigatoria
Params:
id: ID da batalha (string numerico)
Body (JoinBattleDto):
{
team: number, // obrigatorio, 1 ou 2
joinCode?: string // opcional, obrigatorio para batalhas privadas
}Response (200):
{
"success": true
}Erros:
| Status | Mensagem | Condicao |
|---|---|---|
| 404 | Battle not found | ID inexistente |
| 400 | Battle is not waiting for players | Status nao e WAITING |
| 400 | Invalid join code | Codigo incorreto para batalha privada |
| 400 | Already joined this battle | Jogador ja esta na batalha |
| 400 | Battle is full | Todos os slots preenchidos |
| 400 | Team is full | Time especifico esta cheio |
| 400 | Insufficient balance | Saldo insuficiente |
GET /battles/active
Lista batalhas ativas (WAITING e IN_PROGRESS por padrao).
Autenticacao: Obrigatoria
Query (GetBattlesQueryDto):
{
status?: 'WAITING' | 'IN_PROGRESS' | 'FINISHED' | 'CANCELLED',
mode?: 'NORMAL' | 'FLIP' | 'SHARED',
limit?: number, // default 50
offset?: number // default 0
}Response (200): Array de BattleResponseDto.
GET /battles/list
Lista paginada de todas as batalhas.
Autenticacao: Publica (@Public())
Query (GetBattlesQueryDto):
{
status?: 'WAITING' | 'IN_PROGRESS' | 'FINISHED' | 'CANCELLED',
mode?: 'NORMAL' | 'FLIP' | 'SHARED',
limit?: number, // default 10
offset?: number // default 0
}Response (200):
{
"battles": [ ... ],
"total": 42,
"hasMore": true
}GET /battles/:id
Busca batalha pelo ID com todos os detalhes.
Autenticacao: Obrigatoria
Params:
id: ID da batalha (string numerico)
Response (200): BattleResponseDto completo.
Se a batalha tem status FINISHED, a resposta inclui rounds, winners e settlementData.
Erros:
| Status | Mensagem | Condicao |
|---|---|---|
| 404 | Battle not found | ID inexistente |
POST /battles/:id/add-bots
Preenche a batalha com bots e inicia a execucao.
Autenticacao: Obrigatoria (apenas o criador)
Params:
id: ID da batalha (string numerico)
Response (200):
{
"success": true
}Erros:
| Status | Mensagem | Condicao |
|---|---|---|
| 404 | Battle not found | ID inexistente |
| 400 | Battle is not waiting for players | Status nao e WAITING |
| 400 | Only the battle creator can add bots | Nao e o criador |
| 400 | Battle is already full | Sem slots disponiveis |
BattleResponseDto Completo
{
id: string;
mode: string;
type: string;
status: string;
creator: {
id: string;
username: string;
avatarUrl?: string;
};
winner?: {
id: string;
username: string;
};
winnerTeam?: number;
players: {
id: string;
odUserId: string;
odUsername: string;
odAvatarUrl?: string;
team: number;
totalProfitCents: string;
totalProfitFormatted: string;
position?: number;
}[];
cases: {
id: string;
name: string;
imageUrl: string;
priceCents: string;
priceFormatted: string;
order: number;
}[];
totalValueCents: string;
totalValueFormatted: string;
isPrivate: boolean;
joinCode?: string;
createdAt: Date;
startedAt?: Date;
finishedAt?: Date;
winners?: {
odUserId: string;
odUsername: string;
odAvatarUrl?: string;
team: number;
totalWonCents: string;
}[];
settlementData?: Record<string, {
itemId: string;
itemName: string;
itemImage: string;
itemRarity: string;
valueCents: string;
type: string;
}[]>;
rounds?: {
roundNumber: number;
caseId: string;
results: {
odUserId: string;
odUsername: string;
itemId: string;
itemName: string;
itemImage: string;
itemRarity: string;
itemValueCents: string;
roll: number;
isFlip: boolean;
flipRoll?: number;
}[];
}[];
}Os campos winners, settlementData e rounds so estao presentes quando status === 'FINISHED' e winnerTeam esta definido.
Eventos WebSocket
Todos os eventos de batalha usam salas (rooms) do Socket.io. A sala de uma batalha e battle:{battleId}.
Eventos do Cliente -> Servidor
battle:join
Cliente entra na sala WebSocket de uma batalha para receber eventos em tempo real.
// Cliente envia:
socket.emit('battle:join', { battleId: '123456' });
// Servidor responde:
{ event: 'battle:joined', data: { battleId: '123456', room: 'battle:123456' } }Ao entrar, se a batalha esta IN_PROGRESS ou STARTING, o servidor envia o estado atual:
client.emit('battle:state', {
...state, // BattleState completo
isPhaseExpired, // boolean
timeRemainingMs, // number
serverTime: Date.now(),
});battle:leave
Cliente sai da sala WebSocket.
// Cliente envia:
socket.emit('battle:leave', { battleId: '123456' });
// Servidor responde:
{ event: 'battle:left', data: { battleId: '123456' } }battle:request_state
Cliente solicita o estado atual da batalha (util para reconexao).
// Cliente envia:
socket.emit('battle:request_state', { battleId: '123456' });
// Servidor responde com battle:state (ver abaixo)Se nao houver estado em Redis, responde com:
{ battleId: '123456', status: 'NOT_FOUND' }Eventos do Servidor -> Cliente (Sala da Batalha)
battle:player_joined
Emitido para a sala quando um jogador entra na batalha (via HTTP Join ou Add Bots).
{
battleId: string;
player: {
odUserId: string;
odUsername: string;
odAvatarUrl?: string;
team: number;
};
}battle:starting
Emitido quando a batalha vai iniciar (apos countdown de 3s).
{
battleId: string;
startsAt: string; // ISO 8601 timestamp
}battle:round_start
Emitido no inicio de cada rodada.
{
battleId: string;
roundNumber: number;
caseId: string;
caseName: string;
}battle:round_result
Emitido com os resultados de todos os jogadores naquela rodada (itens, rolls, flip).
{
battleId: string;
roundNumber: number;
results: {
odPlayerId: string;
odUsername: string;
itemId: string;
itemName: string;
itemImage: string;
itemRarity: string;
itemValueCents: string;
roll: number;
isFlip: boolean;
flipRoll?: number;
flipItems?: RoundResultItem[]; // apenas se isFlip === true
normalItems?: RoundResultItem[]; // itens da caixa para roleta
}[];
}Estrutura do RoundResultItem:
{
id: string;
itemId: string;
itemName: string;
itemImage: string;
rarity: string;
valueCents: string;
valueFormatted: string;
dropChance: number;
weight: number;
weaponCategory: string | null;
exterior: string | undefined;
isStatTrak: boolean;
}battle:state_push
Emitido a cada 2 segundos durante o spin da rodada para sincronizacao de clientes.
{
currentPhase: string; // 'COUNTDOWN' | 'ROUND_STARTING' | 'SPINNING' | 'ROUND_COMPLETE' | 'FINISHED'
phaseEndsAt: number; // timestamp em ms
currentRound: number;
hasFlipInCurrentRound: boolean;
spinStartedAt?: number; // timestamp em ms
serverTime: number; // Date.now() do servidor
}battle:finished
Emitido para a sala quando a batalha termina.
{
battleId: string;
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; // 'DROPPED' | 'REMOVED_TO_POOL' | 'ADDED_FROM_POOL'
}[]>;
}O settlementData e um mapa onde a chave e o userId (string) e o valor e o array de itens daquele jogador, incluindo itens removidos para o pool (para exibicao visual com linha riscada).
battle:cancelled
Emitido se a batalha e cancelada.
{
battleId: string;
reason: string;
}battle:state
Enviado ao cliente ao entrar na sala ou solicitar estado. Contem o estado completo da batalha:
{
battleId: string;
status: 'WAITING' | 'STARTING' | 'IN_PROGRESS' | 'FINISHED' | 'NOT_FOUND';
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>;
isPhaseExpired: boolean;
timeRemainingMs: number;
serverTime: number;
}Eventos Globais (Broadcast para Todos)
battle:created
Emitido para TODOS os clientes quando uma nova batalha e criada.
{
id: string;
mode: string;
type: string;
status: 'WAITING';
totalValueCents: string;
playersCount: number;
maxPlayers: number;
creator: {
id: string;
username: string;
avatarUrl?: string;
};
cases: {
id: string;
name: string;
imageUrl: string;
order: number;
}[];
isPrivate: boolean;
}battle:updated
Emitido para TODOS quando o status ou contagem de jogadores muda.
{
id: string;
status: string;
playersCount: number;
maxPlayers: number;
}battle:completed
Emitido para TODOS quando uma batalha termina (para atualizar a lista global).
{
battleId: string;
winners: {
odUserId: string;
odUsername: string;
odAvatarUrl?: string;
team: number;
totalWonCents: string;
}[];
totalPotCents: string;
settlementData: Record<string, ...>;
}live:drop
Emitido para TODOS quando um jogador real (nao bot) dropa um item. Atraso de 12s (normal) ou 30s (flip).
{
userName: string;
userAvatar?: string;
itemName: string;
itemImage: string;
itemValueCents: string;
rarity: string;
source: 'battle';
timestamp: string;
dropChance: number;
isTopDrop: boolean; // true se valueCents >= 100000 (R$1.000)
caseImage?: string;
}Battle State Service (Redis)
Arquivo: src/application/services/battle-state.service.ts
O estado da batalha e mantido em Redis para permitir reconexao de clientes e sincronizacao em tempo real.
Chave Redis
battle:state:{battleId}TTL: 3600 segundos (1 hora).
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>;
}Transicoes de Fase
COUNTDOWN (3s) -> ROUND_STARTING (500ms) -> SPINNING (8550ms ou 22050ms) -> ROUND_COMPLETE (1s) -> ROUND_STARTING -> ... -> FINISHED| Metodo | Fase Resultante | Duracao |
|---|---|---|
initializeBattleState() | COUNTDOWN | 3000ms |
startRound() | ROUND_STARTING | 500ms |
setRoundSpinning() | SPINNING | roundDelay (variavel) |
completeRound() | ROUND_COMPLETE | 1000ms |
finishBattle() | FINISHED | - |
Metodos Auxiliares
isPhaseExpired(state: BattleState): boolean {
return Date.now() >= state.phaseEndsAt;
}
getTimeRemainingMs(state: BattleState): number {
return Math.max(0, state.phaseEndsAt - Date.now());
}Provably Fair em Batalhas
Seeds por Jogador por Rodada
Diferente das aberturas de caixa individuais, em batalhas CADA JOGADOR recebe seeds INDEPENDENTES para CADA RODADA:
serverSeed: Gerado comrandomBytes(32).toString('hex').serverSeedHash: HMAC-SHA256 do serverSeed.clientSeed: Gerado comDate.now().toString() + randomBytes(16).toString('hex').nonce: Contador monotonico por usuario em Redis viaNonceService(INCR nonce:{userId}), compartilhado entre caixa, batalha e upgrade.
Calculo do Roll
O roll e calculado com range de 1 a 10.000.000:
const hmac = createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
const hashPrefix = hash.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const roll = (decimal % 10000000) + 1;Protecao contra modular bias:
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
if (result >= maxSafeValue) {
// Recalcula com suffix ':retry'
hmac2.update(`${clientSeed}:${nonce}:retry`);
}FLIP em Batalhas
O sistema FLIP funciona identicamente ao da abertura de caixas individual:
flipThresholde lido da colunaflipThresholdda caixa (nullable;null= caixa sem FLIP).- Se
roll >= flipThreshold, FLIP e ativado. - Seeds FLIP:
flipServerSeed = serverSeed + '_flip'flipClientSeed = clientSeed(mesmo)flipNonce = nonce + 1flipRoll = roll(o roll normal e repassado)
Verificacao
Para verificar um resultado de batalha:
- Obter
serverSeed,clientSeed,noncedo registroBattleRound. - Verificar
serverSeedHash:
const valid = createHmac('sha256', serverSeed).digest('hex') === serverSeedHash;- Recalcular roll:
const calculatedRoll = calculateRoll(serverSeed, clientSeed, nonce);
assert(calculatedRoll === savedRoll);- Se FLIP, verificar separadamente:
const flipValid = calculateRoll(serverSeed + '_flip', clientSeed, nonce + 1);Casos de Borda e Validacoes
Empate
Quando dois ou mais times empatam no topo e o modo nao e SHARED, nao ha sorteio: a distribuicao e dividida entre TODOS os jogadores (winnerTeam = 0, isTie = true):
const tied = entries.filter(([, v]) => v === topValue);
if (tied.length > 1) {
winnerTeam = 0;
isTie = true;
}Caixa sem Itens
Se uma caixa nao possui itens, a execucao lanca erro:
if (!caseItems || caseItems.length === 0) {
throw new BadRequestException(`Case ${battleCase.caseId} has no items`);
}Caixa sem FLIP Configurado
Se a coluna flipThreshold da caixa e null, o FLIP nunca e ativado:
if (flipThreshold === null) {
return false;
}Roll sem Item Correspondente
Se nenhum item corresponde ao roll (falha nos hash ranges), um erro e lancado:
if (!matchedItem) {
throw new Error(`No item found for roll ${roll}`);
}Pool de Compensacao Vazio
Se nao ha itens elegiveis para compensacao, o jogador pode ficar com deficit:
if (eligibleItems.length === 0) {
return [];
}Concorrencia na Entrada
O lockService.withUserBalanceLock(userId, ...) previne que o mesmo usuario entre em duas batalhas simultaneamente gastando o mesmo saldo.
A constraint @@unique([battleId, userId]) no Prisma previne entradas duplicadas no banco.
Execucao Assincrona
A execucao da batalha ocorre fora da request HTTP via setImmediate(). Se a execucao falhar, o erro e logado mas nao retornado ao cliente:
setImmediate(() => {
this.executeBattleUseCase.execute(battleId).catch((err) => {
console.error(`[Battle] Failed to execute battle ${battleId}:`, err);
});
});Debugging
Logs da Criacao
[CreateBattle] Saving 3 cases: order 0: 123, order 1: 456, order 2: 789
[CreateBattle] Adding case order 0: caseId=123
[CreateBattle] Adding case order 1: caseId=456
[CreateBattle] Adding case order 2: caseId=789Logs da Execucao (por rodada)
[Battle 999] Starting battle with 3 rounds
[Battle 999] Cases order: 0: Snow Case (order: 0), 1: Star Case (order: 1), 2: Emerald Case (order: 2)
[Battle 999] Processing round 1/3 - Case: Snow Case (order: 0)
[Battle 999] Player Player1 roll: 456789, flipThreshold: 9800000, isFlip: false
[Battle 999] Player Player1 won: AK-47 | Redline (isRare: false)
[Battle 999] Emitting round 1 results with 4 players
[Battle 999] Waiting 8.55s before next round (hasFlip: false)...
[Battle 999] Round 1 completeLogs do Settlement
============================================================
[Battle 999] SETTLEMENT - Starting
============================================================
[Battle 999] Team 1 Total: R$ 150.30
[Battle 999] Team 2 Total: R$ 120.70
[Battle 999] Winner Team: 1 (Mode: NORMAL)
[Battle 999] All items dropped (12 total):
Round 1 | Team 1 | AK-47 | Redline | R$ 15.30
Round 1 | Team 2 | M4A4 | Desert Strike | R$ 8.50
...
[Battle 999] Winners (2): Player1, Player2
[Battle 999] Fair Distribution Calculation:
Total Items Value: R$ 271.00
Number of Winners: 2
Fair Share Each: R$ 135.50
[Battle 999] STEP 1: Separating items > fairShare to pool...
AWP | Dragon Lore (R$ 707.65) -> POOL (exceeds fair share of R$ 135.50)
[Battle 999] STEP 2: Distributing 11 items...
AK-47 | Redline (R$ 15.30) -> Player1
M4A4 | Desert Strike (R$ 8.50) -> Player2
...
[Battle 999] After initial distribution:
Player1: R$ 140.20 (+4.70)
Player2: R$ 130.80 (-4.70)
[Battle 999] STEP 3: Balancing - removing excess items...
Removed P250 Sand Dune (R$ 3.20) from Player1
[Battle 999] After removing excess items:
Player1: R$ 137.00 (+1.50)
Player2: R$ 130.80 (-4.70)
[Battle 999] STEP 3: Adding items from pool to players below fair share...
Player2 needs R$ 4.70 more
[findCompensationItems] Looking for items to cover R$ 4.70
[findCompensationItems] Found 150 eligible items (of 500 total)
[findCompensationItems] Total: 1 items = R$ 4.50 (target was R$ 4.70)
Added 1 items, total now: R$ 135.30
[Battle 999] Final distribution:
Player1: R$ 137.00 (5 dropped, 0 added)
Player2: R$ 135.30 (6 dropped, 1 added)
[Battle 999] Saved 14 settlement records
[Battle 999] Added 5 items to Player1's inventory
[Battle 999] Added 7 items to Player2's inventory
------------------------------------------------------------
[Battle 999] FINAL SUMMARY:
Total Items Value: R$ 271.00
Fair Share Per Winner: R$ 135.50
Winners:
Player1: R$ 137.00 total (5 dropped + 0 from pool)
Player2: R$ 135.30 total (6 dropped + 1 from pool)
Items Removed to Pool: 2
============================================================Como Diagnosticar Problemas
Batalha travada em WAITING:
- Verificar se todos os slots foram preenchidos.
- Verificar logs do
JoinBattleUseCasepara erros.
Batalha travada em IN_PROGRESS:
- Verificar logs do
ExecuteBattleUseCase. - Verificar estado em Redis:
GET battle:state:{battleId}. - Verificar se o
setImmediatefalhou silenciosamente.
Saldo nao devolvido apos cancelamento:
- Verificar se ha transacao de debito para a batalha.
- Verificar se ha transacao de reembolso correspondente.
Itens nao aparecendo no inventario:
- Verificar se o jogador e bot (
steamId.startsWith('BOT_')). - Verificar registros em
BattleSettlementpara aqueleodUserId. - Verificar se
type === DROPPED || type === ADDED_FROM_POOL.
Valores incorretos no settlement:
- Confirmar que
valueCentsBrlesta sendo usado (naovalueCentsque e USD). - Verificar logs do settlement para o calculo do fairShare.
