Skip to content

Battles - Sistema Completo de Batalhas PvP

Indice

  1. Visao Geral
  2. Tipos e Modos
  3. Modelo de Dados (Prisma)
  4. Fluxo de Criacao
  5. Fluxo de Entrada (Join)
  6. Sistema de Bots
  7. Fluxo de Execucao
  8. Settlement (Distribuicao Justa)
  9. Endpoints da API
  10. Eventos WebSocket
  11. Battle State Service (Redis)
  12. Provably Fair em Batalhas
  13. Casos de Borda e Validacoes
  14. 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:

ArquivoResponsabilidade
src/application/use-cases/battle/create-battle.use-case.tsCriacao de batalhas
src/application/use-cases/battle/join-battle.use-case.tsEntrada de jogadores
src/application/use-cases/battle/execute-battle.use-case.tsExecucao rodada-a-rodada e settlement
src/application/use-cases/battle/add-bots-to-battle.use-case.tsAdicao de bots
src/application/use-cases/battle/get-active-battles.use-case.tsListagem de batalhas ativas
src/application/use-cases/battle/get-battle-by-id.use-case.tsBusca de batalha por ID
src/application/use-cases/battle/get-battle-list.use-case.tsListagem paginada de batalhas
src/presentation/controllers/battle.controller.tsController HTTP
src/application/dto/battle.dto.tsDTOs de validacao e resposta
src/application/services/battle-state.service.tsEstado da batalha em Redis
src/application/services/flip.service.tsLogica FLIP (segunda roleta)
src/application/services/provably-fair.service.tsCalculo de rolls
src/infrastructure/websocket/websocket.gateway.tsEmissao de eventos WebSocket
src/domain/repositories/battle.repository.interface.tsInterface do repositorio
src/domain/repositories/battle-settlement.repository.interface.tsInterface do settlement
src/infrastructure/database/repositories/battle.repository.tsImplementacao do repositorio

Tipos e Modos

BattleType - Tamanho da Batalha

EnumMax JogadoresJogadores por TimeTimes
ONE_VS_ONE212
TWO_VS_TWO422
THREE_VS_THREE632
FOUR_VS_FOUR842
ONE_VS_ONE_VS_ONE313
ONE_VS_ONE_VS_ONE_VS_ONE414

Funcao de mapeamento utilizada em todos os use cases:

typescript
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.

typescript
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

EnumDescricaoLogica de Vencedor
NORMALMaior valor total venceTime com MAIOR total (times ordenados desc)
FLIPMenor valor total vence (invertido)Time com MENOR total (times ordenados asc)
SHAREDTodos vencemwinnerTeam = 0 (todos sao vencedores)

Codigo real de determinacao do vencedor em execute-battle.use-case.ts (generalizado para N times via teamTotals):

typescript
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
StatusDescricaoQuando Muda
WAITINGAguardando jogadoresCriacao da batalha
IN_PROGRESSBatalha em execucaoQuando todos os slots sao preenchidos
FINISHEDBatalha finalizadaApos settlement
CANCELLEDBatalha canceladaTimeout ou cancelamento manual

Modelo de Dados (Prisma)

Battle

prisma
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 com randomBytes(4).toString('hex').toUpperCase(). Apenas para batalhas privadas.

BattlePlayer

prisma
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 como finalValue - battle.totalValueCents para vencedores, -battle.totalValueCents para perdedores.
  • A constraint @@unique([battleId, userId]) impede que o mesmo jogador entre duas vezes na mesma batalha.

BattleCase

prisma
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

prisma
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: Armazena item.valueCentsBrl (BRL, nao USD).
  • playerId: Referencia a BattlePlayer.id, nao ao User.id diretamente.
  • Seeds sao gerados POR JOGADOR POR RODADA (cada jogador tem seus proprios seeds em cada rodada).
  • Campos flip* sao preenchidos quando isFlip = true. O flipServerSeed e serverSeed + '_flip', o flipClientSeed e o mesmo clientSeed, e o flipNonce e nonce + 1.

BattleSettlement

prisma
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:

TipoDescricao
DROPPEDItem dropado durante a batalha, distribuido para o vencedor
REMOVED_TO_POOLItem removido por ter valor maior que o fairShare; vai para a casa
ADDED_FROM_POOLItem adicionado da tabela Item para compensar deficit

