Skip to content

Battles - Batalhas PvP

As batalhas são confrontos entre jogadores onde cada um abre a mesma caixa simultaneamente. O jogador (ou time) com maior valor total de itens vence e leva todos os itens.

Modalidades

ModoJogadoresTimesDescrição
1v12SoloClássico head-to-head
3v362 timesTime vs time

Tipos de Batalha

Normal Mode

  • Vencedor (time com maior valor total) leva todos os itens dropados
  • Distribuição justa entre vencedores (se time)

Invertido Mode

  • Perdedor ganha: O time com menor valor total leva todos os itens
  • Inverte a lógica tradicional de batalha

Fluxo da Batalha

Modelo de Dados

Battle

prisma
model Battle {
  id              BigInt        @id @default(autoincrement())
  caseId          BigInt
  creatorId       BigInt
  
  mode            BattleMode    // NORMAL, FLIP, CRAZY
  type            BattleType    // SOLO_1V1, TEAM_2V2, etc.
  status          BattleStatus
  
  entryPriceCents BigInt
  totalPotCents   BigInt
  roundCount      Int           @default(1)
  
  // Provably Fair
  serverSeed      String
  serverSeedHash  String
  
  // Resultado
  winnerTeam      Int?
  
  participants    BattleParticipant[]
  rounds          BattleRound[]
  settlements     BattleSettlement[]
  
  createdAt       DateTime      @default(now())
  startedAt       DateTime?
  finishedAt      DateTime?
  
  @@index([status])
  @@index([creatorId])
}

enum BattleStatus {
  WAITING         // Aguardando jogadores
  READY           // Todos entraram
  COUNTDOWN       // Contagem regressiva
  RUNNING         // Em execução
  FINISHED        // Finalizada
  CANCELLED       // Cancelada
}

enum BattleMode {
  NORMAL
  FLIP
  CRAZY
}

enum BattleType {
  SOLO_1V1
  TEAM_3V3
}

BattleParticipant

prisma
model BattleParticipant {
  id              BigInt    @id @default(autoincrement())
  battleId        BigInt
  userId          BigInt
  team            Int       // 1 ou 2
  slot            Int       // Posição no time
  
  // Seed individual
  clientSeed      String
  nonce           Int
  
  // Resultado
  totalValueCents BigInt    @default(0)
  isWinner        Boolean   @default(false)
  
  battle          Battle    @relation(...)
  user            User      @relation(...)
  rounds          BattleRound[]
  
  @@unique([battleId, userId])
  @@index([battleId])
}

BattleRound

prisma
model BattleRound {
  id              BigInt    @id @default(autoincrement())
  battleId        BigInt
  participantId   BigInt
  roundNumber     Int
  
  // Resultado
  roll            Int
  itemId          BigInt
  itemValueCents  BigInt
  
  // FLIP (se aplicável)
  isFlip          Boolean   @default(false)
  flipRoll        Int?
  
  battle          Battle    @relation(...)
  participant     BattleParticipant @relation(...)
  item            Item      @relation(...)
  
  @@unique([battleId, participantId, roundNumber])
}

Implementação

Criar Batalha

typescript
// create-battle.use-case.ts
async execute(creatorId: bigint, dto: CreateBattleDto): Promise<Battle> {
  const lockKey = `user:${creatorId}:create-battle`;
  
  return await this.redlock.using([lockKey], 30000, async () => {
    // 1. Validar caixa
    const caseData = await this.caseRepository.findById(dto.caseId);
    if (!caseData?.isActive) {
      throw new CaseNotFoundException();
    }
    
    // 2. Calcular custo total
    const participantCount = this.getParticipantCount(dto.type);
    const totalRounds = dto.roundCount || 1;
    const entryCost = caseData.priceCents * BigInt(totalRounds);
    
    // 3. Verificar saldo
    const balance = await this.transactionService.getUserBalance(creatorId);
    if (balance < entryCost) {
      throw new InsufficientBalanceException();
    }
    
    // 4. Gerar seeds
    const serverSeed = this.provablyFairService.generateServerSeed();
    const serverSeedHash = this.provablyFairService.hashSeed(serverSeed);
    
    return await this.prisma.$transaction(async (tx) => {
      // 5. Criar batalha
      const battle = await tx.battle.create({
        data: {
          caseId: dto.caseId,
          creatorId,
          mode: dto.mode,
          type: dto.type,
          status: BattleStatus.WAITING,
          entryPriceCents: entryCost,
          totalPotCents: 0n,
          roundCount: totalRounds,
          serverSeed,
          serverSeedHash,
        },
      });
      
      // 6. Adicionar criador como participante
      await this.addParticipant(tx, battle.id, creatorId, 1, 1);
      
      // 7. Debitar saldo
      await this.transactionService.createTransaction(tx, {
        debitUserId: creatorId,
        creditUserId: ESCROW_USER_ID,
        amountCents: entryCost,
        type: TransactionType.BATTLE_ENTRY,
        referenceType: 'BATTLE',
        referenceId: battle.id,
      });
      
      // 8. Atualizar pot
      await tx.battle.update({
        where: { id: battle.id },
        data: { totalPotCents: entryCost },
      });
      
      // 9. Broadcast
      this.webSocketGateway.broadcastNewBattle(battle);
      
      return battle;
    });
  });
}

