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
| Modo | Jogadores | Times | Descrição |
|---|---|---|---|
| 1v1 | 2 | Solo | Clássico head-to-head |
| 3v3 | 6 | 2 times | Time 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_1V1Entrar na Batalha
http
POST /api/battles/:id/join
Authorization: Bearer {sessionId}
Content-Type: application/json
{
"team": 2
}Detalhes da Batalha
http
GET /api/battles/:idResponse:
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);
}