Fluxo de Criacao

Arquivo: src/application/use-cases/battle/create-battle.use-case.ts

Parametros de Entrada

typescript
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

  1. caseIds.length deve estar entre 1 e 10:
typescript
if (caseIds.length === 0 || caseIds.length > 10) {
  throw new BadRequestException('Must select between 1 and 10 cases');
}
  1. Todas as caixas devem existir e estar ativas:
typescript
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`);
  }
}
  1. Saldo do criador deve cobrir o custo total (soma dos precos de todas as caixas):
typescript
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

  1. Valida caixas (existencia e status ACTIVE).
  2. Calcula totalValueCents como soma de priceCents de todas as caixas.
  3. Adquire lock de saldo do usuario com lockService.withUserBalanceLock(userId, ...).
  4. Dentro de transacao Prisma: a. Verifica saldo real via transactionService.getUserBalance(). b. Debita saldo do criador via transactionService.debit(). c. Gera joinCode se isPrivate === true com randomBytes(4).toString('hex').toUpperCase(). d. Cria registro Battle no banco. e. Adiciona criador como BattlePlayer no time 1. f. Adiciona todas as caixas como BattleCase com order sequencial (0-indexed). g. Emite balance:updated via WebSocket para o criador. h. Emite battle:created via WebSocket (broadcast global).
  5. Retorna a batalha criada.

Retorno

typescript
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

typescript
async execute(
  userId: bigint,
  battleId: bigint,
  team: number,        // 1 ou 2
  joinCode?: string,   // Obrigatorio para batalhas privadas
)

Validacoes

  1. Batalha deve existir:
typescript
if (!battle) {
  throw new NotFoundException('Battle not found');
}
  1. Status deve ser WAITING:
typescript
if (battle.status !== 'WAITING') {
  throw new BadRequestException('Battle is not waiting for players');
}
  1. Codigo de entrada para batalhas privadas:
typescript
if (battle.isPrivate && battle.joinCode !== joinCode) {
  throw new BadRequestException('Invalid join code');
}
  1. Jogador nao pode estar na batalha:
typescript
const alreadyJoined = battle.players.some((p) => p.userId === userId);
if (alreadyJoined) {
  throw new BadRequestException('Already joined this battle');
}
  1. Batalha nao pode estar cheia:
typescript
const maxPlayers = this.getMaxPlayers(battle.type);
if (battle.players.length >= maxPlayers) {
  throw new BadRequestException('Battle is full');
}
  1. Time especifico nao pode estar cheio:
typescript
const playersPerTeam = maxPlayers / 2;
const teamPlayers = battle.players.filter((p) => p.team === team).length;
if (teamPlayers >= playersPerTeam) {
  throw new BadRequestException('Team is full');
}
  1. Saldo suficiente para entrada (custo e battle.totalValueCents):
typescript
const realBalance = await this.transactionService.getUserBalance(userId);
if (realBalance < battle.totalValueCents) {
  throw new BadRequestException('Insufficient balance');
}

Fluxo Completo

  1. Busca batalha pelo ID.
  2. Executa todas as validacoes listadas acima.
  3. Adquire lock de saldo do usuario.
  4. Dentro de transacao Prisma: a. Verifica saldo real novamente (dentro do lock). b. Debita saldo via transactionService.debit() com descricao Battle entry fee - Battle #{battleId}. c. Emite balance:updated para o jogador. d. Adiciona jogador como BattlePlayer. e. Emite battle:player_joined para a sala da batalha. f. Emite battle:updated (broadcast global). g. Se a batalha ficou cheia (players.length === maxPlayers):
    • Atualiza status para IN_PROGRESS.
    • Emite battle:updated com status IN_PROGRESS.
    • Inicia execucao da batalha com setImmediate():
typescript
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

typescript
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:

typescript
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:

typescript
const isBot = player.user.steamId.startsWith('BOT_');

Validacoes

  1. Batalha deve existir e estar com status WAITING.
  2. Apenas o criador da batalha pode adicionar bots:
typescript
if (battle.creatorId !== requestingUserId) {
  throw new BadRequestException('Only the battle creator can add bots');
}
  1. A batalha nao pode estar cheia.

Fluxo de Adicao

  1. Calcula botsNeeded = maxPlayers - currentPlayers.
  2. 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 como BattlePlayer. d. Emite battle:player_joined.
  3. Atualiza status para IN_PROGRESS.
  4. Emite battle:updated.
  5. 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:
typescript
const isBot = player.user.steamId.startsWith('BOT_');
if (!isBot) {
  setTimeout(() => {
    this.webSocketGateway.emitLiveDrop({ ... });
  }, liveDropDelay);
}
  • Bots vencedores NAO recebem itens no inventario:
typescript
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:

  1. Inicializacao do estado em Redis.
  2. Delay de countdown (3 segundos).
  3. Loop de rodadas (1 rodada por caixa).
  4. Settlement (distribuicao de itens).
  5. Finalizacao e broadcast de resultado.

Fase 1: Inicializacao

typescript
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:

typescript
for (let roundIndex = 0; roundIndex < battle.cases.length; roundIndex++) {
  const battleCase = battle.cases[roundIndex];
  const roundNumber = roundIndex + 1;
  // ...
}

Fase 2.1: Inicio da Rodada

typescript
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:

typescript
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:

typescript
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):

typescript
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:

typescript
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

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
const pd = playerData.get(player.userId)!;
pd.totalValueCents += wonItem.item.valueCentsBrl;

Fase 2.5: Live Drop (apenas jogadores reais)

typescript
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

typescript
this.webSocketGateway.emitBattleRoundResult(battleId, roundNumber, roundResults);

Cada roundResults[i] contem:

typescript
{
  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:

typescript
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

typescript
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;
CenarioCalculoDuracao Total
Rodada SEM flip6000 + 1050 + 15008.550ms
Rodada COM flip6000 + 8550 + 6000 + 150022.050ms

O delay e consumido em intervalos de 2 segundos, emitindo battle:state_push a cada intervalo para manter os clientes sincronizados:

typescript
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:

typescript
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

typescript
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:

typescript
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

typescript
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 = 3
  • fairSharePerWinner = 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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

typescript
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 > 0 sao 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:

typescript
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:

typescript
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

typescript
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

typescript
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:

typescript
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):

typescript
{
  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):

json
{
  "battleId": "1234567890",
  "joinCode": "A1B2C3D4"
}

O joinCode so esta presente se isPrivate === true.

Erros:

StatusMensagemCondicao
400Must select between 1 and 10 casescaseIds.length === 0 ou > 10
400Case not foundCaixa nao existe
400Case {name} is not activeCaixa inativa
400Insufficient balanceSaldo insuficiente

POST /battles/:id/join

Entra em uma batalha existente.

Autenticacao: Obrigatoria

Params:

  • id: ID da batalha (string numerico)

Body (JoinBattleDto):

typescript
{
  team: number,         // obrigatorio, 1 ou 2
  joinCode?: string     // opcional, obrigatorio para batalhas privadas
}

Response (200):

json
{
  "success": true
}

Erros:

StatusMensagemCondicao
404Battle not foundID inexistente
400Battle is not waiting for playersStatus nao e WAITING
400Invalid join codeCodigo incorreto para batalha privada
400Already joined this battleJogador ja esta na batalha
400Battle is fullTodos os slots preenchidos
400Team is fullTime especifico esta cheio
400Insufficient balanceSaldo insuficiente

GET /battles/active

Lista batalhas ativas (WAITING e IN_PROGRESS por padrao).

Autenticacao: Obrigatoria

Query (GetBattlesQueryDto):

typescript
{
  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):

typescript
{
  status?: 'WAITING' | 'IN_PROGRESS' | 'FINISHED' | 'CANCELLED',
  mode?: 'NORMAL' | 'FLIP' | 'SHARED',
  limit?: number,    // default 10
  offset?: number    // default 0
}

Response (200):

json
{
  "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:

StatusMensagemCondicao
404Battle not foundID 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):

json
{
  "success": true
}

Erros:

StatusMensagemCondicao
404Battle not foundID inexistente
400Battle is not waiting for playersStatus nao e WAITING
400Only the battle creator can add botsNao e o criador
400Battle is already fullSem slots disponiveis

BattleResponseDto Completo

typescript
{
  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.

typescript
// 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:

typescript
client.emit('battle:state', {
  ...state,            // BattleState completo
  isPhaseExpired,      // boolean
  timeRemainingMs,     // number
  serverTime: Date.now(),
});

battle:leave

Cliente sai da sala WebSocket.

typescript
// 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).

typescript
// Cliente envia:
socket.emit('battle:request_state', { battleId: '123456' });

// Servidor responde com battle:state (ver abaixo)

Se nao houver estado em Redis, responde com:

typescript
{ 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).

typescript
{
  battleId: string;
  player: {
    odUserId: string;
    odUsername: string;
    odAvatarUrl?: string;
    team: number;
  };
}

battle:starting

Emitido quando a batalha vai iniciar (apos countdown de 3s).

typescript
{
  battleId: string;
  startsAt: string;   // ISO 8601 timestamp
}

battle:round_start

Emitido no inicio de cada rodada.

typescript
{
  battleId: string;
  roundNumber: number;
  caseId: string;
  caseName: string;
}

battle:round_result

Emitido com os resultados de todos os jogadores naquela rodada (itens, rolls, flip).

typescript
{
  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:

typescript
{
  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.

typescript
{
  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.

typescript
{
  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.

typescript
{
  battleId: string;
  reason: string;
}

battle:state

Enviado ao cliente ao entrar na sala ou solicitar estado. Contem o estado completo da batalha:

typescript
{
  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.

typescript
{
  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.

typescript
{
  id: string;
  status: string;
  playersCount: number;
  maxPlayers: number;
}

battle:completed

Emitido para TODOS quando uma batalha termina (para atualizar a lista global).

typescript
{
  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).

typescript
{
  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

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

Transicoes de Fase

COUNTDOWN (3s) -> ROUND_STARTING (500ms) -> SPINNING (8550ms ou 22050ms) -> ROUND_COMPLETE (1s) -> ROUND_STARTING -> ... -> FINISHED
MetodoFase ResultanteDuracao
initializeBattleState()COUNTDOWN3000ms
startRound()ROUND_STARTING500ms
setRoundSpinning()SPINNINGroundDelay (variavel)
completeRound()ROUND_COMPLETE1000ms
finishBattle()FINISHED-

Metodos Auxiliares

typescript
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:

  1. serverSeed: Gerado com randomBytes(32).toString('hex').
  2. serverSeedHash: HMAC-SHA256 do serverSeed.
  3. clientSeed: Gerado com Date.now().toString() + randomBytes(16).toString('hex').
  4. nonce: Contador monotonico por usuario em Redis via NonceService (INCR nonce:{userId}), compartilhado entre caixa, batalha e upgrade.

Calculo do Roll

O roll e calculado com range de 1 a 10.000.000:

typescript
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:

typescript
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:

  1. flipThreshold e lido da coluna flipThreshold da caixa (nullable; null = caixa sem FLIP).
  2. Se roll >= flipThreshold, FLIP e ativado.
  3. Seeds FLIP:
    • flipServerSeed = serverSeed + '_flip'
    • flipClientSeed = clientSeed (mesmo)
    • flipNonce = nonce + 1
    • flipRoll = roll (o roll normal e repassado)

Verificacao

Para verificar um resultado de batalha:

  1. Obter serverSeed, clientSeed, nonce do registro BattleRound.
  2. Verificar serverSeedHash:
typescript
const valid = createHmac('sha256', serverSeed).digest('hex') === serverSeedHash;
  1. Recalcular roll:
typescript
const calculatedRoll = calculateRoll(serverSeed, clientSeed, nonce);
assert(calculatedRoll === savedRoll);
  1. Se FLIP, verificar separadamente:
typescript
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):

typescript
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:

typescript
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:

typescript
if (flipThreshold === null) {
  return false;
}

Roll sem Item Correspondente

Se nenhum item corresponde ao roll (falha nos hash ranges), um erro e lancado:

typescript
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:

typescript
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:

typescript
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=789

Logs 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 complete

Logs 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 JoinBattleUseCase para erros.

Batalha travada em IN_PROGRESS:

  • Verificar logs do ExecuteBattleUseCase.
  • Verificar estado em Redis: GET battle:state:{battleId}.
  • Verificar se o setImmediate falhou 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 BattleSettlement para aquele odUserId.
  • Verificar se type === DROPPED || type === ADDED_FROM_POOL.

Valores incorretos no settlement:

  • Confirmar que valueCentsBrl esta sendo usado (nao valueCents que e USD).
  • Verificar logs do settlement para o calculo do fairShare.

Documentação Técnica - ProCases