Skip to content

Battle Settlement - Sistema de Distribuicao Justa de Itens

Documentacao tecnica completa do sistema de Settlement do ProCases. Este sistema e responsavel por distribuir de forma justa e equilibrada todos os itens dropados durante uma batalha entre os vencedores.


Indice

  1. Visao Geral
  2. Modelo de Dados
  3. Algoritmo de Settlement - 5 Fases
  4. Fase 0: Determinacao do Vencedor
  5. Fase 1: Separacao de Itens que Excedem o Fair Share
  6. Fase 2: Distribuicao Greedy dos Itens
  7. Fase 3: Balanceamento - Remocao de Itens em Excesso
  8. Fase 4: Compensacao com Itens do Pool
  9. Fase 5: Persistencia, Inventario e WebSocket
  10. Interface do Repositorio
  11. Implementacao do Repositorio
  12. Eventos WebSocket
  13. DTOs e Tipos
  14. Frontend - Renderizacao do Settlement
  15. Exemplo Numerico Completo
  16. Regras de Negocio
  17. Casos de Borda
  18. Debugging e Troubleshooting
  19. Arquivos Relacionados

Visao Geral

Quando uma batalha termina no ProCases, todos os itens dropados durante todas as rodadas precisam ser distribuidos entre os vencedores. O sistema Settlement garante que cada vencedor receba um valor total em itens o mais proximo possivel do fair share (valor total / numero de vencedores).

O processo segue 5 fases sequenciais e deterministicas:

Fase 0: Determinar vencedor(es)
    |
    v
Fase 1: Separar itens com valor > fairShare (removidos para pool da casa)
    |
    v
Fase 2: Distribuir itens restantes via algoritmo greedy
    |
    v
Fase 3: Remover itens em excesso de jogadores acima do fairShare
    |
    v
Fase 4: Adicionar itens de compensacao do pool para jogadores abaixo do fairShare
    |
    v
Fase 5: Persistir no banco, adicionar ao inventario, emitir WebSocket

Modelo de Dados

Enum BattleSettlementType

Definido em prisma/schema.prisma:

prisma
enum BattleSettlementType {
  DROPPED           // Item dropado pelo jogador durante a batalha
  REMOVED_TO_POOL   // Item removido (valor > fairShare) - vai para casa
  ADDED_FROM_POOL   // Item adicionado do pool para compensar
}
TipoDescricaoQuem recebe
DROPPEDItem que foi dropado durante uma rodada da batalha e distribuido ao vencedorJogador vencedor
REMOVED_TO_POOLItem cujo valor excede o fairShare, removido para o pool da casaPool da casa (registrado com odUserId do jogador de quem foi removido)
ADDED_FROM_POOLItem buscado da tabela Item para compensar deficit de um vencedorJogador vencedor

Model BattleSettlement

Definido em prisma/schema.prisma:

prisma
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])
}

Campos explicados:

CampoTipoDescricao
idBigIntSnowflake ID gerado pelo SnowflakeService
battleIdBigIntID da batalha a que pertence este settlement
odUserIdBigIntID do usuario associado ao registro
itemIdBigIntID do item na tabela Item
typeBattleSettlementTypeTipo do settlement (DROPPED, REMOVED_TO_POOL, ADDED_FROM_POOL)
valueCentsBigIntValor do item em centavos de BRL (valueCentsBrl)
roundNumberInt?Numero da rodada em que o item foi dropado (null para ADDED_FROM_POOL)
createdAtDateTimeTimestamp de criacao do registro

Indices:

  • battleId - Busca rapida de todos os settlements de uma batalha
  • odUserId - Busca rapida de todos os settlements de um usuario
  • type - Filtragem por tipo de settlement

Relacoes:

  • battle - Referencia para a tabela Battle (cascade delete)
  • user - Referencia para a tabela User
  • item - Referencia para a tabela Item

Algoritmo de Settlement - 5 Fases

O algoritmo completo esta implementado no metodo settleBattle dentro de ExecuteBattleUseCase:

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

O metodo recebe dois parametros:

  1. battle: BattleWithRelations - A batalha completa com jogadores, caixas e rodadas
  2. playerData: Map<bigint, PlayerRoundData> - Mapa com dados acumulados de cada jogador

Fase 0: Determinacao do Vencedor

A primeira fase determina quem venceu a batalha com base no modo de jogo.

Calculo dos Totais por Time

O numero de times vem de getNumTeams(battle.type) (2 para os modos padrao, 3 para ONE_VS_ONE_VS_ONE, 4 para ONE_VS_ONE_VS_ONE_VS_ONE). Os totais sao acumulados por time num Map:

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);
}

Logica de Determinacao do Vencedor por Modo

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;

No modo NORMAL os times sao ordenados por valor decrescente (vence o de maior total); no FLIP, por valor crescente (vence o de menor total). Havendo empate no topo (dois ou mais times com o mesmo total), winnerTeam = 0 e isTie = true, e a distribuicao e dividida entre TODOS os jogadores (isSplitAll), sem aleatoriedade.

ModoRegra de VitoriaObservacao
NORMALTime com MAIOR valor total vencePadrao
FLIPTime com MENOR valor total venceLogica invertida
SHAREDTodos sao vencedoreswinnerTeam = 0
Empate no topoDistribuicao dividida entre TODOS os jogadoreswinnerTeam = 0, isTie = true (NORMAL e FLIP)

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);

Formula: fairSharePerWinner = totalItemsValue / numWinners

Onde totalItemsValue e a soma de valueCentsBrl de todos os itens dropados em todas as rodadas da batalha, independente de qual time dropou.


Fase 1: Separacao de Itens que Excedem o Fair Share

Nesta fase, todos os itens dropados sao coletados e ordenados por valor decrescente. Itens cujo valor excede o fairSharePerWinner sao removidos para o pool da casa.

Coleta de Todos os Itens Dropados

typescript
const allRounds = await this.prisma.battleRound.findMany({
  where: { battleId: battle.id },
  include: {
    item: {
      select: {
        id: true,
        name: true,
        valueCentsBrl: true,
        imageUrl: true,
        rarity: true,
      },
    },
    player: { select: { userId: true, team: true } },
  },
  orderBy: [{ roundNumber: 'asc' }, { playerId: 'asc' }],
});