Entrar na Batalha

typescript
// join-battle.use-case.ts
async execute(userId: bigint, battleId: bigint, team?: number): Promise<Battle> {
  const lockKey = `battle:${battleId}:join`;
  
  return await this.redlock.using([lockKey], 30000, async () => {
    const battle = await this.battleRepository.findById(battleId);
    
    // 1. Validar status
    if (battle.status !== BattleStatus.WAITING) {
      throw new BattleNotOpenException();
    }
    
    // 2. Validar não está na batalha
    if (battle.participants.some(p => p.userId === userId)) {
      throw new AlreadyInBattleException();
    }
    
    // 3. Validar slots disponíveis
    const slot = this.findAvailableSlot(battle, team);
    if (!slot) {
      throw new BattleFullException();
    }
    
    // 4. Verificar saldo
    const balance = await this.transactionService.getUserBalance(userId);
    if (balance < battle.entryPriceCents) {
      throw new InsufficientBalanceException();
    }
    
    return await this.prisma.$transaction(async (tx) => {
      // 5. Adicionar participante
      await this.addParticipant(
        tx,
        battleId,
        userId,
        slot.team,
        slot.position,
      );
      
      // 6. Debitar saldo
      await this.transactionService.createTransaction(tx, {
        debitUserId: userId,
        creditUserId: ESCROW_USER_ID,
        amountCents: battle.entryPriceCents,
        type: TransactionType.BATTLE_ENTRY,
        referenceType: 'BATTLE',
        referenceId: battleId,
      });
      
      // 7. Atualizar pot
      const newPot = battle.totalPotCents + battle.entryPriceCents;
      await tx.battle.update({
        where: { id: battleId },
        data: { totalPotCents: newPot },
      });
      
      // 8. Verificar se está cheio
      const updatedBattle = await tx.battle.findUnique({
        where: { id: battleId },
        include: { participants: true },
      });
      
      const maxParticipants = this.getParticipantCount(battle.type);
      if (updatedBattle.participants.length >= maxParticipants) {
        // Iniciar batalha
        await this.startBattle(tx, battleId);
      }
      
      // 9. Broadcast
      this.webSocketGateway.broadcastParticipantJoined(updatedBattle, userId);
      
      return updatedBattle;
    });
  });
}

Executar Batalha

typescript
// execute-battle.use-case.ts
async execute(battleId: bigint): Promise<BattleResult> {
  const battle = await this.battleRepository.findById(battleId);
  
  // 1. Atualizar status
  await this.battleRepository.updateStatus(battleId, BattleStatus.RUNNING);
  this.webSocketGateway.broadcastBattleStarted(battle);
  
  const allRounds: BattleRound[] = [];
  
  // 2. Executar cada rodada
  for (let round = 1; round <= battle.roundCount; round++) {
    // 3. Gerar resultados para cada participante
    for (const participant of battle.participants) {
      const roll = this.provablyFairService.calculateRoll(
        battle.serverSeed,
        participant.clientSeed,
        participant.nonce + round - 1,
      );
      
      // 4. Determinar item
      const item = await this.selectItemByRoll(battle.caseId, roll);
      
      // 5. Verificar FLIP (se modo FLIP)
      let isFlip = false;
      let flipRoll: number | null = null;
      
      if (battle.mode === BattleMode.FLIP) {
        isFlip = this.flipService.shouldTriggerFlip(roll);
        if (isFlip) {
          // ... lógica FLIP
        }
      }
      
      // 6. Salvar rodada
      const battleRound = await this.battleRoundRepository.create({
        battleId,
        participantId: participant.id,
        roundNumber: round,
        roll,
        itemId: item.id,
        itemValueCents: item.valueCents,
        isFlip,
        flipRoll,
      });
      
      allRounds.push(battleRound);
      
      // 7. Adicionar item ao inventário (temporário)
      await this.inventoryService.addItem(participant.userId, item.id);
    }
    
    // 8. Broadcast rodada
    this.webSocketGateway.broadcastBattleRound(battleId, round, allRounds);
    
    // 9. Delay para animação (se não for última rodada)
    if (round < battle.roundCount) {
      await this.delay(12000); // 12s para animação da roleta
    }
  }
  
  // 10. Calcular vencedor
  const result = await this.calculateWinner(battle, allRounds);
  
  // 11. Distribuir itens
  const settlements = await this.settleBattle(battle, result);
  
  // 12. Finalizar
  await this.battleRepository.update(battleId, {
    status: BattleStatus.FINISHED,
    winnerTeam: result.winnerTeam,
    finishedAt: new Date(),
  });
  
  // 13. Broadcast resultado final
  this.webSocketGateway.broadcastBattleFinished(battle, result, settlements);
  
  return result;
}

