Skip to content

Cases - Abertura de Caixas

A abertura de caixas é a mecânica principal do CSGOFlip. O jogador seleciona uma caixa, paga o preço e recebe um item aleatório baseado em probabilidades justas e verificáveis.

Visão Geral

Modelo de Dados

Case (Caixa)

prisma
model Case {
  id              BigInt       @id @default(autoincrement())
  name            String
  slug            String       @unique
  description     String?
  imageUrl        String
  priceCents      BigInt
  category        CaseCategory
  isActive        Boolean      @default(true)
  isFeatured      Boolean      @default(false)
  houseEdge       Float        @default(0.10)
  
  items           CaseItem[]
  openings        CaseOpening[]
  
  createdAt       DateTime     @default(now())
  updatedAt       DateTime     @updatedAt
}

enum CaseCategory {
  CHEAP          // < R$ 10
  MEDIUM         // R$ 10 - R$ 50
  EXPENSIVE      // R$ 50 - R$ 200
  PREMIUM        // > R$ 200
  EVENT          // Casos especiais
}

CaseItem (Item da Caixa)

prisma
model CaseItem {
  id              BigInt    @id @default(autoincrement())
  caseId          BigInt
  itemId          BigInt
  dropChance      Float     // 0.0001 a 1.0
  rangeStart      Int       // 1 a 10000000
  rangeEnd        Int       // 1 a 10000000
  
  case            Case      @relation(...)
  item            Item      @relation(...)
  
  @@index([caseId])
}

CaseOpening (Abertura)

prisma
model CaseOpening {
  id              BigInt    @id @default(autoincrement())
  caseId          BigInt
  userId          BigInt
  itemId          BigInt
  
  // Provably Fair
  serverSeed      String
  serverSeedHash  String
  clientSeed      String
  nonce           Int
  roll            Int
  
  // Sistema FLIP
  isFlip          Boolean   @default(false)
  flipServerSeed  String?
  flipClientSeed  String?
  flipNonce       Int?
  flipRoll        Int?
  
  // Metadados
  pricePaidCents  BigInt
  itemValueCents  BigInt
  profit          BigInt    // itemValue - pricePaid
  
  case            Case      @relation(...)
  user            User      @relation(...)
  item            Item      @relation(...)
  
  createdAt       DateTime  @default(now())
  
  @@index([userId])
  @@index([caseId])
  @@index([createdAt])
}

Implementação

Use Case: Abrir Caixa

typescript
// open-case.use-case.ts
@Injectable()
export class OpenCaseUseCase {
  constructor(
    private caseRepository: CaseRepository,
    private caseOpeningRepository: CaseOpeningRepository,
    private transactionService: TransactionService,
    private provablyFairService: ProvablyFairService,
    private inventoryService: InventoryService,
    private flipService: FlipService,
    private webSocketGateway: WebSocketGateway,
    private redlock: Redlock,
  ) {}
  
  async execute(userId: bigint, caseId: bigint): Promise<CaseOpeningResult> {
    const lockKey = `user:${userId}:case-open`;
    
    return await this.redlock.using([lockKey], 30000, async () => {
      // 1. Buscar caixa e validar
      const caseData = await this.caseRepository.findById(caseId);
      if (!caseData || !caseData.isActive) {
        throw new CaseNotFoundException();
      }
      
      // 2. Verificar saldo
      const balance = await this.transactionService.getUserBalance(userId);
      if (balance < caseData.priceCents) {
        throw new InsufficientBalanceException();
      }
      
      // 3. Gerar seeds e calcular roll
      const serverSeed = this.provablyFairService.generateServerSeed();
      const serverSeedHash = this.provablyFairService.hashSeed(serverSeed);
      const clientSeed = await this.getOrCreateClientSeed(userId);
      const nonce = await this.incrementNonce(userId);
      
      const roll = this.provablyFairService.calculateRoll(
        serverSeed,
        clientSeed,
        nonce,
      );
      
      // 4. Verificar FLIP
      const isFlip = this.flipService.shouldTriggerFlip(roll);
      let wonItem: Item;
      let flipData: FlipData | null = null;
      
      if (isFlip) {
        flipData = await this.processFlip(caseData, serverSeed, clientSeed, nonce);
        wonItem = flipData.item;
      } else {
        wonItem = this.selectItemByRoll(caseData.items, roll);
      }
      
      // 5. Executar transação atômica
      return await this.prisma.$transaction(async (tx) => {
        // Debitar saldo
        await this.transactionService.createTransaction(tx, {
          debitUserId: userId,
          creditUserId: HOUSE_USER_ID,
          amountCents: caseData.priceCents,
          type: TransactionType.CASE_OPEN,
          referenceType: 'CASE',
          referenceId: caseId,
        });
        
        // Registrar abertura
        const opening = await tx.caseOpening.create({
          data: {
            caseId,
            userId,
            itemId: wonItem.id,
            serverSeed,
            serverSeedHash,
            clientSeed,
            nonce,
            roll,
            isFlip,
            flipServerSeed: flipData?.serverSeed,
            flipClientSeed: flipData?.clientSeed,
            flipNonce: flipData?.nonce,
            flipRoll: flipData?.roll,
            pricePaidCents: caseData.priceCents,
            itemValueCents: wonItem.valueCents,
            profit: wonItem.valueCents - caseData.priceCents,
          },
        });
        
        // Adicionar item ao inventário
        await this.inventoryService.addItem(tx, userId, wonItem.id);
        
        // Atualizar estatísticas
        await this.updateUserStats(tx, userId, wonItem);
        
        return {
          openingId: opening.id,
          item: wonItem,
          roll,
          serverSeedHash,
          isFlip,
          flipItems: flipData?.items,
          flipRoll: flipData?.roll,
        };
      });
    });
  }
  