Montagem da Lista de Itens para Distribuicao

typescript
const droppedItems = allRounds.map((r) => ({
  itemId: r.item.id,
  itemName: r.item.name,
  itemImage: r.item.imageUrl,
  itemRarity: r.item.rarity,
  valueCents: r.item.valueCentsBrl,
  roundNumber: r.roundNumber,
}));

droppedItems.sort((a, b) => Number(b.valueCents) - Number(a.valueCents));

A ordenacao por valor decrescente garante que itens mais caros sejam processados primeiro no algoritmo greedy da Fase 2.

Separacao: Pool vs Distribuiveis

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);
  }
}

Regra: Se item.valueCents > fairSharePerWinner, o item e removido para o pool. Um unico item que vale mais do que o fair share individual nao pode ser dado a nenhum jogador sem causar desequilibrio.


Fase 2: Distribuicao Greedy dos Itens

Os itens que passaram pelo filtro da Fase 1 sao distribuidos usando um algoritmo greedy: cada item e atribuido ao jogador que possui o menor valor total acumulado naquele momento.

Inicializacao das Alocacoes

typescript
const playerSettlements: Map<bigint, PlayerSettlement> = new Map();

for (const player of winnerPlayers) {
  playerSettlements.set(player.userId, {
    odUserId: player.userId,
    odUsername: player.user.username,
    odAvatarUrl: player.user.avatarUrl,
    team: player.team,
    items: [],
    totalValue: BigInt(0),
  });
}

Distribuicao Greedy

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;
}

Como funciona:

  1. Os itens ja estao ordenados por valor decrescente (Fase 1)
  2. Para cada item, percorre todos os vencedores
  3. Encontra o vencedor com menor valor total acumulado
  4. Atribui o item a esse vencedor
  5. Atualiza o totalValue do vencedor

Este algoritmo e equivalente ao "Longest Processing Time" (LPT) para particionamento de numeros, que e uma heuristica classica para distribuicao equilibrada.


Fase 3: Balanceamento - Remocao de Itens em Excesso

Apos a distribuicao greedy, alguns jogadores podem ter recebido um valor total superior ao fairSharePerWinner. Esta fase remove itens DROPPED desses jogadores para equilibrar a distribuicao.

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,
      });
    }
  }
}

Logica detalhada:

  1. Para cada jogador, verifica se totalValue > fairSharePerWinner
  2. Calcula o excesso: excess = totalValue - fairSharePerWinner
  3. Filtra apenas itens do tipo DROPPED (nunca remove ADDED_FROM_POOL)
  4. Ordena por valor crescente
  5. Filtra itens cujo valor cabe dentro do excesso (valueCents <= excess)
  6. Remove o maior item que cabe (ultimo do array filtrado)
  7. Move o item para itemsRemovedToPool
  8. Repete o loop ate que totalValue <= fairSharePerWinner ou nao haja mais itens removiveis

Regras criticas:

  • Somente itens do tipo DROPPED podem ser removidos nesta fase
  • Itens ADDED_FROM_POOL nunca sao removidos
  • O loop para se nenhum item cabe dentro do excesso restante
  • O loop para se nao ha mais itens DROPPED para remover

Fase 4: Compensacao com Itens do Pool

Jogadores que ficaram abaixo do fairSharePerWinner recebem itens de compensacao buscados da tabela Item do banco de dados.

Codigo de Compensacao

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;
    }
  }
}

Metodo findCompensationItems

Este metodo busca itens do pool que preencham o deficit do jogador:

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;
}

Logica detalhada:

  1. Busca TODOS os itens disponiveis no banco
  2. Filtra itens elegíveis: valueCentsBrl > BigInt(0) && valueCentsBrl <= targetValue + BigInt(1000) (exclui itens de valor zero; tolerancia de R$ 10,00)
  3. Ordena por valor decrescente (greedy packing: pega os maiores primeiro)
  4. Loop: enquanto remainingValue > 0 e menos de 200 itens foram adicionados:
    • Busca o maior item que cabe (valueCentsBrl <= remainingValue)
    • Adiciona ao array de compensacao
    • Subtrai do remainingValue
    • Se nenhum item cabe, para o loop
  5. Retorna os itens encontrados

Tolerancia: BigInt(1000) = R$ 10,00. Itens de compensacao podem exceder o deficit em ate R$ 10,00.

Caso sem itens elegíveis: Se nenhum item do pool atende ao criterio, o jogador fica sub-compensado. O sistema registra um WARNING no log mas nao bloqueia o settlement.


Fase 5: Persistencia, Inventario e WebSocket

5.1: Criacao dos Registros de Settlement

typescript
const settlementRecords: {
  battleId: bigint;
  odUserId: bigint;
  itemId: bigint;
  type: BattleSettlementType;
  valueCents: bigint;
  roundNumber?: number;
}[] = [];

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,
    });
  }
}

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);

Todos os registros sao criados atomicamente via createMany. O SnowflakeService gera IDs unicos para cada registro.

5.2: Adicao de Itens ao Inventario dos Vencedores

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',
      });
    }
  }
}

Regras de inventario:

  • Apenas itens do tipo DROPPED e ADDED_FROM_POOL sao adicionados ao inventario
  • Itens REMOVED_TO_POOL NAO sao adicionados (vao para o pool da casa)
  • Bots (usuarios com steamId comecando com 'BOT_') NAO recebem itens no inventario
  • Bots contam para o calculo do fairSharePerWinner mas os itens alocados a eles nao sao creditados
  • O status do item no inventario e 'AVAILABLE'
  • A fonte de aquisicao e 'Batalha'

5.3: Atualizacao do Profit 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,
    );
  }
}
JogadorCalculo do Profit
VencedorfinalValue - battle.totalValueCents (pode ser positivo ou negativo)
Perdedor-battle.totalValueCents (sempre negativo)

5.4: Finalizacao da Batalha

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');

5.5: Emissao dos Eventos WebSocket

typescript
this.webSocketGateway.emitBattleFinished(
  battle.id,
  winners.map((w) => {
    const settlement = playerSettlements.get(w.odUserId);
    const finalValue = settlement ? settlement.totalValue : BigInt(0);
    return {
      odUserId: w.odUserId.toString(),
      odUsername: w.odUsername,
      odAvatarUrl: w.odAvatarUrl || undefined,
      team: w.team,
      totalWonCents: finalValue.toString(),
    };
  }),
  totalItemsValue.toString(),
  settlementData,
);

