Skip to content

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:

  1. Todos os itens dropados são coletados
  2. Fair share é calculado (total / número de vencedores)
  3. Itens caros demais (> fair share) são removidos para o pool
  4. 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

ItemValorElegível?
AWP Dragon LoreR$ 200❌ (> R$ 150)
AK-47 RedlineR$ 80
M4A4 HowlR$ 90
Glock FadeR$ 50
USP Kill ConfirmedR$ 30

Distribuição

Passo 1: AWP Dragon Lore removida para pool (REMOVED_TO_POOL)

Passo 2: Distribuir itens elegíveis (maior para menor)

JogadorItem RecebidoTotal Acumulado
Player 1M4A4 Howl (R$ 90)R$ 90
Player 2AK-47 Redline (R$ 80)R$ 80
Player 3Glock Fade (R$ 50)R$ 50
Player 3USP Kill Confirmed (R$ 30)R$ 80

Passo 3: Calcular déficits

JogadorValor AtualDéficit
Player 1R$ 90R$ 60
Player 2R$ 80R$ 70
Player 3R$ 80R$ 70

Passo 4: Compensar com itens do pool

JogadorCompensaçãoValor Final
Player 1P250 Sand Dune (R$ 58)R$ 148
Player 2Deagle Blaze (R$ 68)R$ 148
Player 3AWP 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

  1. NUNCA debitar/creditar saldo - Apenas itens são movimentados
  2. Itens > fairShare são removidos - Vão para o pool da casa
  3. Compensação vem do pool - Tabela Item contém itens disponíveis
  4. Tolerância de R$ 1 - Itens de compensação podem ter até R$ 1 a mais
  5. Tudo é auditado - Cada movimentação é registrada
  6. Distribuição justa - Todos vencedores recebem ~mesmo valor

Endpoints da API

Consultar Settlements

http
GET /api/battles/:id/settlements

Response:

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

Documentação Técnica CSGOFlip