  private selectItemByRoll(items: CaseItem[], roll: number): Item {
    for (const caseItem of items) {
      if (roll >= caseItem.rangeStart && roll <= caseItem.rangeEnd) {
        return caseItem.item;
      }
    }
    throw new InvalidRollException();
  }
}

Cálculo de Ranges

Os ranges são calculados proporcionalmente às probabilidades:

typescript
// case.service.ts
calculateItemRanges(items: CaseItemInput[]): CaseItem[] {
  const TOTAL_RANGE = 10000000; // 1-10000000
  let currentStart = 0;
  
  // Ordenar por dropChance (menor primeiro)
  const sorted = [...items].sort((a, b) => a.dropChance - b.dropChance);
  
  return sorted.map((item) => {
    const rangeSize = Math.floor(item.dropChance * TOTAL_RANGE);
    const rangeStart = currentStart;
    const rangeEnd = currentStart + rangeSize - 1;
    
    currentStart = rangeEnd + 1;
    
    return {
      ...item,
      rangeStart,
      rangeEnd,
    };
  });
}

Exemplo:

ItemDrop ChanceRange
Consumer79.92%1 - 7992000
Industrial15.98%7992001 - 9590000
Mil-Spec3.2%9590001 - 9910000
Restricted0.64%9910001 - 9974000
Classified0.26%9974001 - 10000000

Frontend - Roleta

Estrutura da Roleta

tsx
// case/[id]/page.tsx
const Roulette = ({ items, roll, isFlip, flipItems }) => {
  const [offset, setOffset] = useState(0);
  const [isSpinning, setIsSpinning] = useState(false);
  
  // Calcular posição final baseada no roll
  const calculateFinalOffset = (roll: number, targetItems: any[]) => {
    const itemWidth = 180; // pixels
    const containerCenter = containerWidth / 2;
    
    // Encontrar item pelo roll
    const targetItem = findItemByRoll(targetItems, roll);
    const targetIndex = targetItems.findIndex(i => i.id === targetItem.id);
    
    // Calcular offset para centralizar
    return (targetIndex * itemWidth) - containerCenter + (itemWidth / 2);
  };
  
  const startSpin = async () => {
    setIsSpinning(true);
    
    // Offset inicial (longe)
    setOffset(-5000);
    
    // Após delay, ir para posição final
    setTimeout(() => {
      setOffset(calculateFinalOffset(roll, items));
    }, 100);
    
    // Após 10s, parar
    setTimeout(() => {
      setIsSpinning(false);
    }, 10000);
  };
  
  return (
    <div className="roulette-container overflow-hidden">
      <motion.div
        className="roulette-track flex"
        animate={{ x: -offset }}
        transition={{
          duration: isSpinning ? 10 : 0,
          ease: [0.1, 0.8, 0.2, 1], // Custom easing
        }}
      >
        {/* Repetir itens 5x para efeito de loop */}
        {[...Array(5)].map((_, repeatIndex) =>
          items.map((item, itemIndex) => (
            <RouletteCard
              key={`${repeatIndex}-${itemIndex}`}
              item={item}
              isCenter={/* lógica de highlight */}
            />
          ))
        )}
      </motion.div>
      
      {/* Indicador central */}
      <div className="absolute left-1/2 top-0 h-full w-1 bg-primary" />
    </div>
  );
};

Animação

css
/* Easing customizado para roleta */
.roulette-track {
  transition-timing-function: cubic-bezier(0.1, 0.8, 0.2, 1);
}