5.6: Montagem do settlementData para WebSocket

O settlementData e um Record<string, any[]> onde a chave e o odUserId (string) e o valor e um array de itens com seus tipos:

typescript
const settlementData: Record<string, any[]> = {};

for (const [odUserId, settlement] of playerSettlements) {
  const playerItems = settlement.items.map((item) => ({
    itemId: item.itemId.toString(),
    itemName: item.itemName,
    itemImage: item.itemImage,
    itemRarity: item.itemRarity,
    valueCents: item.valueCents.toString(),
    type: item.type,
  }));

  const removedFromPlayer = itemsRemovedToPool
    .filter((removed) => removed.removedFromUserId === odUserId)
    .map((item) => ({
      itemId: item.itemId.toString(),
      itemName: item.itemName,
      itemImage: item.itemImage,
      itemRarity: item.itemRarity,
      valueCents: item.valueCents.toString(),
      type: BattleSettlementType.REMOVED_TO_POOL,
    }));

  settlementData[odUserId.toString()] = [
    ...playerItems,
    ...removedFromPlayer,
  ];
}

Cada jogador vencedor recebe no seu array:

  • Itens DROPPED que lhe foram atribuidos
  • Itens ADDED_FROM_POOL que recebeu como compensacao
  • Itens REMOVED_TO_POOL que foram removidos dele (para exibicao riscada no frontend)

Interface do Repositorio

Arquivo: src/domain/repositories/battle-settlement.repository.interface.ts

typescript
import { BattleSettlement, BattleSettlementType } from '@prisma/client';

export interface CreateBattleSettlementData {
  battleId: bigint;
  odUserId: bigint;
  itemId: bigint;
  type: BattleSettlementType;
  valueCents: bigint;
  roundNumber?: number;
}

export interface BattleSettlementWithItem extends BattleSettlement {
  item: {
    id: bigint;
    name: string;
    imageUrl: string;
    rarity: string;
    valueCents: bigint;
    valueCentsBrl: bigint;
  };
}

export interface IBattleSettlementRepository {
  create(data: CreateBattleSettlementData): Promise<BattleSettlement>;

  createMany(data: CreateBattleSettlementData[]): Promise<number>;

  findByBattleId(battleId: bigint): Promise<BattleSettlementWithItem[]>;

  findByBattleIdAndUserId(
    battleId: bigint,
    userId: bigint,
  ): Promise<BattleSettlementWithItem[]>;

  findByBattleIdGroupedByUser(
    battleId: bigint,
  ): Promise<Map<bigint, BattleSettlementWithItem[]>>;

  getSettlementSummary(battleId: bigint): Promise<
    {
      userId: bigint;
      totalDropped: bigint;
      totalRemoved: bigint;
      totalAdded: bigint;
      netValue: bigint;
    }[]
  >;
}

Descricao dos Metodos

MetodoDescricaoRetorno
createCria um unico registro de settlementBattleSettlement
createManyCria multiplos registros atomicamentenumber (quantidade criada)
findByBattleIdBusca todos os settlements de uma batalha com dados do itemBattleSettlementWithItem[]
findByBattleIdAndUserIdBusca settlements de um usuario especifico em uma batalhaBattleSettlementWithItem[]
findByBattleIdGroupedByUserAgrupa settlements por usuarioMap<bigint, BattleSettlementWithItem[]>
getSettlementSummaryRetorna resumo com totais por tipo para cada usuarioArray de summaries

Implementacao do Repositorio

Arquivo: src/infrastructure/database/repositories/battle-settlement.repository.ts

typescript
@Injectable()
export class BattleSettlementRepository implements IBattleSettlementRepository {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly snowflake: SnowflakeService,
  ) {}

  async create(data: CreateBattleSettlementData) {
    return this.prisma.battleSettlement.create({
      data: {
        id: this.snowflake.generate(),
        battleId: data.battleId,
        odUserId: data.odUserId,
        itemId: data.itemId,
        type: data.type,
        valueCents: data.valueCents,
        roundNumber: data.roundNumber,
      },
    });
  }

  async createMany(data: CreateBattleSettlementData[]): Promise<number> {
    const result = await this.prisma.battleSettlement.createMany({
      data: data.map((d) => ({
        id: this.snowflake.generate(),
        battleId: d.battleId,
        odUserId: d.odUserId,
        itemId: d.itemId,
        type: d.type,
        valueCents: d.valueCents,
        roundNumber: d.roundNumber,
      })),
    });
    return result.count;
  }

  async findByBattleId(
    battleId: bigint,
  ): Promise<BattleSettlementWithItem[]> {
    return this.prisma.battleSettlement.findMany({
      where: { battleId },
      include: {
        item: {
          select: {
            id: true,
            name: true,
            imageUrl: true,
            rarity: true,
            valueCents: true,
            valueCentsBrl: true,
          },
        },
      },
      orderBy: [
        { odUserId: 'asc' },
        { type: 'asc' },
        { createdAt: 'asc' },
      ],
    }) as Promise<BattleSettlementWithItem[]>;
  }

  async findByBattleIdAndUserId(
    battleId: bigint,
    userId: bigint,
  ): Promise<BattleSettlementWithItem[]> {
    return this.prisma.battleSettlement.findMany({
      where: { battleId, odUserId: userId },
      include: {
        item: {
          select: {
            id: true,
            name: true,
            imageUrl: true,
            rarity: true,
            valueCents: true,
            valueCentsBrl: true,
          },
        },
      },
      orderBy: [{ type: 'asc' }, { createdAt: 'asc' }],
    }) as Promise<BattleSettlementWithItem[]>;
  }

  async findByBattleIdGroupedByUser(
    battleId: bigint,
  ): Promise<Map<bigint, BattleSettlementWithItem[]>> {
    const settlements = await this.findByBattleId(battleId);
    const grouped = new Map<bigint, BattleSettlementWithItem[]>();

    for (const settlement of settlements) {
      const userId = settlement.odUserId;
      if (!grouped.has(userId)) {
        grouped.set(userId, []);
      }
      grouped.get(userId)!.push(settlement);
    }

    return grouped;
  }

  async getSettlementSummary(battleId: bigint): Promise<
    {
      userId: bigint;
      totalDropped: bigint;
      totalRemoved: bigint;
      totalAdded: bigint;
      netValue: bigint;
    }[]
  > {
    const settlements = await this.findByBattleId(battleId);
    const summaryMap = new Map<
      bigint,
      {
        totalDropped: bigint;
        totalRemoved: bigint;
        totalAdded: bigint;
      }
    >();

    for (const settlement of settlements) {
      const userId = settlement.odUserId;
      if (!summaryMap.has(userId)) {
        summaryMap.set(userId, {
          totalDropped: BigInt(0),
          totalRemoved: BigInt(0),
          totalAdded: BigInt(0),
        });
      }

      const summary = summaryMap.get(userId)!;
      switch (settlement.type) {
        case BattleSettlementType.DROPPED:
          summary.totalDropped += settlement.valueCents;
          break;
        case BattleSettlementType.REMOVED_TO_POOL:
          summary.totalRemoved += settlement.valueCents;
          break;
        case BattleSettlementType.ADDED_FROM_POOL:
          summary.totalAdded += settlement.valueCents;
          break;
      }
    }

    return Array.from(summaryMap.entries()).map(([userId, summary]) => ({
      userId,
      totalDropped: summary.totalDropped,
      totalRemoved: summary.totalRemoved,
      totalAdded: summary.totalAdded,
      netValue:
        summary.totalDropped - summary.totalRemoved + summary.totalAdded,
    }));
  }
}

