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:
| Item | Drop Chance | Range |
|---|---|---|
| Consumer | 79.92% | 1 - 7992000 |
| Industrial | 15.98% | 7992001 - 9590000 |
| Mil-Spec | 3.2% | 9590001 - 9910000 |
| Restricted | 0.64% | 9910001 - 9974000 |
| Classified | 0.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=trueResponse:
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/:idResponse:
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/verifyResponse:
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
}