/* Highlight do item central */
.roulette-card.center {
  transform: scale(1.1);
  border: 2px solid var(--primary);
  box-shadow: 0 0 20px var(--primary);
}

Raridades e Cores

typescript
// rarity.constants.ts
export const RARITY_CONFIG = {
  CONSUMER: {
    name: 'Consumer Grade',
    color: '#b0c3d9',
    probability: 0.7992,
  },
  INDUSTRIAL: {
    name: 'Industrial Grade',
    color: '#5e98d9',
    probability: 0.1598,
  },
  MIL_SPEC: {
    name: 'Mil-Spec',
    color: '#4b69ff',
    probability: 0.032,
  },
  RESTRICTED: {
    name: 'Restricted',
    color: '#8847ff',
    probability: 0.0064,
  },
  CLASSIFIED: {
    name: 'Classified',
    color: '#d32ce6',
    probability: 0.0026,
  },
  COVERT: {
    name: 'Covert',
    color: '#eb4b4b',
    probability: 0.0004,
  },
  EXTRAORDINARY: {
    name: 'Extraordinary',
    color: '#ffd700',
    probability: 0.0001,
  },
};

Endpoints da API

Listar Caixas

http
GET /api/cases?category=MEDIUM&featured=true

Response:

json
{
  "cases": [
    {
      "id": "123",
      "name": "Caixa Neon",
      "slug": "caixa-neon",
      "imageUrl": "https://...",
      "priceCents": 1500,
      "category": "MEDIUM",
      "isFeatured": true,
      "itemCount": 15,
      "bestItem": {
        "name": "AWP | Fade",
        "rarity": "COVERT",
        "valueCents": 150000
      }
    }
  ],
  "total": 24,
  "page": 1
}

Detalhes da Caixa

http
GET /api/cases/:id

Response:

json
{
  "id": "123",
  "name": "Caixa Neon",
  "priceCents": 1500,
  "items": [
    {
      "id": "456",
      "name": "Glock-18 | Fade",
      "rarity": "COVERT",
      "valueCents": 85000,
      "imageUrl": "https://...",
      "dropChance": 0.004
    }
  ]
}

Abrir Caixa

http
POST /api/cases/:id/open
Authorization: Bearer {sessionId}

Response (Normal):

json
{
  "openingId": "789",
  "item": {
    "id": "456",
    "name": "AK-47 | Redline",
    "rarity": "CLASSIFIED",
    "valueCents": 15000,
    "imageUrl": "https://..."
  },
  "roll": 45230,
  "serverSeedHash": "a1b2c3...",
  "isFlip": false
}

Response (FLIP):

json
{
  "openingId": "789",
  "item": {
    "id": "999",
    "name": "AWP | Dragon Lore",
    "rarity": "EXTRAORDINARY",
    "valueCents": 250000
  },
  "roll": 3888,
  "serverSeedHash": "a1b2c3...",
  "isFlip": true,
  "flipRoll": 87543,
  "flipItems": [
    { "id": "999", "name": "AWP | Dragon Lore", ... },
    { "id": "998", "name": "M4A4 | Howl", ... }
  ]
}

Verificar Fairness

http
GET /api/cases/openings/:id/verify

Response:

json
{
  "openingId": "789",
  "serverSeed": "original-server-seed",
  "serverSeedHash": "a1b2c3...",
  "clientSeed": "user-client-seed",
  "nonce": 42,
  "roll": 45230,
  "calculatedRoll": 45230,
  "isValid": true
}

Estatísticas

Por Usuário

typescript
interface UserCaseStats {
  totalOpened: number;
  totalSpent: bigint;
  totalWon: bigint;
  profit: bigint;
  favoriteCase: string;
  bestDrop: {
    item: Item;
    date: Date;
  };
  flipCount: number;
}

Por Caixa

typescript
interface CaseStats {
  totalOpened: number;
  uniqueOpeners: number;
  totalVolume: bigint;
  averageProfit: bigint;
  dropDistribution: {
    rarity: string;
    count: number;
    percentage: number;
  }[];
}

Troubleshooting

Roll Fora do Range

typescript
// Se roll não encontrar item, verificar ranges
const totalRange = items.reduce((sum, item) => {
  return sum + (item.rangeEnd - item.rangeStart + 1);
}, 0);

if (totalRange !== 100000) {
  console.error('Ranges não cobrem 1-10000000');
  // Recalcular ranges
}

Saldo Não Debitado

typescript
// Verificar transação
const tx = await transactionRepository.findByReference(
  openingId,
  'CASE_OPENING',
);

if (!tx) {
  // Transação não foi criada - problema no rollback
  // Verificar logs e refazer manualmente se necessário
}

Documentação Técnica CSGOFlip