Formula do netValue

netValue = totalDropped - totalRemoved + totalAdded

Onde:

  • totalDropped: soma dos itens DROPPED atribuidos ao jogador
  • totalRemoved: soma dos itens REMOVED_TO_POOL que pertenciam ao jogador
  • totalAdded: soma dos itens ADDED_FROM_POOL que o jogador recebeu

Eventos WebSocket

Evento: battle:finished

Escopo: Room battle:{battleId} (apenas quem esta na sala da batalha)

Emitido por: WebSocketGatewayService.emitBattleFinished()

Arquivo: src/infrastructure/websocket/websocket.gateway.ts

typescript
emitBattleFinished(
  battleId: bigint,
  winners: {
    odUserId: string;
    odUsername: string;
    odAvatarUrl?: string;
    team: number;
    totalWonCents: string;
  }[],
  totalPotCents: string,
  settlementData?: Record<
    string,
    {
      itemId: string;
      itemName: string;
      itemImage: string;
      itemRarity: string;
      valueCents: string;
      type: string;
    }[]
  >,
) {
  const roomName = `battle:${battleId}`;
  this.server.to(roomName).emit('battle:finished', {
    battleId: battleId.toString(),
    winners,
    totalPotCents,
    settlementData,
  });
  this.server.emit('battle:completed', {
    battleId: battleId.toString(),
    winners,
    totalPotCents,
    settlementData,
  });
}

Payload do battle:finished:

typescript
{
  battleId: string;           // ID da batalha
  winners: {
    odUserId: string;         // ID do jogador vencedor
    odUsername: string;        // Username do vencedor
    odAvatarUrl?: string;     // URL do avatar (opcional)
    team: number;             // Numero do time (0 para SHARED)
    totalWonCents: string;    // Valor total ganho em centavos BRL (string)
  }[];
  totalPotCents: string;      // Valor total de todos os itens da batalha (string)
  settlementData: {           // Mapa userId -> array de itens
    [userId: string]: {
      itemId: string;
      itemName: string;
      itemImage: string;
      itemRarity: string;
      valueCents: string;     // Centavos BRL (string)
      type: 'DROPPED' | 'REMOVED_TO_POOL' | 'ADDED_FROM_POOL';
    }[];
  };
}

Evento: battle:completed

Escopo: Broadcast global (todos os clientes conectados)

Payload: Identico ao battle:finished.

Este evento e emitido globalmente para atualizar listagens de batalhas em outras paginas.

Evento: battle:updated

Escopo: Broadcast global

Emitido apos: O settlement terminar, para atualizar o status da batalha nas listagens.

typescript
this.webSocketGateway.emitBattleUpdated({
  id: battle.id.toString(),
  status: 'FINISHED',
  playersCount: battle.players.length,
  maxPlayers,
});

DTOs e Tipos

BattleSettlementItemDto

Arquivo: src/application/dto/battle.dto.ts

typescript
export class BattleSettlementItemDto {
  itemId: string;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  valueCents: string;
  type: string;
}

BattleWinnerDto

typescript
export class BattleWinnerDto {
  odUserId: string;
  odUsername: string;
  odAvatarUrl?: string;
  team: number;
  totalWonCents: string;
}

BattleResponseDto (trecho relevante)

typescript
export class BattleResponseDto {
  // ... outros campos
  winners?: BattleWinnerDto[];
  settlementData?: Record<string, BattleSettlementItemDto[]>;
}

Interfaces Internas do Use Case

typescript
interface SettlementItem {
  itemId: bigint;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  valueCents: bigint;
  type: BattleSettlementType;
  roundNumber?: number;
}

interface PlayerSettlement {
  odUserId: bigint;
  odUsername: string;
  odAvatarUrl: string | null;
  team: number;
  items: SettlementItem[];
  totalValue: bigint;
}

Frontend - Renderizacao do Settlement

Hook: useBattleSocket

Arquivo: client/app/lib/use-battle-socket.tsx

O hook useBattleSocket recebe o evento battle:finished e extrai os dados de settlement:

typescript
export interface BattleSettlementItem {
  itemId: string;
  itemName: string;
  itemImage: string;
  itemRarity: string;
  valueCents: string;
  type: 'DROPPED' | 'REMOVED_TO_POOL' | 'ADDED_FROM_POOL';
}

export interface BattleSocketEvents {
  onBattleFinished?: (
    winners: BattleWinner[],
    totalPotCents: string,
    settlementData?: Record<string, BattleSettlementItem[]>,
  ) => void;
}

Pagina da Batalha - Estado

Arquivo: client/app/batalha/[id]/page.tsx