Calcular Vencedor

typescript
// calculate-winner.service.ts
calculateWinner(
  battle: Battle,
  rounds: BattleRound[],
): BattleResult {
  // 1. Agrupar por time
  const teamTotals: Map<number, bigint> = new Map();
  
  for (const participant of battle.participants) {
    const participantRounds = rounds.filter(
      r => r.participantId === participant.id,
    );
    
    const total = participantRounds.reduce(
      (sum, r) => sum + r.itemValueCents,
      0n,
    );
    
    const currentTeamTotal = teamTotals.get(participant.team) || 0n;
    teamTotals.set(participant.team, currentTeamTotal + total);
    
    // Atualizar total do participante
    participant.totalValueCents = total;
  }
  
  // 2. Determinar time vencedor
  let winnerTeam = 1;
  let maxValue = 0n;
  
  for (const [team, total] of teamTotals) {
    if (total > maxValue) {
      maxValue = total;
      winnerTeam = team;
    }
  }
  
  // 3. Marcar vencedores
  const winners = battle.participants.filter(p => p.team === winnerTeam);
  const losers = battle.participants.filter(p => p.team !== winnerTeam);
  
  return {
    winnerTeam,
    winners,
    losers,
    teamTotals: Object.fromEntries(teamTotals),
    totalPot: battle.totalPotCents,
  };
}

Settlement (Distribuição)

Ver Sistema de Settlement para detalhes completos.

typescript
// settle-battle.service.ts
async settleBattle(
  battle: Battle,
  result: BattleResult,
): Promise<BattleSettlement[]> {
  const settlements: BattleSettlement[] = [];
  const allItems = await this.getAllDroppedItems(battle.id);
  
  // 1. Calcular fair share
  const totalValue = allItems.reduce((sum, i) => sum + i.valueCents, 0n);
  const fairSharePerWinner = totalValue / BigInt(result.winners.length);
  
  // 2. Distribuir itens
  for (const winner of result.winners) {
    let winnerValue = 0n;
    
    // 2.1 Atribuir itens dropados
    for (const item of allItems) {
      if (item.valueCents <= fairSharePerWinner) {
        settlements.push({
          battleId: battle.id,
          odUserId: winner.userId,
          itemId: item.id,
          type: 'DROPPED',
          valueCents: item.valueCents,
        });
        winnerValue += item.valueCents;
      } else {
        // Item muito caro - remover para pool
        settlements.push({
          battleId: battle.id,
          odUserId: null, // Pool da casa
          itemId: item.id,
          type: 'REMOVED_TO_POOL',
          valueCents: item.valueCents,
        });
      }
    }
    
    // 2.2 Compensar com itens do pool se necessário
    const deficit = fairSharePerWinner - winnerValue;
    if (deficit > 0n) {
      const compensationItems = await this.findCompensationItems(deficit);
      for (const compItem of compensationItems) {
        settlements.push({
          battleId: battle.id,
          odUserId: winner.userId,
          itemId: compItem.id,
          type: 'ADDED_FROM_POOL',
          valueCents: compItem.valueCents,
        });
      }
    }
  }
  
  // 3. Salvar settlements
  await this.battleSettlementRepository.createMany(settlements);
  
  // 4. Atualizar inventários
  await this.updateInventories(settlements);
  
  return settlements;
}

WebSocket Events

Eventos Emitidos

typescript
interface BattleWebSocketEvents {
  // Nova batalha criada
  'battle:created': {
    battle: Battle;
    creator: UserPublic;
    case: CasePublic;
  };
  
  // Jogador entrou
  'battle:joined': {
    battleId: string;
    participant: UserPublic;
    team: number;
    slot: number;
  };
  
