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
- Visao Geral
- Modelo de Dados
- Algoritmo de Settlement - 5 Fases
- Fase 0: Determinacao do Vencedor
- Fase 1: Separacao de Itens que Excedem o Fair Share
- Fase 2: Distribuicao Greedy dos Itens
- Fase 3: Balanceamento - Remocao de Itens em Excesso
- Fase 4: Compensacao com Itens do Pool
- Fase 5: Persistencia, Inventario e WebSocket
- Interface do Repositorio
- Implementacao do Repositorio
- Eventos WebSocket
- DTOs e Tipos
- Frontend - Renderizacao do Settlement
- Exemplo Numerico Completo
- Regras de Negocio
- Casos de Borda
- Debugging e Troubleshooting
- 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 WebSocketModelo de Dados
Enum BattleSettlementType
Definido em prisma/schema.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
}| Tipo | Descricao | Quem recebe |
|---|---|---|
DROPPED | Item que foi dropado durante uma rodada da batalha e distribuido ao vencedor | Jogador vencedor |
REMOVED_TO_POOL | Item cujo valor excede o fairShare, removido para o pool da casa | Pool da casa (registrado com odUserId do jogador de quem foi removido) |
ADDED_FROM_POOL | Item buscado da tabela Item para compensar deficit de um vencedor | Jogador vencedor |
Model BattleSettlement
Definido em prisma/schema.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:
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID gerado pelo SnowflakeService |
battleId | BigInt | ID da batalha a que pertence este settlement |
odUserId | BigInt | ID do usuario associado ao registro |
itemId | BigInt | ID do item na tabela Item |
type | BattleSettlementType | Tipo do settlement (DROPPED, REMOVED_TO_POOL, ADDED_FROM_POOL) |
valueCents | BigInt | Valor do item em centavos de BRL (valueCentsBrl) |
roundNumber | Int? | Numero da rodada em que o item foi dropado (null para ADDED_FROM_POOL) |
createdAt | DateTime | Timestamp de criacao do registro |
Indices:
battleId- Busca rapida de todos os settlements de uma batalhaodUserId- Busca rapida de todos os settlements de um usuariotype- Filtragem por tipo de settlement
Relacoes:
battle- Referencia para a tabelaBattle(cascade delete)user- Referencia para a tabelaUseritem- Referencia para a tabelaItem
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:
battle: BattleWithRelations- A batalha completa com jogadores, caixas e rodadasplayerData: 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:
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
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.
| Modo | Regra de Vitoria | Observacao |
|---|---|---|
NORMAL | Time com MAIOR valor total vence | Padrao |
FLIP | Time com MENOR valor total vence | Logica invertida |
SHARED | Todos sao vencedores | winnerTeam = 0 |
| Empate no topo | Distribuicao dividida entre TODOS os jogadores | winnerTeam = 0, isTie = true (NORMAL e FLIP) |
Calculo do Fair Share
const winnerPlayers = isSplitAll
? battle.players
: battle.players.filter((p) => p.team === winnerTeam);
const winnerUserIds = winnerPlayers.map((p) => p.userId);
const numWinners = winnerUserIds.length;
const totalItemsValue = allRounds.reduce(
(sum, r) => sum + r.item.valueCentsBrl,
BigInt(0),
);
const fairSharePerWinner = totalItemsValue / BigInt(numWinners);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
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
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
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
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
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:
- Os itens ja estao ordenados por valor decrescente (Fase 1)
- Para cada item, percorre todos os vencedores
- Encontra o vencedor com menor valor total acumulado
- Atribui o item a esse vencedor
- Atualiza o
totalValuedo 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.
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:
- Para cada jogador, verifica se
totalValue > fairSharePerWinner - Calcula o excesso:
excess = totalValue - fairSharePerWinner - Filtra apenas itens do tipo
DROPPED(nunca remove ADDED_FROM_POOL) - Ordena por valor crescente
- Filtra itens cujo valor cabe dentro do excesso (
valueCents <= excess) - Remove o maior item que cabe (ultimo do array filtrado)
- Move o item para
itemsRemovedToPool - Repete o loop ate que
totalValue <= fairSharePerWinnerou nao haja mais itens removiveis
Regras criticas:
- Somente itens do tipo
DROPPEDpodem ser removidos nesta fase - Itens
ADDED_FROM_POOLnunca 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
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:
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:
- Busca TODOS os itens disponiveis no banco
- Filtra itens elegíveis:
valueCentsBrl > BigInt(0) && valueCentsBrl <= targetValue + BigInt(1000)(exclui itens de valor zero; tolerancia de R$ 10,00) - Ordena por valor decrescente (greedy packing: pega os maiores primeiro)
- Loop: enquanto
remainingValue > 0e 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
- Busca o maior item que cabe (
- 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
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
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
DROPPEDeADDED_FROM_POOLsao adicionados ao inventario - Itens
REMOVED_TO_POOLNAO sao adicionados (vao para o pool da casa) - Bots (usuarios com
steamIdcomecando com'BOT_') NAO recebem itens no inventario - Bots contam para o calculo do
fairSharePerWinnermas 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
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,
);
}
}| Jogador | Calculo do Profit |
|---|---|
| Vencedor | finalValue - battle.totalValueCents (pode ser positivo ou negativo) |
| Perdedor | -battle.totalValueCents (sempre negativo) |
5.4: Finalizacao da Batalha
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
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:
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
DROPPEDque lhe foram atribuidos - Itens
ADDED_FROM_POOLque recebeu como compensacao - Itens
REMOVED_TO_POOLque foram removidos dele (para exibicao riscada no frontend)
Interface do Repositorio
Arquivo: src/domain/repositories/battle-settlement.repository.interface.ts
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
| Metodo | Descricao | Retorno |
|---|---|---|
create | Cria um unico registro de settlement | BattleSettlement |
createMany | Cria multiplos registros atomicamente | number (quantidade criada) |
findByBattleId | Busca todos os settlements de uma batalha com dados do item | BattleSettlementWithItem[] |
findByBattleIdAndUserId | Busca settlements de um usuario especifico em uma batalha | BattleSettlementWithItem[] |
findByBattleIdGroupedByUser | Agrupa settlements por usuario | Map<bigint, BattleSettlementWithItem[]> |
getSettlementSummary | Retorna resumo com totais por tipo para cada usuario | Array de summaries |
Implementacao do Repositorio
Arquivo: src/infrastructure/database/repositories/battle-settlement.repository.ts
@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 + totalAddedOnde:
totalDropped: soma dos itens DROPPED atribuidos ao jogadortotalRemoved: soma dos itens REMOVED_TO_POOL que pertenciam ao jogadortotalAdded: 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
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:
{
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.
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
export class BattleSettlementItemDto {
itemId: string;
itemName: string;
itemImage: string;
itemRarity: string;
valueCents: string;
type: string;
}BattleWinnerDto
export class BattleWinnerDto {
odUserId: string;
odUsername: string;
odAvatarUrl?: string;
team: number;
totalWonCents: string;
}BattleResponseDto (trecho relevante)
export class BattleResponseDto {
// ... outros campos
winners?: BattleWinnerDto[];
settlementData?: Record<string, BattleSettlementItemDto[]>;
}Interfaces Internas do Use Case
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:
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
const [settlementData, setSettlementData] = useState<
Record<string, BattleSettlementItem[]>
>({});
const [winnerPlayers, setWinnerPlayers] = useState<BattleWinner[]>([]);O estado e preenchido de tres formas:
- Via evento WebSocket
battle:finished(em tempo real) - Via fetch da API quando a batalha ja esta finalizada (reload da pagina)
- Via
battleData.settlementDataao carregar estado salvo
Renderizacao da Distribuicao de Itens
A secao de distribuicao so aparece quando Object.keys(settlementData).length > 0:
{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):
{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):
{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):
{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
<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)
| Rodada | Time | Jogador | Item | Valor (BRL) |
|---|---|---|---|---|
| 1 | 1 | Player A | AWP Dragon Lore | R$ 150,00 |
| 1 | 1 | Player B | AK-47 Redline | R$ 50,00 |
| 1 | 1 | Player C | M4A4 Howl | R$ 60,00 |
| 1 | 2 | Player D | Glock Fade | R$ 40,00 |
| 2 | 1 | Player A | USP Kill Confirmed | R$ 30,00 |
| 2 | 1 | Player B | P250 Sand Dune | R$ 10,00 |
| 2 | 1 | Player C | Desert Eagle Blaze | R$ 35,00 |
| 2 | 2 | Player E | Five-SeveN Case Hardened | R$ 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:
| Item | Valor | > R$ 133,33? | Destino |
|---|---|---|---|
| AWP Dragon Lore | R$ 150,00 | Sim | REMOVED_TO_POOL |
| M4A4 Howl | R$ 60,00 | Nao | Distribuivel |
| AK-47 Redline | R$ 50,00 | Nao | Distribuivel |
| Glock Fade | R$ 40,00 | Nao | Distribuivel |
| Desert Eagle Blaze | R$ 35,00 | Nao | Distribuivel |
| USP Kill Confirmed | R$ 30,00 | Nao | Distribuivel |
| Five-SeveN Case Hardened | R$ 25,00 | Nao | Distribuivel |
| P250 Sand Dune | R$ 10,00 | Nao | Distribuivel |
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).
| Passo | Item | Valor | Player A | Player B | Player C | Quem recebe |
|---|---|---|---|---|---|---|
| 1 | M4A4 Howl | R$ 60 | R$ 0 | R$ 0 | R$ 0 | Player A (todos empatam, pega primeiro) |
| 2 | AK-47 Redline | R$ 50 | R$ 60 | R$ 0 | R$ 0 | Player B (menor valor: R$ 0) |
| 3 | Glock Fade | R$ 40 | R$ 60 | R$ 50 | R$ 0 | Player C (menor valor: R$ 0) |
| 4 | Desert Eagle Blaze | R$ 35 | R$ 60 | R$ 50 | R$ 40 | Player C (menor valor: R$ 40) |
| 5 | USP Kill Confirmed | R$ 30 | R$ 60 | R$ 50 | R$ 75 | Player B (menor valor: R$ 50) |
| 6 | Five-SeveN | R$ 25 | R$ 60 | R$ 80 | R$ 75 | Player A (menor valor: R$ 60) |
| 7 | P250 Sand Dune | R$ 10 | R$ 85 | R$ 80 | R$ 75 | Player C (menor valor: R$ 75) |
Apos distribuicao:
| Jogador | Itens | Total | Diferenca do Fair Share |
|---|---|---|---|
| Player A | M4A4 Howl (R$60) + Five-SeveN (R$25) | R$ 85,00 | -R$ 48,33 |
| Player B | AK-47 Redline (R$50) + USP Kill Confirmed (R$30) | R$ 80,00 | -R$ 53,33 |
| Player C | Glock 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:
| Jogador | Deficit | Compensacao Buscada |
|---|---|---|
| Player A | R$ 48,33 | Busca item com valor <= R$ 48,33 |
| Player B | R$ 53,33 | Busca item com valor <= R$ 53,33 |
| Player C | R$ 48,33 | Busca 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
| Jogador | Item Compensacao | Valor | Total Final |
|---|---|---|---|
| Player A | Item X | R$ 48,00 | R$ 133,00 |
| Player B | Item Y | R$ 52,00 | R$ 132,00 |
| Player C | Item Z | R$ 47,50 | R$ 132,50 |
Registros de Settlement no Banco
[
{ "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
- SEMPRE usar
valueCentsBrl(BRL) para todos os calculos de settlement. NUNCA usarvalueCents(USD). - Fair share:
totalItemsValue / numWinnersondetotalItemsValuee a soma de TODOS os itens dropados em TODAS as rodadas, de TODOS os times. - Itens com valor > fairShare DEVEM ser removidos para o pool da casa na Fase 1.
- NUNCA debitar ou creditar saldo monetario. O settlement trabalha exclusivamente com itens.
- Valores sao BigInt em centavos. Toda aritmetica e feita com BigInt para evitar imprecisao de ponto flutuante.
- Divisao BigInt trunca (nao arredonda).
BigInt(40000) / BigInt(3) = BigInt(13333).
Regras do Algoritmo Greedy
- Proximo item vai para o jogador com MENOR valor acumulado. Em caso de empate, o primeiro da lista
winnerUserIdsrecebe. - Itens sao processados do MAIOR para o MENOR valor. Isso e fundamental para a qualidade da distribuicao.
- Somente itens DROPPED podem ser removidos na fase de balanceamento (Fase 3). Itens ADDED_FROM_POOL nunca sao removidos.
- Na remocao de balanceamento, o maior item DROPPED que cabe no excesso e removido primeiro.
Regras de Compensacao
- Tolerancia de R$ 10,00 (
BigInt(1000)centavos). Itens de compensacao podem ter valor ate R$ 10,00 acima do deficit. - Greedy packing: itens maiores sao selecionados primeiro para preencher o deficit.
- Se nao ha itens elegíveis no pool, o jogador fica sub-compensado. O sistema NAO bloqueia por isso.
- Itens de compensacao vem da tabela
Item(pool global do sistema).
Regras de Bots
- Bots sao identificados por
steamId.startsWith('BOT_'). - Bots contam no calculo do fairShare (dividem o total por numWinners que inclui bots).
- Bots NAO recebem itens no inventario. Os itens alocados a bots efetivamente "desaparecem" (ficam para a casa).
Regras de Persistencia
- Settlement records sao imutaveis. Uma vez criados, nunca sao alterados ou deletados (exceto cascade delete da batalha).
- Todos os registros sao criados atomicamente via
createMany. - IDs sao Snowflake IDs gerados pelo
SnowflakeService.
Regras de Modo de Jogo
- NORMAL: time com MAIOR valor total ganha.
- FLIP: time com MENOR valor total ganha (logica invertida).
- SHARED: todos os jogadores sao vencedores (
winnerTeam = 0). - Empate: quando dois ou mais times empatam no topo,
winnerTeam = 0e 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) distributableItemsfica 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
totalValuefinal sera menor que ofairSharePerWinner - 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 = 0eisTie = 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 = 0winnerPlayers = 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:
settlement.totalValue > fairSharePerWinner- Existem itens DROPPED na alocacao do jogador
- 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
totalValueligeiramente acima dofairSharePerWinner - 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
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:
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
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
| Problema | Causa Provavel | Solucao |
|---|---|---|
| Distribuicao muito desigual | Item de compensacao nao encontrado no pool | Verificar se ha itens com valor adequado na tabela Item |
| Jogador sem itens | Todos os itens excediam o fairShare | Verificar o fairShare vs valores dos itens |
| Settlement nao aparece no frontend | settlementData nao emitido via WebSocket | Verificar logs do emitBattleFinished |
| Itens duplicados no inventario | Bug no loop de criacao de inventario | Verificar unicidade via findByBattleIdAndUserId |
| Bot recebendo itens | Verificacao de steamId.startsWith('BOT_') falhando | Verificar steamId do bot no banco |
| Valores em USD no settlement | Usando valueCents ao inves de valueCentsBrl | Verificar se allRounds usa valueCentsBrl |
Arquivos Relacionados
Backend
| Arquivo | Descricao |
|---|---|
prisma/schema.prisma | Model BattleSettlement e enum BattleSettlementType |
src/domain/repositories/battle-settlement.repository.interface.ts | Interface do repositorio |
src/infrastructure/database/repositories/battle-settlement.repository.ts | Implementacao Prisma do repositorio |
src/application/use-cases/battle/execute-battle.use-case.ts | Logica completa do settlement (metodo settleBattle) |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de battle:finished e battle:completed |
src/application/dto/battle.dto.ts | DTOs BattleSettlementItemDto, BattleWinnerDto, BattleResponseDto |
src/presentation/modules/battle.module.ts | Modulo que registra o repositorio via DI |
src/application/use-cases/admin/get-battle-rtp.use-case.ts | Usa settlement para calcular RTP de batalhas |
src/application/use-cases/admin/get-ggr.use-case.ts | Usa settlement para calcular GGR |
src/application/use-cases/admin/get-ggr-by-user.use-case.ts | Usa settlement para GGR por usuario |
src/application/use-cases/admin/get-overview-stats.use-case.ts | Usa settlement para estatisticas gerais |
Frontend
| Arquivo | Descricao |
|---|---|
client/app/lib/use-battle-socket.tsx | Hook com interface BattleSettlementItem e handler de battle:finished |
client/app/batalha/[id]/page.tsx | Pagina da batalha com renderizacao do settlement |
client/app/lib/api.ts | Funcoes 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