typescript
const [settlementData, setSettlementData] = useState<
  Record<string, BattleSettlementItem[]>
>({});
const [winnerPlayers, setWinnerPlayers] = useState<BattleWinner[]>([]);

O estado e preenchido de tres formas:

  1. Via evento WebSocket battle:finished (em tempo real)
  2. Via fetch da API quando a batalha ja esta finalizada (reload da pagina)
  3. Via battleData.settlementData ao carregar estado salvo

Renderizacao da Distribuicao de Itens

A secao de distribuicao so aparece quando Object.keys(settlementData).length > 0:

tsx
{Object.keys(settlementData).length > 0 && (
  <motion.div
    initial={{ opacity: 0, y: 20 }}
    animate={{ opacity: 1, y: 0 }}
    transition={{ delay: 0.5 }}
    className="w-full mt-6"
  >
    <h3 className="text-sm font-semibold text-white/80 mb-4 text-center">
      Distribuicao de Itens
    </h3>
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {winnerPlayers.map((winnerPlayer) => {
        const playerItems = settlementData[winnerPlayer.odUserId] || [];
        const droppedItems = playerItems.filter(
          (item) => item.type === 'DROPPED',
        );
        const addedItems = playerItems.filter(
          (item) => item.type === 'ADDED_FROM_POOL',
        );
        const removedItems = playerItems.filter(
          (item) => item.type === 'REMOVED_TO_POOL',
        );

        return (
          <div key={winnerPlayer.odUserId} className="bg-[#020816] rounded-lg p-3">
            {/* Card do jogador com itens */}
          </div>
        );
      })}
    </div>
  </motion.div>
)}

Estilos por Tipo de Settlement

Itens DROPPED (normais):

tsx
{droppedItems.map((item, idx) => (
  <div
    key={`dropped-${idx}`}
    className="flex items-center gap-2 p-2 rounded-md"
    style={{
      background: getRarityGradient(item.itemRarity),
      borderLeft: `3px solid ${getRarityColor(item.itemRarity)}`,
    }}
  >
    <img
      src={item.itemImage}
      alt={item.itemName}
      className="w-10 h-10 object-contain"
    />
    <div className="flex-1 min-w-0">
      <p className="text-xs font-medium text-white truncate">
        {item.itemName}
      </p>
      <p className="text-[10px] text-white/60">
        R$ {formatBRL(item.valueCents)}
      </p>
    </div>
  </div>
))}
  • Sem indicadores visuais especiais
  • Borda esquerda colorida pela raridade do item
  • Background com gradiente da raridade

Itens ADDED_FROM_POOL (compensacao):

tsx
{addedItems.map((item, idx) => (
  <div
    key={`added-${idx}`}
    className="flex items-center gap-2 p-2 rounded-md relative"
    style={{
      background: getRarityGradient(item.itemRarity),
      borderLeft: `3px solid ${getRarityColor(item.itemRarity)}`,
    }}
  >
    <div className="absolute top-1 right-1 z-10">
      <Tooltip content="Item adicionado para equilibrar a distribuicao entre os jogadores">
        <div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center cursor-help">
          <span className="text-[8px] font-bold text-green-400">i</span>
        </div>
      </Tooltip>
    </div>
    <img src={item.itemImage} alt={item.itemName} className="w-10 h-10 object-contain" />
    <div className="flex-1 min-w-0">
      <p className="text-xs font-medium text-white truncate">{item.itemName}</p>
      <p className="text-[10px] text-green-400">+ R$ {formatBRL(item.valueCents)}</p>
    </div>
  </div>
))}
  • Icone verde "i" no canto superior direito com tooltip explicativo
  • Valor em verde com prefixo "+"
  • Tooltip: "Item adicionado para equilibrar a distribuicao entre os jogadores"

Itens REMOVED_TO_POOL (removidos):

tsx
{removedItems.map((item, idx) => (
  <div
    key={`removed-${idx}`}
    className="flex items-center gap-2 p-2 rounded-md opacity-60 relative"
    style={{
      background: getRarityGradient(item.itemRarity),
      borderLeft: `3px solid ${getRarityColor(item.itemRarity)}`,
    }}
  >
    <div className="absolute top-1 right-1 z-10">
      <Tooltip content="Item removido para equilibrar a distribuicao entre os jogadores">
        <div className="w-4 h-4 rounded-full bg-red-500/20 flex items-center justify-center cursor-help">
          <span className="text-[8px] font-bold text-red-400">i</span>
        </div>
      </Tooltip>
    </div>
    <img src={item.itemImage} alt={item.itemName} className="w-10 h-10 object-contain" />
    <div className="flex-1 min-w-0">
      <p className="text-xs font-medium text-white/60 truncate">{item.itemName}</p>
      <p className="text-[10px] text-red-400">- R$ {formatBRL(item.valueCents)}</p>
    </div>
  </div>
))}
  • Opacidade reduzida (opacity-60)
  • Icone vermelho "i" no canto superior direito com tooltip
  • Valor em vermelho com prefixo "-"
  • Nome do item em cor mais tenue (text-white/60)
  • Tooltip: "Item removido para equilibrar a distribuicao entre os jogadores"

Rodape com Total por Jogador

tsx
<div className="mt-3 pt-2 border-t border-white/10">
  <div className="flex justify-between text-xs">
    <span className="text-white/60">Total:</span>
    <span className="font-semibold text-white">
      R$ {formatBRL(winnerPlayer.totalWonCents)}
    </span>
  </div>
</div>

O total exibido e o totalWonCents do BattleWinnerDto, que corresponde ao totalValue final do PlayerSettlement (soma de DROPPED + ADDED_FROM_POOL, sem os REMOVED_TO_POOL).


Exemplo Numerico Completo

Cenario

  • Tipo: 3v3 (THREE_VS_THREE)
  • Modo: NORMAL
  • Time 1 (vencedor): Player A, Player B, Player C
  • Time 2 (perdedor): Player D, Player E, Player F
  • Caixas: 3 caixas

Itens Dropados (Todas as Rodadas)

