Settlement - Distribuição de Itens
O sistema de Settlement garante distribuição justa e equilibrada de itens entre os vencedores de uma batalha. Cada vencedor recebe aproximadamente o mesmo valor total.
Conceito
Quando uma batalha termina:
- Todos os itens dropados são coletados
- Fair share é calculado (total / número de vencedores)
- Itens caros demais (> fair share) são removidos para o pool
- Déficits são compensados com itens do pool
Fluxo de Settlement
Tipos de Settlement
typescript
enum BattleSettlementType {
DROPPED // Item dropado na batalha - distribuído normalmente
REMOVED_TO_POOL // Item removido (muito caro) - vai para a casa
ADDED_FROM_POOL // Item adicionado para compensar déficit
}Modelo de Dados
prisma
model BattleSettlement {
id BigInt @id @default(autoincrement())
battleId BigInt
odUserId BigInt? // null se REMOVED_TO_POOL
itemId BigInt
type BattleSettlementType
valueCents BigInt
roundNumber Int?
battle Battle @relation(...)
user User? @relation(...)
item Item @relation(...)
createdAt DateTime @default(now())
@@index([battleId])
@@index([odUserId])
}Implementação
Serviço de Settlement
typescript
// battle-settlement.service.ts
@Injectable()
export class BattleSettlementService {
async settleBattle(
battle: Battle,
result: BattleResult,
): Promise<BattleSettlement[]> {
const settlements: BattleSettlement[] = [];
// 1. Coletar todos os itens dropados
const allItems = await this.collectDroppedItems(battle.id);
// 2. Calcular valor total e fair share
const totalValue = allItems.reduce((sum, i) => sum + i.valueCents, 0n);
const winnerCount = BigInt(result.winners.length);
const fairSharePerWinner = totalValue / winnerCount;
console.log(`[Settlement] Battle ${battle.id}`);
console.log(`[Settlement] Total Value: R$ ${totalValue / 100n}`);
console.log(`[Settlement] Winners: ${result.winners.length}`);
console.log(`[Settlement] Fair Share: R$ ${fairSharePerWinner / 100n}`);
// 3. Separar itens por elegibilidade
const eligibleItems: DroppedItem[] = [];
const expensiveItems: DroppedItem[] = [];
for (const item of allItems) {
if (item.valueCents > fairSharePerWinner) {
// Item muito caro - remover para pool
expensiveItems.push(item);
settlements.push({
battleId: battle.id,
odUserId: null, // Pool da casa
itemId: item.id,
type: BattleSettlementType.REMOVED_TO_POOL,
valueCents: item.valueCents,
roundNumber: item.roundNumber,
});
console.log(`[Settlement] Removed to pool: ${item.name} (R$ ${item.valueCents / 100n})`);
} else {
eligibleItems.push(item);
}
}
// 4. Distribuir itens elegíveis
const winnerAllocations = new Map<bigint, {
items: DroppedItem[];
totalValue: bigint;
}>();
// Inicializar allocations
for (const winner of result.winners) {
winnerAllocations.set(winner.userId, {
items: [],
totalValue: 0n,
});
}
// Ordenar itens por valor (maiores primeiro)
const sortedItems = [...eligibleItems].sort(
(a, b) => Number(b.valueCents - a.valueCents),
);
// Distribuir para quem tem menor valor acumulado
for (const item of sortedItems) {
// Encontrar vencedor com menor valor
let minWinner: bigint | null = null;
let minValue = BigInt(Number.MAX_SAFE_INTEGER);
for (const [userId, allocation] of winnerAllocations) {
if (allocation.totalValue < minValue) {
minValue = allocation.totalValue;
minWinner = userId;
}
}
if (minWinner) {
const allocation = winnerAllocations.get(minWinner)!;
allocation.items.push(item);
allocation.totalValue += item.valueCents;
settlements.push({
battleId: battle.id,
odUserId: minWinner,
itemId: item.id,
type: BattleSettlementType.DROPPED,
valueCents: item.valueCents,
roundNumber: item.roundNumber,
});
console.log(`[Settlement] ${item.name} -> User ${minWinner}`);
}
}
// 5. Compensar déficits
const TOLERANCE_CENTS = 100n; // R$ 1,00 de tolerância
for (const [userId, allocation] of winnerAllocations) {
const deficit = fairSharePerWinner - allocation.totalValue;
if (deficit > TOLERANCE_CENTS) {
console.log(`[Settlement] User ${userId} deficit: R$ ${deficit / 100n}`);
// Buscar itens de compensação do pool
const compensationItems = await this.findCompensationItems(
deficit,
TOLERANCE_CENTS,
);
for (const compItem of compensationItems) {
settlements.push({
battleId: battle.id,
odUserId: userId,
itemId: compItem.id,
type: BattleSettlementType.ADDED_FROM_POOL,
valueCents: compItem.valueCents,
roundNumber: null,
});
console.log(`[Settlement] Compensation: ${compItem.name} -> User ${userId}`);
}
}
}
// 6. Salvar todos os settlements
await this.battleSettlementRepository.createMany(settlements);
console.log(`[Settlement] Saved ${settlements.length} records`);
return settlements;
}
private async findCompensationItems(
targetValue: bigint,
tolerance: bigint,
): Promise<Item[]> {
// Buscar itens do pool com valor próximo ao target
const items = await this.itemRepository.findAvailableForCompensation({
minValue: targetValue - tolerance,
maxValue: targetValue + tolerance,
limit: 5,
});
if (items.length === 0) {
// Fallback: buscar múltiplos itens menores
return this.findMultipleSmallItems(targetValue);
}
// Retornar item mais próximo do target
return [items[0]];
}
private async findMultipleSmallItems(targetValue: bigint): Promise<Item[]> {
const result: Item[] = [];
let remaining = targetValue;
while (remaining > 0n) {
const item = await this.itemRepository.findLargestUnderValue(remaining);
if (!item) break;
result.push(item);
remaining -= item.valueCents;
}
return result;
}
}Coletar Itens Dropados
typescript
async collectDroppedItems(battleId: bigint): Promise<DroppedItem[]> {
const rounds = await this.battleRoundRepository.findByBattle(battleId);
return rounds.map((round) => ({
id: round.item.id,
name: round.item.name,
valueCents: round.itemValueCents,
roundNumber: round.roundNumber,
participantId: round.participantId,
imageUrl: round.item.imageUrl,
rarity: round.item.rarity,
}));
}Exemplo Prático
Cenário
- Batalha: 3v3
- Vencedores: Time 1 (3 jogadores)
- Total dropado: R$ 450,00
- Fair Share: R$ 450 / 3 = R$ 150 por vencedor
Itens Dropados
| Item | Valor | Elegível? |
|---|---|---|
| AWP Dragon Lore | R$ 200 | ❌ (> R$ 150) |
| AK-47 Redline | R$ 80 | ✅ |
| M4A4 Howl | R$ 90 | ✅ |
| Glock Fade | R$ 50 | ✅ |
| USP Kill Confirmed | R$ 30 | ✅ |
Distribuição
Passo 1: AWP Dragon Lore removida para pool (REMOVED_TO_POOL)
Passo 2: Distribuir itens elegíveis (maior para menor)
| Jogador | Item Recebido | Total Acumulado |
|---|---|---|
| Player 1 | M4A4 Howl (R$ 90) | R$ 90 |
| Player 2 | AK-47 Redline (R$ 80) | R$ 80 |
| Player 3 | Glock Fade (R$ 50) | R$ 50 |
| Player 3 | USP Kill Confirmed (R$ 30) | R$ 80 |
Passo 3: Calcular déficits
| Jogador | Valor Atual | Déficit |
|---|---|---|
| Player 1 | R$ 90 | R$ 60 |
| Player 2 | R$ 80 | R$ 70 |
| Player 3 | R$ 80 | R$ 70 |
Passo 4: Compensar com itens do pool
| Jogador | Compensação | Valor Final |
|---|---|---|
| Player 1 | P250 Sand Dune (R$ 58) | R$ 148 |
| Player 2 | Deagle Blaze (R$ 68) | R$ 148 |
| Player 3 | AWP Redline (R$ 69) | R$ 149 |
Settlements Finais
json
[
{
"type": "REMOVED_TO_POOL",
"item": "AWP Dragon Lore",
"value": 20000,
"userId": null
},
{
"type": "DROPPED",
"item": "M4A4 Howl",
"value": 9000,
"userId": "player1"
},
{
"type": "DROPPED",
"item": "AK-47 Redline",
"value": 8000,
"userId": "player2"
},
{
"type": "DROPPED",
"item": "Glock Fade",
"value": 5000,
"userId": "player3"
},
{
"type": "DROPPED",
"item": "USP Kill Confirmed",
"value": 3000,
"userId": "player3"
},
{
"type": "ADDED_FROM_POOL",
"item": "P250 Sand Dune",
"value": 5800,
"userId": "player1"
},
{
"type": "ADDED_FROM_POOL",
"item": "Deagle Blaze",
"value": 6800,
"userId": "player2"
},
{
"type": "ADDED_FROM_POOL",
"item": "AWP Redline",
"value": 6900,
"userId": "player3"
}
]Frontend - Tela de Vitória
Estrutura
tsx
const BattleVictoryScreen = ({
battle,
settlements,
isWinner,
}: Props) => {
const groupedByUser = groupSettlements(settlements);
return (
<div className="victory-screen">
<h1>{isWinner ? '🎉 Vitória!' : '😢 Derrota'}</h1>
{/* Tabela de distribuição */}
<div className="settlements-table">
{Object.entries(groupedByUser).map(([userId, items]) => (
<div key={userId} className="user-settlements">
<UserAvatar userId={userId} />
<div className="items-list">
{items.map((settlement) => (
<SettlementItem
key={settlement.id}
settlement={settlement}
/>
))}
</div>
<div className="total">
R$ {calculateTotal(items) / 100}
</div>
</div>
))}
</div>
</div>
);
};Item com Indicadores
tsx
const SettlementItem = ({ settlement }: { settlement: BattleSettlement }) => {
return (
<div className={cn(
"settlement-item",
settlement.type === 'REMOVED_TO_POOL' && 'line-through opacity-50',
settlement.type === 'ADDED_FROM_POOL' && 'border-blue-500',
)}>
<img src={settlement.item.imageUrl} alt={settlement.item.name} />
<span className="item-name">{settlement.item.name}</span>
{/* Indicadores */}
{settlement.type === 'ADDED_FROM_POOL' && (
<Tooltip content="Item adicionado para compensação">
<InfoIcon className="text-blue-500" />
</Tooltip>
)}
{settlement.type === 'REMOVED_TO_POOL' && (
<Tooltip content="Item removido (valor muito alto)">
<XIcon className="text-red-500" />
</Tooltip>
)}
<span className="value">
R$ {(settlement.valueCents / 100).toFixed(2)}
</span>
</div>
);
};Regras Importantes
- NUNCA debitar/creditar saldo - Apenas itens são movimentados
- Itens > fairShare são removidos - Vão para o pool da casa
- Compensação vem do pool - Tabela Item contém itens disponíveis
- Tolerância de R$ 1 - Itens de compensação podem ter até R$ 1 a mais
- Tudo é auditado - Cada movimentação é registrada
- Distribuição justa - Todos vencedores recebem ~mesmo valor
Endpoints da API
Consultar Settlements
http
GET /api/battles/:id/settlementsResponse:
json
{
"battleId": "456",
"fairSharePerWinner": 15000,
"settlements": [
{
"id": "1",
"type": "DROPPED",
"userId": "111",
"item": {
"id": "789",
"name": "AK-47 | Redline",
"valueCents": 8000,
"imageUrl": "..."
},
"roundNumber": 2
},
{
"id": "2",
"type": "ADDED_FROM_POOL",
"userId": "111",
"item": {
"id": "999",
"name": "P250 | Sand Dune",
"valueCents": 5800,
"imageUrl": "..."
},
"roundNumber": null
}
],
"summary": {
"totalDropped": 25000,
"removedToPool": 20000,
"addedFromPool": 18500,
"winnersCount": 3
}
}Troubleshooting
Distribuição Desigual
typescript
// Verificar distribuição
const settlements = await settlementRepository.findByBattle(battleId);
const byUser = new Map<bigint, bigint>();
for (const s of settlements) {
if (s.odUserId && s.type !== 'REMOVED_TO_POOL') {
const current = byUser.get(s.odUserId) || 0n;
byUser.set(s.odUserId, current + s.valueCents);
}
}
console.log('Distribution:', Object.fromEntries(byUser));Item Não Creditado
typescript
// Verificar se settlement foi criado
const settlement = await settlementRepository.findByBattleAndItem(
battleId,
itemId,
);
if (!settlement) {
console.error('Settlement não encontrado');
}
// Verificar inventário
const inventoryItem = await inventoryRepository.findByUserAndItem(
userId,
itemId,
);
if (!inventoryItem) {
// Creditar manualmente
await inventoryService.addItem(userId, itemId);
}