  // Batalha iniciou
  'battle:started': {
    battleId: string;
    countdown: number;
  };
  
  // Resultado da rodada
  'battle:round': {
    battleId: string;
    roundNumber: number;
    results: {
      participantId: string;
      roll: number;
      item: ItemPublic;
      isFlip: boolean;
    }[];
  };
  
  // Batalha finalizada
  'battle:finished': {
    battleId: string;
    winnerTeam: number;
    winners: UserPublic[];
    settlements: BattleSettlement[];
    serverSeed: string; // Revelado
  };
}

Endpoints da API

Criar Batalha

http
POST /api/battles
Authorization: Bearer {sessionId}
Content-Type: application/json

{
  "caseId": "123",
  "type": "TEAM_3V3",
  "mode": "NORMAL",
  "roundCount": 3
}

Listar Batalhas Ativas

http
GET /api/battles?status=WAITING&type=SOLO_1V1

Entrar na Batalha

http
POST /api/battles/:id/join
Authorization: Bearer {sessionId}
Content-Type: application/json

{
  "team": 2
}

Detalhes da Batalha

http
GET /api/battles/:id

Response:

json
{
  "id": "456",
  "case": { "name": "Caixa Neon", ... },
  "status": "RUNNING",
  "mode": "NORMAL",
  "type": "TEAM_3V3",
  "roundCount": 3,
  "currentRound": 2,
  "participants": [
    {
      "userId": "111",
      "username": "player1",
      "team": 1,
      "slot": 1,
      "totalValueCents": 15000
    }
  ],
  "rounds": [
    {
      "roundNumber": 1,
      "results": [...]
    }
  ],
  "serverSeedHash": "abc123..."
}

Frontend

Tela de Lobby

tsx
const BattleLobby = ({ battle }: { battle: Battle }) => {
  const { joinBattle } = useBattleSocket();
  
  return (
    <div className="grid grid-cols-2 gap-8">
      {/* Time 1 */}
      <TeamSlots
        team={1}
        participants={battle.participants.filter(p => p.team === 1)}
        maxSlots={battle.type === 'SOLO_1V1' ? 1 : 3}
        onJoin={() => joinBattle(battle.id, 1)}
      />
      
      {/* VS */}
      <div className="flex items-center justify-center">
        <span className="text-4xl font-bold">VS</span>
      </div>
      
      {/* Time 2 */}
      <TeamSlots
        team={2}
        participants={battle.participants.filter(p => p.team === 2)}
        maxSlots={battle.type === 'SOLO_1V1' ? 1 : 3}
        onJoin={() => joinBattle(battle.id, 2)}
      />
    </div>
  );
};

Tela de Batalha

tsx
const BattleArena = ({ battle }: { battle: Battle }) => {
  const [currentRound, setCurrentRound] = useState(1);
  const [roundResults, setRoundResults] = useState<RoundResult[]>([]);
  
  useBattleSocket({
    onRound: (data) => {
      setCurrentRound(data.roundNumber);
      setRoundResults(data.results);
    },
    onFinished: (data) => {
      // Mostrar tela de resultado
    },
  });
  
  return (
    <div className="battle-arena">
      {/* Roletas paralelas para cada participante */}
      <div className="grid grid-cols-2 gap-4">
        {battle.participants.map((participant) => (
          <ParticipantRoulette
            key={participant.id}
            participant={participant}
            result={roundResults.find(r => r.participantId === participant.id)}
          />
        ))}
      </div>
      
      {/* Placar */}
      <Scoreboard
        participants={battle.participants}
        currentRound={currentRound}
        totalRounds={battle.roundCount}
      />
    </div>
  );
};

Troubleshooting

Batalha Travada

typescript
// Verificar status
const battle = await battleRepository.findById(id);
console.log('Status:', battle.status);
console.log('Participants:', battle.participants.length);

// Forçar início se todos entraram
if (
  battle.status === 'WAITING' &&
  battle.participants.length >= getParticipantCount(battle.type)
) {
  await startBattle(battle.id);
}

Saldo Não Devolvido

typescript
// Verificar transações de entrada
const entries = await transactionRepository.findByReference(
  battleId,
  'BATTLE',
  TransactionType.BATTLE_ENTRY,
);

// Se batalha foi cancelada, verificar reversões
if (battle.status === 'CANCELLED') {
  const reversals = await transactionRepository.findByReference(
    battleId,
    'BATTLE',
    TransactionType.BATTLE_REFUND,
  );
  
  console.log('Entries:', entries.length);
  console.log('Reversals:', reversals.length);
}

Documentação Técnica CSGOFlip