RodadaTimeJogadorItemValor (BRL)
11Player AAWP Dragon LoreR$ 150,00
11Player BAK-47 RedlineR$ 50,00
11Player CM4A4 HowlR$ 60,00
12Player DGlock FadeR$ 40,00
21Player AUSP Kill ConfirmedR$ 30,00
21Player BP250 Sand DuneR$ 10,00
21Player CDesert Eagle BlazeR$ 35,00
22Player EFive-SeveN Case HardenedR$ 25,00

Total dropado por time:

  • Time 1: R$ 150 + R$ 50 + R$ 60 + R$ 30 + R$ 10 + R$ 35 = R$ 335
  • Time 2: R$ 40 + R$ 25 = R$ 65

Modo NORMAL: Time 1 vence (R$ 335 > R$ 65)

Fase 0: Calculo do Fair Share

totalItemsValue = R$ 335 + R$ 65 = R$ 400,00
numWinners = 3
fairSharePerWinner = R$ 400 / 3 = R$ 133,33 (BigInt: 40000 / 3 = 13333)

Nota: BigInt trunca a divisao, entao fairSharePerWinner = 13333 centavos = R$ 133,33

Fase 1: Separar Itens > Fair Share

Itens ordenados por valor decrescente:

ItemValor> R$ 133,33?Destino
AWP Dragon LoreR$ 150,00SimREMOVED_TO_POOL
M4A4 HowlR$ 60,00NaoDistribuivel
AK-47 RedlineR$ 50,00NaoDistribuivel
Glock FadeR$ 40,00NaoDistribuivel
Desert Eagle BlazeR$ 35,00NaoDistribuivel
USP Kill ConfirmedR$ 30,00NaoDistribuivel
Five-SeveN Case HardenedR$ 25,00NaoDistribuivel
P250 Sand DuneR$ 10,00NaoDistribuivel

Resultado: AWP Dragon Lore (R$ 150,00) removida para pool da casa.

Fase 2: Distribuicao Greedy

Itens distribuiveis (ja ordenados por valor decrescente): M4A4 Howl (R$60), AK-47 Redline (R$50), Glock Fade (R$40), Desert Eagle Blaze (R$35), USP Kill Confirmed (R$30), Five-SeveN Case Hardened (R$25), P250 Sand Dune (R$10).

PassoItemValorPlayer APlayer BPlayer CQuem recebe
1M4A4 HowlR$ 60R$ 0R$ 0R$ 0Player A (todos empatam, pega primeiro)
2AK-47 RedlineR$ 50R$ 60R$ 0R$ 0Player B (menor valor: R$ 0)
3Glock FadeR$ 40R$ 60R$ 50R$ 0Player C (menor valor: R$ 0)
4Desert Eagle BlazeR$ 35R$ 60R$ 50R$ 40Player C (menor valor: R$ 40)
5USP Kill ConfirmedR$ 30R$ 60R$ 50R$ 75Player B (menor valor: R$ 50)
6Five-SeveNR$ 25R$ 60R$ 80R$ 75Player A (menor valor: R$ 60)
7P250 Sand DuneR$ 10R$ 85R$ 80R$ 75Player C (menor valor: R$ 75)

Apos distribuicao:

JogadorItensTotalDiferenca do Fair Share
Player AM4A4 Howl (R$60) + Five-SeveN (R$25)R$ 85,00-R$ 48,33
Player BAK-47 Redline (R$50) + USP Kill Confirmed (R$30)R$ 80,00-R$ 53,33
Player CGlock Fade (R$40) + Desert Eagle Blaze (R$35) + P250 Sand Dune (R$10)R$ 85,00-R$ 48,33

Fase 3: Balanceamento

Nenhum jogador excede o fairShare (R$ 133,33), entao nenhum item e removido nesta fase.

Fase 4: Compensacao

Todos os jogadores tem deficit:

JogadorDeficitCompensacao Buscada
Player AR$ 48,33Busca item com valor <= R$ 48,33
Player BR$ 53,33Busca item com valor <= R$ 53,33
Player CR$ 48,33Busca item com valor <= R$ 48,33

Supondo que o pool contenha:

  • Item X: R$ 48,00
  • Item Y: R$ 52,00
  • Item Z: R$ 47,50
JogadorItem CompensacaoValorTotal Final
Player AItem XR$ 48,00R$ 133,00
Player BItem YR$ 52,00R$ 132,00
Player CItem ZR$ 47,50R$ 132,50

Registros de Settlement no Banco

json
[
  { "type": "REMOVED_TO_POOL", "itemName": "AWP Dragon Lore", "valueCents": 15000, "odUserId": "playerA" },
  { "type": "DROPPED", "itemName": "M4A4 Howl", "valueCents": 6000, "odUserId": "playerA", "roundNumber": 1 },
  { "type": "DROPPED", "itemName": "Five-SeveN Case Hardened", "valueCents": 2500, "odUserId": "playerA", "roundNumber": 2 },
  { "type": "ADDED_FROM_POOL", "itemName": "Item X", "valueCents": 4800, "odUserId": "playerA" },
  { "type": "DROPPED", "itemName": "AK-47 Redline", "valueCents": 5000, "odUserId": "playerB", "roundNumber": 1 },
  { "type": "DROPPED", "itemName": "USP Kill Confirmed", "valueCents": 3000, "odUserId": "playerB", "roundNumber": 2 },
  { "type": "ADDED_FROM_POOL", "itemName": "Item Y", "valueCents": 5200, "odUserId": "playerB" },
  { "type": "DROPPED", "itemName": "Glock Fade", "valueCents": 4000, "odUserId": "playerC", "roundNumber": 1 },
  { "type": "DROPPED", "itemName": "Desert Eagle Blaze", "valueCents": 3500, "odUserId": "playerC", "roundNumber": 2 },
  { "type": "DROPPED", "itemName": "P250 Sand Dune", "valueCents": 1000, "odUserId": "playerC", "roundNumber": 2 },
  { "type": "ADDED_FROM_POOL", "itemName": "Item Z", "valueCents": 4750, "odUserId": "playerC" }
]

Regras de Negocio

Regras Financeiras

  1. SEMPRE usar valueCentsBrl (BRL) para todos os calculos de settlement. NUNCA usar valueCents (USD).
  2. Fair share: totalItemsValue / numWinners onde totalItemsValue e a soma de TODOS os itens dropados em TODAS as rodadas, de TODOS os times.
  3. Itens com valor > fairShare DEVEM ser removidos para o pool da casa na Fase 1.
  4. NUNCA debitar ou creditar saldo monetario. O settlement trabalha exclusivamente com itens.
  5. Valores sao BigInt em centavos. Toda aritmetica e feita com BigInt para evitar imprecisao de ponto flutuante.
  6. Divisao BigInt trunca (nao arredonda). BigInt(40000) / BigInt(3) = BigInt(13333).

Regras do Algoritmo Greedy

  1. Proximo item vai para o jogador com MENOR valor acumulado. Em caso de empate, o primeiro da lista winnerUserIds recebe.
  2. Itens sao processados do MAIOR para o MENOR valor. Isso e fundamental para a qualidade da distribuicao.
  3. Somente itens DROPPED podem ser removidos na fase de balanceamento (Fase 3). Itens ADDED_FROM_POOL nunca sao removidos.
  4. Na remocao de balanceamento, o maior item DROPPED que cabe no excesso e removido primeiro.

Regras de Compensacao

  1. Tolerancia de R$ 10,00 (BigInt(1000) centavos). Itens de compensacao podem ter valor ate R$ 10,00 acima do deficit.
  2. Greedy packing: itens maiores sao selecionados primeiro para preencher o deficit.
  3. Se nao ha itens elegíveis no pool, o jogador fica sub-compensado. O sistema NAO bloqueia por isso.
  4. Itens de compensacao vem da tabela Item (pool global do sistema).

Regras de Bots

  1. Bots sao identificados por steamId.startsWith('BOT_').
  2. Bots contam no calculo do fairShare (dividem o total por numWinners que inclui bots).
  3. Bots NAO recebem itens no inventario. Os itens alocados a bots efetivamente "desaparecem" (ficam para a casa).

Regras de Persistencia

  1. Settlement records sao imutaveis. Uma vez criados, nunca sao alterados ou deletados (exceto cascade delete da batalha).
  2. Todos os registros sao criados atomicamente via createMany.
  3. IDs sao Snowflake IDs gerados pelo SnowflakeService.

Regras de Modo de Jogo

  1. NORMAL: time com MAIOR valor total ganha.
  2. FLIP: time com MENOR valor total ganha (logica invertida).
  3. SHARED: todos os jogadores sao vencedores (winnerTeam = 0).
  4. Empate: quando dois ou mais times empatam no topo, winnerTeam = 0 e a distribuicao e dividida entre TODOS os jogadores (mesmo comportamento do modo SHARED, sem aleatoriedade).

Casos de Borda

Vencedor Unico (1v1)

Quando ha apenas 1 vencedor:

  • fairSharePerWinner = totalItemsValue (100% do total)
  • Nenhum item excede o fairShare (exceto se um item valer mais que o total, o que e impossivel)
  • O vencedor recebe todos os itens
  • Nenhuma compensacao e necessaria

Todos os Itens Excedem o Fair Share

Se todos os itens valem mais que o fairShare:

  • Todos sao removidos para o pool (REMOVED_TO_POOL)
  • distributableItems fica vazio
  • Todos os jogadores ficam com totalValue = 0
  • Todos precisam de compensacao total do pool
  • Se o pool nao tem itens suficientes, os jogadores ficam sem itens

Sem Itens Elegíveis no Pool

Se findCompensationItems retorna array vazio:

  • O jogador fica sub-compensado
  • Seu totalValue final sera menor que o fairSharePerWinner
  • O sistema registra um WARNING no log
  • O settlement NAO e bloqueado

Empate Exato entre Times

Se dois ou mais times empatam no maior total (NORMAL) ou menor total (FLIP):

  • winnerTeam = 0 e isTie = true
  • A distribuicao e dividida entre TODOS os jogadores (isSplitAll = isShared || isTie)
  • O settlement e totalmente deterministico (nao ha nenhum ponto de aleatoriedade)

Modo SHARED

Em modo SHARED:

  • winnerTeam = 0
  • winnerPlayers = battle.players (todos)
  • numWinners = battle.players.length
  • O fairShare e dividido entre TODOS os jogadores, nao apenas um time
  • A logica de settlement e identica, mas com mais vencedores e fairShare menor

Multiplos Itens Removidos no Balanceamento (Fase 3)

O loop da Fase 3 continua removendo itens enquanto:

  1. settlement.totalValue > fairSharePerWinner
  2. Existem itens DROPPED na alocacao do jogador
  3. Existe pelo menos um item DROPPED com valor <= excesso

Isso significa que um jogador pode ter multiplos itens removidos em uma unica iteracao da Fase 3.

Item de Compensacao Excede o Deficit

Se o melhor item de compensacao disponivel tem valor maior que o deficit (mas dentro da tolerancia de R$ 10):

  • O item e aceito
  • O jogador pode terminar com totalValue ligeiramente acima do fairSharePerWinner
  • Isso e intencional para evitar que jogadores fiquem muito abaixo do fair share

Batalha com Apenas Bots Vencendo

Se todos os vencedores sao bots:

  • O settlement e calculado normalmente
  • Os itens sao alocados aos bots no calculo
  • Nenhum item e adicionado a nenhum inventario
  • Efetivamente, todos os itens ficam para a casa

Debugging e Troubleshooting

Logs do Backend

O ExecuteBattleUseCase possui logs detalhados em cada fase:

============================================================
[Battle 123] SETTLEMENT - Starting
============================================================
[Battle 123] Team 1 Total: R$ 335.00
[Battle 123] Team 2 Total: R$ 65.00
[Battle 123] Winner Team: 1 (Mode: NORMAL)

[Battle 123] All items dropped (8 total):
  Round 1 | Team 1 | AWP Dragon Lore | R$ 150.00
  Round 1 | Team 1 | AK-47 Redline | R$ 50.00
  ...

[Battle 123] Winners (3): Player A, Player B, Player C

[Battle 123] Fair Distribution Calculation:
  Total Items Value: R$ 400.00
  Number of Winners: 3
  Fair Share Each: R$ 133.33

[Battle 123] STEP 1: Separating items > fairShare to pool...
  AWP Dragon Lore (R$ 150.00) -> POOL (exceeds fair share of R$ 133.33)

[Battle 123] STEP 2: Distributing 7 items...
  M4A4 Howl (R$ 60.00) -> Player A
  AK-47 Redline (R$ 50.00) -> Player B
  ...

[Battle 123] After initial distribution:
  Player A: R$ 85.00 (-48.33)
  Player B: R$ 80.00 (-53.33)
  Player C: R$ 85.00 (-48.33)

[Battle 123] STEP 3: Balancing - removing excess items...

[Battle 123] After removing excess items:
  Player A: R$ 85.00 (-48.33)
  ...

[Battle 123] STEP 3: Adding items from pool to players below fair share...
  Player A needs R$ 48.33 more
    [findCompensationItems] Looking for items to cover R$ 48.33
    [findCompensationItems] Found 150 eligible items (of 500 total)
    [findCompensationItems] Total: 1 items = R$ 48.00 (target was R$ 48.33)
    Added 1 items, total now: R$ 133.00
  ...

[Battle 123] Final distribution:
  Player A: R$ 133.00 (2 dropped, 1 added)
  Player B: R$ 132.00 (2 dropped, 1 added)
  Player C: R$ 132.50 (3 dropped, 1 added)

[Battle 123] Saving settlements to database...
[Battle 123] Saved 11 settlement records

[Battle 123] Adding items to player inventories...
[Battle 123] Added 3 items to Player A's inventory
[Battle 123] Added 3 items to Player B's inventory
[Battle 123] Added 4 items to Player C's inventory

------------------------------------------------------------
[Battle 123] FINAL SUMMARY:
  Total Items Value: R$ 400.00
  Fair Share Per Winner: R$ 133.33
  Winners:
    Player A: R$ 133.00 total (2 dropped + 1 from pool)
    Player B: R$ 132.00 total (2 dropped + 1 from pool)
    Player C: R$ 132.50 total (3 dropped + 1 from pool)
  Items Removed to Pool: 1
============================================================

Logs do Frontend

typescript
console.log('[Battle] Battle finished. Winners:', winners);
console.log('[Battle] Settlement data:', settlement);
console.log('[BattleSocket] Settlement data:', data.settlementData);

Verificar Distribuicao Manualmente

Para verificar se uma batalha teve settlement correto:

typescript
const settlements = await battleSettlementRepository.findByBattleId(battleId);

const byUser = new Map<bigint, bigint>();
for (const s of settlements) {
  if (s.type !== 'REMOVED_TO_POOL') {
    const current = byUser.get(s.odUserId) || BigInt(0);
    byUser.set(s.odUserId, current + s.valueCents);
  }
}

for (const [userId, total] of byUser) {
  console.log(`User ${userId}: R$ ${(Number(total) / 100).toFixed(2)}`);
}

Verificar Resumo via Repository

typescript
const summary = await battleSettlementRepository.getSettlementSummary(battleId);

for (const entry of summary) {
  console.log(`User ${entry.userId}:`);
  console.log(`  Dropped: R$ ${(Number(entry.totalDropped) / 100).toFixed(2)}`);
  console.log(`  Removed: R$ ${(Number(entry.totalRemoved) / 100).toFixed(2)}`);
  console.log(`  Added:   R$ ${(Number(entry.totalAdded) / 100).toFixed(2)}`);
  console.log(`  Net:     R$ ${(Number(entry.netValue) / 100).toFixed(2)}`);
}

Problemas Comuns

ProblemaCausa ProvavelSolucao
Distribuicao muito desigualItem de compensacao nao encontrado no poolVerificar se ha itens com valor adequado na tabela Item
Jogador sem itensTodos os itens excediam o fairShareVerificar o fairShare vs valores dos itens
Settlement nao aparece no frontendsettlementData nao emitido via WebSocketVerificar logs do emitBattleFinished
Itens duplicados no inventarioBug no loop de criacao de inventarioVerificar unicidade via findByBattleIdAndUserId
Bot recebendo itensVerificacao de steamId.startsWith('BOT_') falhandoVerificar steamId do bot no banco
Valores em USD no settlementUsando valueCents ao inves de valueCentsBrlVerificar se allRounds usa valueCentsBrl

Arquivos Relacionados

Backend

ArquivoDescricao
prisma/schema.prismaModel BattleSettlement e enum BattleSettlementType
src/domain/repositories/battle-settlement.repository.interface.tsInterface do repositorio
src/infrastructure/database/repositories/battle-settlement.repository.tsImplementacao Prisma do repositorio
src/application/use-cases/battle/execute-battle.use-case.tsLogica completa do settlement (metodo settleBattle)
src/infrastructure/websocket/websocket.gateway.tsEmissao de battle:finished e battle:completed
src/application/dto/battle.dto.tsDTOs BattleSettlementItemDto, BattleWinnerDto, BattleResponseDto
src/presentation/modules/battle.module.tsModulo que registra o repositorio via DI
src/application/use-cases/admin/get-battle-rtp.use-case.tsUsa settlement para calcular RTP de batalhas
src/application/use-cases/admin/get-ggr.use-case.tsUsa settlement para calcular GGR
src/application/use-cases/admin/get-ggr-by-user.use-case.tsUsa settlement para GGR por usuario
src/application/use-cases/admin/get-overview-stats.use-case.tsUsa settlement para estatisticas gerais

Frontend

ArquivoDescricao
client/app/lib/use-battle-socket.tsxHook com interface BattleSettlementItem e handler de battle:finished
client/app/batalha/[id]/page.tsxPagina da batalha com renderizacao do settlement
client/app/lib/api.tsFuncoes de API que retornam settlementData

Fluxo Completo de Dados

ExecuteBattleUseCase.settleBattle()
  |
  v
BattleSettlementRepository.createMany()  -->  PostgreSQL (battle_settlements)
  |
  v
UserInventoryRepository.create()         -->  PostgreSQL (user_inventory)
  |
  v
WebSocketGatewayService.emitBattleFinished()
  |
  +--> battle:finished (room battle:{id})  -->  useBattleSocket.onBattleFinished()
  |                                              |
  |                                              v
  |                                        setSettlementData() --> Renderizacao
  |
  +--> battle:completed (broadcast)        -->  Listagem de batalhas atualizada
  |
  +--> battle:updated (broadcast)          -->  Status da batalha atualizado

Documentação Técnica - ProCases