Cases - Abertura de Caixas
A abertura de caixas e a mecanica principal da plataforma. O jogador seleciona uma caixa, paga o preco em BRL (centavos) e recebe um item aleatorio determinado pelo sistema Provably Fair (HMAC-SHA256). O sistema suporta abertura unica e multipla (ate 100 por vez), com sistema FLIP integrado para itens raros.
Indice
- Modelo de Dados (Prisma)
- Fluxo Completo de Abertura
- Calculo de Probabilidades e Hash Ranges
- Calculo do Roll (Provably Fair)
- Calculo do Nonce
- Selecao do Item pelo Roll
- Calculo de Lucro
- Abertura Multipla
- Preparacao de Itens para Roleta
- Eventos WebSocket
- Live Drop Delay
- Endpoints da API
- DTOs
- Verificacao Provably Fair
- Casos de Erro
- Debugging
Modelo de Dados (Prisma)
Enums
enum CaseStatus {
ACTIVE
INACTIVE
COMING_SOON
}
enum RiskLevel {
LOW
MEDIUM
HIGH
MAXIMUM
}
enum Rarity {
CONSUMER
INDUSTRIAL
MIL_SPEC
RESTRICTED
CLASSIFIED
COVERT
EXTRAORDINARY
}
enum WeaponCategory {
PISTOL
RIFLE
SMG
SNIPER
SHOTGUN
HEAVY
KNIFE
GLOVE
OTHER
}
enum ItemExterior {
FACTORY_NEW
MINIMAL_WEAR
FIELD_TESTED
WELL_WORN
BATTLE_SCARRED
NOT_PAINTED
}
enum InventoryItemStatus {
AVAILABLE
LOCKED
SOLD
WITHDRAWN
USED_IN_UPGRADE
USED_IN_BATTLE
IN_SWAP
IN_CRASH
USED_IN_CRASH
IN_MINES
USED_IN_MINES
}CaseCategory
model CaseCategory {
id BigInt @id
name String @unique
slug String @unique
description String?
order Int @default(0)
status CaseStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
cases Case[]
@@map("case_categories")
@@index([status, order])
}Case
model Case {
id BigInt @id
name String
slug String @unique
description String?
imageUrl String @map("image_url")
bgColor String? @map("bg_color")
priceCents BigInt @map("price_cents")
riskLevel RiskLevel @map("risk_level")
categoryId BigInt @map("category_id")
isHot Boolean @default(false) @map("is_hot")
isNew Boolean @default(false) @map("is_new")
order Int @default(0)
flipThreshold Int? @map("flip_threshold")
flipDisplayRareSpecial Boolean @default(false) @map("flip_display_rare_special")
status CaseStatus @default(ACTIVE)
targetRtpPercent Decimal? @db.Decimal(5, 2) @map("target_rtp_percent")
rtpAlertActive Boolean @default(false) @map("rtp_alert_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
category CaseCategory @relation(fields: [categoryId], references: [id])
items CaseItem[]
openings CaseOpening[]
battleCases BattleCase[]
battleRounds BattleRound[]
freeCredits FreeOpeningCredit[]
@@map("cases")
@@index([status, categoryId, order])
@@index([isHot])
@@index([isNew])
@@index([riskLevel])
}Campos importantes:
priceCents: Preco em centavos de BRL. Exemplo:1590= R$ 15,90riskLevel: LOW, MEDIUM, HIGH ou MAXIMUMstatus: Apenas caixas com statusACTIVEpodem ser abertasisHot/isNew: Flags para destaque visual no frontendflipThreshold: Limiar do FLIP (nullable). Senull, a caixa nao tem FLIP; caso contrario,roll >= flipThresholdativa a segunda roleta (ver FLIP)targetRtpPercent/rtpAlertActive: RTP alvo e flag de alerta usados pelo monitoramento de RTP apos sync de precos
Item
model Item {
id BigInt @id
name String
marketHashName String @unique @map("market_hash_name")
description String?
imageUrl String @map("image_url")
rarity Rarity
valueCents BigInt @map("value_cents")
valueCentsBrl BigInt @default(0) @map("value_cents_brl")
originalPriceCents BigInt @default(0) @map("original_price_cents")
marginPercent Float @default(20) @map("margin_percent")
weaponCategory WeaponCategory @map("weapon_category")
collection String?
exterior ItemExterior?
isStatTrak Boolean @default(false) @map("is_stattrak")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("items")
@@index([rarity])
@@index([valueCents])
@@index([valueCentsBrl])
@@index([weaponCategory])
}REGRA CRITICA: O campo valueCentsBrl (BRL) e o utilizado em TODOS os calculos internos (RTP, lucro, probabilidades, exibicao). O campo valueCents (USD) serve apenas como referencia do preco original.
CaseItem
model CaseItem {
id BigInt @id
caseId BigInt @map("case_id")
itemId BigInt @map("item_id")
weight Int
hashRangeStart Int @map("hash_range_start")
hashRangeEnd Int @map("hash_range_end")
dropChance Float @map("drop_chance")
isRare Boolean @default(false) @map("is_rare")
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id])
@@unique([caseId, itemId])
@@map("case_items")
@@index([caseId])
@@index([itemId])
}Campos importantes:
weight: Peso relativo do item. Quanto maior, mais provavel de dropar.hashRangeStart/hashRangeEnd: Faixa de 0 a 9.999.999 (HASH_MAX - 1). Se o roll cair nessa faixa, o item e selecionado.dropChance: Porcentagem real de drop (calculada a partir do weight).isRare: Flag legada no schema. NAO e usada peloFlipService— a participacao no FLIP e decidida porhashRangeStart >= Case.flipThreshold(ver FLIP), nao por este campo.
CaseOpening
model CaseOpening {
id BigInt @id
userId BigInt @map("user_id")
caseId BigInt @map("case_id")
itemId BigInt @map("item_id")
pricePaidCents BigInt @map("price_paid_cents")
itemValueCents BigInt @map("item_value_cents")
profitCents BigInt @map("profit_cents")
serverSeed String @map("server_seed")
serverSeedHash String @map("server_seed_hash")
clientSeed String @map("client_seed")
nonce Int
roll Int
isFlip Boolean @default(false) @map("is_flip")
flipServerSeed String? @map("flip_server_seed")
flipClientSeed String? @map("flip_client_seed")
flipNonce Int? @map("flip_nonce")
flipRoll Int? @map("flip_roll")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
case Case @relation(fields: [caseId], references: [id])
item Item @relation(fields: [itemId], references: [id])
@@map("case_openings")
@@index([userId, createdAt])
@@index([caseId, createdAt])
@@index([isFlip])
}UserInventory
model UserInventory {
id BigInt @id
userId BigInt @map("user_id")
itemId BigInt @map("item_id")
status InventoryItemStatus @default(AVAILABLE)
acquiredAt DateTime @default(now()) @map("acquired_at")
acquiredFrom String? @map("acquired_from")
acquiredValueCents BigInt @default(0) @map("acquired_value_cents")
restrictedToGames Boolean @default(false) @map("restricted_to_games")
lockedUntil DateTime? @map("locked_until")
user User @relation(fields: [userId], references: [id])
item Item @relation(fields: [itemId], references: [id])
swapItems SwapItem[]
siteSwapItems SiteSwapItem[]
waxpeerWithdrawals WaxpeerWithdrawal[]
crashBetSkins CrashBetSkin[]
@@map("user_inventory")
@@index([userId, status])
@@index([itemId])
}Fluxo Completo de Abertura
O fluxo e executado no OpenCaseUseCase.execute() em src/application/use-cases/case-opening/open-case.use-case.ts.
Passo a Passo
1. Buscar caixa por ID (cache) -> caseRepository.findByIdCached(caseId)
2. Validar status da caixa -> caseData.status === 'ACTIVE'
3. Adquirir lock distribuido -> lockService.withUserBalanceLock(userId)
4. Buscar usuario -> userRepository.findById(userId)
5. Validar saldo REAL -> transactionService.getUserBalance(userId) >= caseData.priceCents
6. Gerar server seed (32 bytes hex) -> provablyFairService.generateServerSeed()
7. Gerar hash do server seed -> provablyFairService.hashServerSeed(serverSeed)
8. Resolver client seed -> provablyFairService.generateClientSeed(clientSeed)
9. Obter nonce (Redis INCR por usuario) -> nonceService.getNextNonce(userId)
10. Calcular roll (1 a 10.000.000) -> provablyFairService.calculateRoll(serverSeed, clientSeed, nonce)
11. Ler flip threshold da caixa -> caseData.flipThreshold
12. Verificar se ativou FLIP -> flipService.shouldTriggerFlip(roll, flipThreshold)
13. Separar itens normais/raros -> filter por hashRangeStart vs flipThreshold
14. Preparar itens para roleta -> prepareFlipItemsForRoulette()
15. Selecionar item ganho pelo roll -> selectFlipWinner (FLIP) ou getItemByRoll (normal)
16. Buff STREAMER (opcional) -> reroll determinístico se role === 'STREAMER'
17. Calcular lucro -> wonItem.valueCentsBrl - caseData.priceCents
18. Iniciar transacao Prisma (15000ms) -> prisma.$transaction()
19. Debitar saldo (double-entry) -> transactionService.debit(userId, priceCents, ..., tx)
20. Criar registro CaseOpening -> caseOpeningRepository.create() (campos flip = null)
21. Adicionar item ao inventario -> userInventoryRepository.create() (acquiredValueCents)
22. Marcar firstBetAt + totalWageredCents-> tx.user.updateMany / update (atomicos)
23. Commit da transacao
24. Buscar saldo REAL (pos-commit) -> transactionService.getUserBalance(userId)
25. Emitir saldo via WebSocket -> webSocketGateway.emitBalanceUpdate()
26. Auditar abertura -> auditService.logUserAction('CASE_OPENED', ...)
27. Agendar live drop com delay -> setTimeout(emitLiveDrop, delay)Codigo Real do Use Case
// src/application/use-cases/case-opening/open-case.use-case.ts
async execute(
userId: bigint,
caseId: bigint,
clientSeed?: string,
): Promise<{
opening: any;
item: any;
inventoryItemId: bigint;
serverSeedHash: string;
clientSeed: string;
nonce: number;
roll: number;
isFlip: boolean;
flipRoll?: number;
flipItems?: any[];
normalItems?: any[];
}> {
const caseData = await this.caseRepository.findByIdCached(caseId);
if (!caseData) {
throw new NotFoundException(`Case with ID ${caseId} not found`);
}
if (caseData.status !== 'ACTIVE') {
throw new BadRequestException('Case is not available');
}
return this.lockService.withUserBalanceLock(userId, async () => {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundException('User not found');
}
// Saldo REAL (source of truth), NAO o cache user.balanceCents
const realBalance = await this.transactionService.getUserBalance(userId);
if (realBalance < caseData.priceCents) {
throw new BadRequestException('Insufficient balance');
}
const serverSeed = this.provablyFairService.generateServerSeed();
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
// Nonce monotonico por usuario (redis.incr('nonce:{userId}'))
const nonce = await this.nonceService.getNextNonce(userId);
const roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce);
const flipThreshold = caseData.flipThreshold;
const allItems = caseData.items;
const isFlip = this.flipService.shouldTriggerFlip(roll, flipThreshold);
const normalItems =
flipThreshold === null ? allItems : allItems.filter((ci) => ci.hashRangeStart < flipThreshold);
const normalItemsData = this.prepareFlipItemsForRoulette(normalItems);
const flipItems =
flipThreshold === null ? [] : allItems.filter((ci) => ci.hashRangeStart >= flipThreshold);
const flipItemsData = this.prepareFlipItemsForRoulette(flipItems);
// Selecao do item: FLIP primeiro (selectFlipWinner), fallback getItemByRoll
let wonItem;
let actualIsFlip = false;
if (isFlip && flipItems.length > 0) {
const flipWinner = this.flipService.selectFlipWinner(flipItems, roll);
if (flipWinner) {
wonItem = flipWinner;
actualIsFlip = true;
}
}
if (!wonItem) {
wonItem = this.flipService.getItemByRoll(allItems, roll);
}
// Buff STREAMER: reroll determinístico (sufixos _buff_check e _buff)
if (user.role === 'STREAMER' && user.affiliateBuffPercentage > 0) {
const buffCheckRoll = this.provablyFairService.calculateRoll(
serverSeed + '_buff_check', finalClientSeed, nonce,
) % 100;
if (buffCheckRoll < user.affiliateBuffPercentage) {
const buffRoll = this.provablyFairService.calculateRoll(
serverSeed + '_buff', finalClientSeed, nonce,
);
let buffItem;
if (actualIsFlip && flipItems.length > 0) {
buffItem = this.flipService.selectFlipWinner(flipItems, buffRoll);
}
if (!buffItem) {
buffItem = this.flipService.getItemByRoll(allItems, buffRoll);
}
if (buffItem && buffItem.item.valueCentsBrl > wonItem.item.valueCentsBrl) {
wonItem = buffItem;
}
}
}
const profitCents = wonItem.item.valueCentsBrl - caseData.priceCents;
// Tudo que muda estado numa unica transacao atomica (timeout 15000ms)
const result = await this.prisma.$transaction(async (tx) => {
await this.transactionService.debit(
userId,
caseData.priceCents,
`Case opening: ${caseData.name}`,
{ action: 'CASE_OPEN', caseId: caseData.id.toString(), isFlip: actualIsFlip },
tx,
);
const opening = await this.caseOpeningRepository.create({
userId,
caseId,
itemId: wonItem.item.id,
pricePaidCents: caseData.priceCents,
itemValueCents: wonItem.item.valueCentsBrl,
profitCents,
serverSeed,
serverSeedHash,
clientSeed: finalClientSeed,
nonce,
roll,
isFlip: actualIsFlip,
flipServerSeed: null,
flipClientSeed: null,
flipNonce: null,
flipRoll: null,
}, tx);
const inventoryItem = await this.userInventoryRepository.create({
userId,
itemId: wonItem.item.id,
status: 'AVAILABLE',
acquiredFrom: `Caixa - ${caseData.name}`,
acquiredValueCents: wonItem.item.valueCentsBrl,
}, tx);
// FTB (firstBetAt) + wagered atomicos no mesmo row/tx
await tx.user.updateMany({
where: { id: userId, firstBetAt: null },
data: { firstBetAt: new Date() },
});
await tx.user.update({
where: { id: userId },
data: { totalWageredCents: { increment: caseData.priceCents } },
});
return {
opening,
item: wonItem.item,
dropChance: wonItem.dropChance,
inventoryItemId: inventoryItem.id,
serverSeedHash,
clientSeed: finalClientSeed,
nonce,
roll,
isFlip: actualIsFlip,
flipRoll: actualIsFlip ? roll : undefined,
flipItems: actualIsFlip ? flipItemsData : undefined,
normalItems: normalItemsData,
};
}, { timeout: 15000 });
// Saldo REAL pos-commit -> WebSocket
const realBalanceCents = await this.transactionService.getUserBalance(userId);
this.webSocketGateway.emitBalanceUpdate(userId, realBalanceCents);
await this.auditService.logUserAction(
userId, 'CASE_OPENED', 'CaseOpening', result.opening.id.toString(),
{ caseId: caseId.toString(), itemWon: result.item.name, roll: result.roll, isFlip: result.isFlip },
);
// Live drop com delay (12s normal / 30s FLIP)
const liveDropDelay = result.isFlip ? 30000 : 12000;
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: user.username,
userAvatar: user.avatarUrl || undefined,
itemName: result.item.name,
itemImage: result.item.imageUrl,
itemValueCents: result.item.valueCentsBrl,
rarity: result.item.rarity,
source: 'case',
timestamp: new Date(),
dropChance: result.dropChance,
caseImage: caseData.imageUrl || undefined,
caseName: caseData.name || undefined,
});
}, liveDropDelay);
return result;
});
}Lock Distribuido (Redlock)
O lock e adquirido via LockService.withUserBalanceLock() usando Redlock:
// src/infrastructure/locks/lock.service.ts
async withUserBalanceLock<T>(userId: bigint, callback: () => Promise<T>): Promise<T> {
return this.withLock(`user:balance:${userId}`, callback, 10000);
}
async withLock<T>(resource: string, callback: () => Promise<T>, ttl: number = 5000): Promise<T> {
const lock = await this.acquireLock(resource, ttl);
try {
return await callback();
} finally {
await this.releaseLock(lock);
}
}Parametros do Redlock:
driftFactor: 0.01retryCount: 10retryDelay: 200msretryJitter: 200msautomaticExtensionThreshold: 500ms- TTL do lock de saldo: 10.000ms (10 segundos)
Debito (Double-Entry Bookkeeping)
O debito e feito via TransactionService.debit() que internamente chama createDoubleEntry():
// src/application/services/transaction.service.ts
async debit(
userId: bigint,
amountCents: bigint,
reason: string,
metadata?: any,
externalTx?: PrismaTransactionClient,
): Promise<any> {
return this.createDoubleEntry(
userId,
this.SYSTEM_USER_ID, // BigInt(0)
amountCents,
reason,
metadata,
externalTx,
);
}Dentro de createDoubleEntry:
- Valida que
amountCents > 0 - Busca o usuario de debito
- Busca saldo REAL via
transactionRepository.getUserBalance(debitUserId) - Valida
currentBalance >= amountCents - Cria transacao DEBIT (usuario -> sistema)
- Cria transacao CREDIT (sistema -> usuario)
- Vincula as duas transacoes via
relatedTransactionId - Atualiza cache
user.balanceCentspara usuarios reais (nao SYSTEM_USER_ID = 0)
Calculo de Probabilidades e Hash Ranges
Implementado em src/application/services/probability.service.ts.
Constantes
const HASH_MAX = 10000000; // Range: 0 a 9.999.999Calculo de Probabilidades
// src/application/services/probability.service.ts
async calculateProbabilities(caseId: bigint) {
const caseItems = await this.caseRepository.getCaseItems(caseId);
const totalWeight = caseItems.reduce((sum, item) => sum + item.weight, 0);
if (totalWeight === 0) {
throw new Error('Total weight cannot be zero');
}
const probabilities = caseItems.map((caseItem) => ({
caseItemId: caseItem.id,
itemId: caseItem.itemId,
itemName: caseItem.item.name,
weight: caseItem.weight,
dropChance: (caseItem.weight / totalWeight) * 100,
}));
const totalProbability = probabilities.reduce((sum, p) => sum + p.dropChance, 0);
if (Math.abs(totalProbability - 100) > 0.01) {
throw new Error(`Total probability must be 100%, got ${totalProbability}%`);
}
return probabilities;
}Atribuicao de Hash Ranges
// src/application/services/probability.service.ts
async assignHashRanges(caseId: bigint) {
const probabilities = await this.calculateProbabilities(caseId);
const HASH_MAX = 10000000;
let currentStart = 0;
const ranges = probabilities.map((prob) => {
const range = Math.floor((prob.dropChance / 100) * HASH_MAX);
const hashRangeStart = currentStart;
const hashRangeEnd = currentStart + range - 1;
currentStart += range;
return {
caseItemId: prob.caseItemId,
hashRangeStart,
hashRangeEnd,
dropChance: prob.dropChance,
};
});
// Garante que o ultimo item cubra ate o final do range
if (ranges.length > 0) {
ranges[ranges.length - 1].hashRangeEnd = HASH_MAX - 1;
}
// Persiste no banco
await Promise.all(
ranges.map((range) =>
this.caseRepository.updateCaseItem(range.caseItemId, {
hashRangeStart: range.hashRangeStart,
hashRangeEnd: range.hashRangeEnd,
dropChance: range.dropChance,
}),
),
);
return ranges;
}Validacao
async validateProbabilities(caseId: bigint): Promise<boolean> {
const probabilities = await this.calculateProbabilities(caseId);
const total = probabilities.reduce((sum, p) => sum + p.dropChance, 0);
return Math.abs(total - 100) < 0.01;
}Exemplo de Hash Ranges
Supondo uma caixa com 5 itens e os seguintes pesos:
| Item | Weight | Drop Chance | Hash Range Start | Hash Range End |
|---|---|---|---|---|
| Consumer (Barato) | 7992 | 79.92% | 0 | 7.991.999 |
| Industrial | 1598 | 15.98% | 7.992.000 | 9.589.999 |
| Mil-Spec | 320 | 3.20% | 9.590.000 | 9.909.999 |
| Restricted | 64 | 0.64% | 9.910.000 | 9.973.999 |
| Classified (Raro) | 26 | 0.26% | 9.974.000 | 9.999.999 |
Regras:
- O ultimo item SEMPRE tem
hashRangeEnd = HASH_MAX - 1(9.999.999) para evitar gaps - Itens baratos ficam nos ranges baixos (alta probabilidade)
- Itens caros/raros ficam nos ranges altos (baixa probabilidade)
- A soma de todos os ranges DEVE cobrir exatamente 0 a 9.999.999
Calculo do Roll (Provably Fair)
Implementado em src/application/services/provably-fair.service.ts.
Geracao do Server Seed
generateServerSeed(): string {
return randomBytes(32).toString('hex');
}Resultado: String hexadecimal de 64 caracteres (256 bits).
Hash do Server Seed
hashServerSeed(seed: string): string {
return createHmac('sha256', seed).digest('hex');
}O hash e enviado ao usuario ANTES da abertura. O server seed real so e revelado na verificacao.
Geracao/Resolucao do Client Seed
generateClientSeed(userInput?: string): string {
if (userInput && userInput.length > 0) {
return userInput;
}
return randomBytes(32).toString('hex');
}Se o usuario fornece um client seed customizado, ele e usado diretamente. Caso contrario, e gerado automaticamente com 32 bytes aleatorios (64 caracteres hex).
Calculo do Roll
calculateRoll(
serverSeed: string,
clientSeed: string,
nonce: number,
max: number = 10000000,
): number {
const hmac = createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
const hashPrefix = hash.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
let result = decimal;
if (result >= maxSafeValue) {
const hmac2 = createHmac('sha256', serverSeed);
hmac2.update(`${clientSeed}:${nonce}:retry`);
const hash2 = hmac2.digest('hex');
result = parseInt(hash2.substring(0, 13), 16);
}
const roll = (result % max) + 1;
return roll;
}Detalhes do algoritmo:
- Cria HMAC-SHA256 usando
serverSeedcomo chave - Atualiza com
${clientSeed}:${nonce}como mensagem - Gera hash hexadecimal (64 caracteres)
- Extrai os primeiros 13 caracteres do hash
- Converte para decimal (
parseInt(hashPrefix, 16)) - Verifica overflow de seguranca: se
decimal >= maxSafeValue, recalcula com suffix:retry - Aplica modulo:
(result % 10000000) + 1 - Resultado final: numero de 1 a 10.000.000
Importante: O roll retornado e de 1 a 10.000.000, mas os hash ranges no banco sao de 0 a 9.999.999. A selecao do item usa roll >= hashRangeStart && roll <= hashRangeEnd, entao o roll precisa ser ajustado ou os ranges consideram esse offset. No codigo atual, getItemByRoll do FlipService faz a comparacao direta.
Calculo do Nonce
const nonce = await this.nonceService.getNextNonce(userId);
// nonce.service.ts -> redis.incr('nonce:{userId}')Fonte: NonceService.getNextNonce(userId) executa redis.incr('nonce:{userId}').
- Contador monotonico por usuario, persistido no Redis
- Compartilhado por todos os jogos que usam o
NonceService(case, battle, upgrade) - Cada abertura (unica ou multipla) puxa um valor unico e crescente, sem colisao
- Como e sequencial, o mesmo usuario nunca repete um nonce
Na abertura multipla: executeMultiple chama getNextNonce(userId) para cada draw dentro do loop, antes de abrir a transacao unica.
Selecao do Item pelo Roll
Implementado em src/application/services/flip.service.ts. Quando o FLIP ativa, o OpenCaseUseCase chama primeiro selectFlipWinner(flipItems, roll) (busca so no tier raro); o fallback abaixo, getItemByRoll(allItems, roll), cobre todos os itens da caixa:
getItemByRoll(items: any[], roll: number) {
const matchedItem = items.find(
(item) => roll >= item.hashRangeStart && roll <= item.hashRangeEnd,
);
if (!matchedItem) {
throw new Error(`No item found for roll ${roll}`);
}
return matchedItem;
}Tambem existe a versao em ProbabilityService.getItemByRoll():
async getItemByRoll(caseId: bigint, roll: number) {
const caseItems = await this.caseRepository.getCaseItems(caseId);
const matchedItem = caseItems.find(
(item) => roll >= item.hashRangeStart && roll <= item.hashRangeEnd,
);
if (!matchedItem) {
throw new Error(`No item found for roll ${roll} in case ${caseId}`);
}
return matchedItem;
}Se nenhum item for encontrado para o roll, uma excecao e lancada. Isso indica que os hash ranges nao cobrem todo o espectro (bug de configuracao).
Calculo de Lucro
const profitCents = wonItem.item.valueCentsBrl - caseData.priceCents;- Lucro positivo: Jogador ganhou item mais valioso que o preco da caixa
- Lucro negativo: Jogador ganhou item menos valioso que o preco da caixa
- Ambos os valores sao BigInt em centavos de BRL
O lucro e salvo no campo profitCents do CaseOpening e retornado formatado na response da API via MoneyService.format().
MoneyService
// src/application/services/money.service.ts
format(cents: bigint, locale = 'pt-BR', currency = 'BRL'): string {
const reais = Number(cents) / 100;
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(reais);
}Exemplo: format(BigInt(-1410)) retorna "-R$ 14,10".
Abertura Multipla
Implementada em src/application/use-cases/case-opening/open-multiple-cases.use-case.ts.
// open-multiple-cases.use-case.ts apenas DELEGA:
async execute(userId: bigint, caseId: bigint, quantity: number, clientSeed?: string) {
return this.openCaseUseCase.executeMultiple(userId, caseId, quantity, clientSeed);
}
// A logica real fica em open-case.use-case.ts -> executeMultiple():
async executeMultiple(userId: bigint, caseId: bigint, quantity: number, clientSeed?: string) {
if (quantity < 1 || quantity > 100) {
throw new BadRequestException('Quantity must be between 1 and 100');
}
const caseData = await this.caseRepository.findByIdCached(caseId);
if (!caseData || caseData.status !== 'ACTIVE') {
throw new BadRequestException('Case is not available');
}
return this.lockService.withUserBalanceLock(userId, async () => {
const user = await this.userRepository.findById(userId);
if (!user) throw new NotFoundException('User not found');
const totalCost = caseData.priceCents * BigInt(quantity);
const realBalance = await this.transactionService.getUserBalance(userId);
if (realBalance < totalCost) {
throw new BadRequestException('Insufficient balance');
}
// 1) Gera TODOS os draws (seeds proprios + nonce sequencial + item) FORA da tx
const draws = [];
for (let i = 0; i < quantity; i++) {
const serverSeed = this.provablyFairService.generateServerSeed();
const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
const nonce = await this.nonceService.getNextNonce(userId);
const roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce);
// ... shouldTriggerFlip + selectFlipWinner/getItemByRoll + buff STREAMER ...
draws.push({ serverSeed, finalClientSeed, nonce, roll, wonItem, actualIsFlip });
}
// 2) Persiste todos os draws numa UNICA transacao atomica (timeout 20000ms)
const created = await this.prisma.$transaction(async (tx) => {
for (const d of draws) {
await this.transactionService.debit(userId, caseData.priceCents, /* ... */ tx);
// create CaseOpening (flip fields null) + create UserInventory (acquiredValueCents)
}
// firstBetAt + totalWageredCents (increment totalCost) atomicos
return out;
}, { timeout: 20000 });
// 3) Pos-commit: emit saldo, audit e live drops (delay por draw)
return created.map(/* ... */);
});
}Comportamento:
- Quantidade minima: 1, maxima: 100
OpenMultipleCasesUseCaseapenas delega paraOpenCaseUseCase.executeMultiple()- Um unico lock por usuario (
withUserBalanceLock) cobre toda a operacao - Validacao de saldo total (REAL, via
getUserBalance) feita ANTES de gerar os draws (priceCents * quantity) - Todos os draws (seeds proprios + nonce sequencial + item) sao gerados antes da transacao
- Uma unica transacao Prisma (timeout 20000ms) persiste TODOS os draws (debit + opening + inventory + firstBetAt/wagered)
- Se qualquer draw falhar, a transacao inteira sofre rollback (tudo ou nada)
- Cada draw gera seu proprio live drop com delay independente
Preparacao de Itens para Roleta
O metodo prepareFlipItemsForRoulette formata os itens para consumo do frontend:
private prepareFlipItemsForRoulette(items: any[]): any[] {
const sortedItems = [...items].sort(
(a, b) => Number(b.item.valueCentsBrl) - Number(a.item.valueCentsBrl),
);
return sortedItems.map((item) => ({
id: item.id.toString(),
itemId: item.item.id.toString(),
itemName: item.item.name,
itemImage: item.item.imageUrl,
rarity: item.item.rarity,
valueCents: item.item.valueCentsBrl.toString(),
valueFormatted: (Number(item.item.valueCentsBrl) / 100).toFixed(2),
dropChance: item.dropChance,
weight: item.weight,
weaponCategory: item.item.weaponCategory,
exterior: item.item.exterior ?? undefined,
isStatTrak: item.item.isStatTrak,
}));
}Comportamento:
- Ordena itens por
valueCentsBrldescendente (mais caros primeiro) - Retorna TODOS os itens (nao limita a 15)
- Converte BigInt para string (necessario para serializacao JSON)
valueCentsno retorno e na verdadevalueCentsBrl(BRL, nao USD)valueFormattede o valor em reais com 2 casas decimais (ex:"1863.21")- Usado tanto para
normalItemsquanto paraflipItems
Eventos WebSocket
balance:updated (Privado - sala user:{userId})
Emitido apos o commit da transacao:
this.webSocketGateway.emitBalanceUpdate(userId, realBalanceCents);// Implementacao no WebSocketGatewayService
emitBalanceUpdate(userId: bigint, balanceCents: bigint) {
const roomName = `user:${userId}`;
this.server.to(roomName).emit('balance:updated', {
balanceCents: balanceCents.toString()
});
}Payload:
{
"balanceCents": "186321"
}REGRA: balanceCents e SEMPRE uma string representando centavos inteiros. Frontend faz parseInt(data.balanceCents, 10).
live:drop (Global - broadcast para todos)
Emitido com delay apos a abertura:
emitLiveDrop(drop: {
userName: string;
userAvatar?: string;
itemName: string;
itemImage: string;
itemValueCents: bigint | string;
rarity: string;
source: 'case' | 'upgrade' | 'swap' | 'battle' | 'event';
timestamp: Date;
dropChance: number;
caseImage?: string;
}) {
const valueCents = typeof drop.itemValueCents === 'bigint'
? drop.itemValueCents
: BigInt(drop.itemValueCents);
const isTopDrop = valueCents >= BigInt(100000); // >= R$ 1.000,00
this.server.emit('live:drop', {
userName: drop.userName,
userAvatar: drop.userAvatar,
itemName: drop.itemName,
itemImage: drop.itemImage,
itemValueCents: valueCents.toString(),
rarity: drop.rarity,
source: drop.source,
timestamp: drop.timestamp,
dropChance: drop.dropChance,
isTopDrop,
caseImage: drop.caseImage,
});
}Payload:
{
"userName": "Player123",
"userAvatar": "https://...",
"itemName": "AWP | Dragon Lore",
"itemImage": "https://...",
"itemValueCents": "70765",
"rarity": "EXTRAORDINARY",
"source": "case",
"timestamp": "2026-02-11T10:30:00.000Z",
"dropChance": 0.05,
"isTopDrop": false,
"caseImage": "https://..."
}isTopDrop: true quando valueCents >= 100000 (R$ 1.000,00 ou mais). Usado pelo frontend para animacoes especiais.
case:opened (Global - broadcast para todos)
emitCaseOpening(opening: any) {
this.server.emit('case:opened', {
id: opening.id.toString(),
userId: opening.userId.toString(),
username: opening.username,
userAvatar: opening.userAvatar,
caseName: opening.caseName,
caseImage: opening.caseImage,
itemName: opening.itemName,
itemImage: opening.itemImage,
itemRarity: opening.itemRarity,
itemValue: opening.itemValue,
bgColor: opening.bgColor,
bottomColor: opening.bottomColor,
isFlip: opening.isFlip,
timestamp: opening.timestamp,
});
}Live Drop Delay
O live drop e emitido com delay para sincronizar com a animacao do frontend:
const liveDropDelay = result.isFlip ? 30000 : 12000;| Tipo | Delay | Motivo |
|---|---|---|
| Normal | 12.000ms (12s) | Tempo da animacao da roleta + pull |
| FLIP | 30.000ms (30s) | Primeira roleta (10s) + animacao faca (~8s) + segunda roleta (10s) + pull |
O setTimeout e executado FORA da transacao, apos o commit. Se o processo reiniciar antes do delay, o live drop nao sera emitido (aceitavel - nao afeta integridade).
Endpoints da API
Todos os endpoints estao nos controllers:
src/presentation/controllers/case.controller.ts- CRUD de caixas (publico)src/presentation/controllers/case-opening.controller.ts- Abertura e historico
GET /cases
Descricao: Lista todas as caixas com filtros opcionais. Autenticacao: Publica (@Public()) Controller: CaseController.getCases()
Query Parameters (GetCasesQueryDto):
| Parametro | Tipo | Obrigatorio | Valores |
|---|---|---|---|
| categoryId | string | Nao | ID da categoria (BigInt como string) |
| riskLevel | enum | Nao | LOW, MEDIUM, HIGH, MAXIMUM |
| status | enum | Nao | ACTIVE, INACTIVE, COMING_SOON |
| isHot | boolean | Nao | true ou false |
| isNew | boolean | Nao | true ou false |
Response (CaseResponseDto[]):
[
{
"id": "123456789",
"name": "Snow Case",
"slug": "snow-case",
"description": "Caixa com skins de inverno",
"imageUrl": "https://cdn.example.com/cases/snow.png",
"bgColor": "#1a2b3c",
"priceCents": "1590",
"priceFormatted": "R$ 15,90",
"riskLevel": "MEDIUM",
"category": {
"id": "1",
"name": "Populares",
"slug": "populares"
},
"isHot": true,
"isNew": false,
"status": "ACTIVE",
"itemCount": 15,
"bestItem": {
"id": "999",
"name": "Butterfly Knife | Emerald",
"imageUrl": "https://...",
"rarity": "EXTRAORDINARY",
"valueCents": "70765",
"valueFormatted": "R$ 707,65"
}
}
]Logica do bestItem: E o item com maior valueCentsBrl dentro da caixa:
const bestItem = c.items?.length
? c.items.reduce((best, current) =>
current.item.valueCentsBrl > best.item.valueCentsBrl ? current : best,
)
: null;GET /cases/categories
Descricao: Lista todas as categorias de caixas. Autenticacao: Publica (@Public()) Controller: CaseController.getCategories()
Response (CaseCategoryResponseDto[]):
[
{
"id": "1",
"name": "Populares",
"slug": "populares",
"description": "As caixas mais abertas",
"order": 1,
"status": "ACTIVE"
}
]GET /cases/:id
Descricao: Detalhes completos de uma caixa com todos os itens. Autenticacao: Publica (@Public()) Controller: CaseController.getCaseById()
Response (CaseResponseDto):
{
"id": "123456789",
"name": "Snow Case",
"slug": "snow-case",
"description": "Caixa com skins de inverno",
"imageUrl": "https://cdn.example.com/cases/snow.png",
"bgColor": "#1a2b3c",
"priceCents": "1590",
"priceFormatted": "R$ 15,90",
"riskLevel": "MEDIUM",
"category": {
"id": "1",
"name": "Populares",
"slug": "populares"
},
"isHot": true,
"isNew": false,
"status": "ACTIVE",
"items": [
{
"id": "1",
"itemId": "456",
"itemName": "MAG-7 | Wildwood",
"itemImage": "https://...",
"rarity": "CONSUMER",
"valueCents": "37",
"valueFormatted": "R$ 0,37",
"dropChance": 66.82,
"weight": 6682,
"weaponCategory": "SHOTGUN",
"isStatTrak": false
},
{
"id": "15",
"itemId": "999",
"itemName": "Butterfly Knife | Emerald",
"itemImage": "https://...",
"rarity": "EXTRAORDINARY",
"valueCents": "70765",
"valueFormatted": "R$ 707,65",
"dropChance": 0.01,
"weight": 1,
"weaponCategory": "KNIFE",
"exterior": "FACTORY_NEW",
"isStatTrak": false
}
]
}Nota: O campo valueCents na response dos items e na verdade valueCentsBrl (BRL). O mapeamento e feito no controller:
valueCents: ci.item.valueCentsBrl.toString(),
valueFormatted: this.moneyService.format(ci.item.valueCentsBrl),GET /cases/:id/items
Descricao: Lista apenas os itens de uma caixa. Autenticacao: Publica (@Public()) Controller: CaseController.getCaseItems()
Response (CaseItemResponseDto[]):
[
{
"id": "1",
"itemId": "456",
"itemName": "MAG-7 | Wildwood",
"itemImage": "https://...",
"rarity": "CONSUMER",
"valueCents": "37",
"valueFormatted": "R$ 0,37",
"dropChance": 66.82,
"weight": 6682,
"weaponCategory": "SHOTGUN",
"isStatTrak": false
}
]GET /cases/:id/probabilities
Descricao: Retorna as probabilidades e hash ranges de cada item da caixa. Autenticacao: Publica (@Public()) Controller: CaseController.getCaseProbabilities()
Response (CaseProbabilityResponseDto[]):
[
{
"itemId": "456",
"itemName": "MAG-7 | Wildwood",
"rarity": "CONSUMER",
"valueCents": "37",
"valueFormatted": "R$ 0,37",
"dropChance": 66.82,
"weight": 6682,
"hashRangeStart": 0,
"hashRangeEnd": 6681999
},
{
"itemId": "999",
"itemName": "Butterfly Knife | Emerald",
"rarity": "EXTRAORDINARY",
"valueCents": "70765",
"valueFormatted": "R$ 707,65",
"dropChance": 0.01,
"weight": 1,
"hashRangeStart": 9999899,
"hashRangeEnd": 9999999
}
]POST /cases/:id/open
Descricao: Abre uma ou mais caixas. Autenticacao: Requerida (@UseGuards(AuthGuard)) Controller: CaseOpeningController.openCase()
Request Body (OpenCaseDto):
{
"quantity": 1,
"clientSeed": "minha-seed-customizada"
}| Campo | Tipo | Obrigatorio | Validacao | Default |
|---|---|---|---|---|
| quantity | number | Nao | @Min(1) @Max(100) | 1 |
| clientSeed | string | Nao | @IsString() | Gerado automaticamente |
Response (quantity = 1) (CaseOpeningResponseDto):
{
"id": "789012345",
"inventoryItemId": "111222333",
"userId": "100",
"caseId": "123456789",
"caseName": "",
"caseImage": "",
"itemId": "456",
"itemName": "AK-47 | Redline",
"itemImage": "https://...",
"itemRarity": "CLASSIFIED",
"itemValueCents": "15000",
"itemValueFormatted": "R$ 150,00",
"pricePaidCents": "1590",
"pricePaidFormatted": "R$ 15,90",
"profitCents": "13410",
"profitFormatted": "R$ 134,10",
"serverSeedHash": "a1b2c3d4e5f6...",
"clientSeed": "minha-seed-customizada",
"nonce": 456789,
"roll": 9985432,
"isFlip": false,
"normalItems": [
{
"id": "1",
"itemId": "456",
"itemName": "AK-47 | Redline",
"itemImage": "https://...",
"rarity": "CLASSIFIED",
"valueCents": "15000",
"valueFormatted": "150.00",
"dropChance": 0.26,
"weight": 26,
"weaponCategory": "RIFLE",
"isStatTrak": false
}
],
"createdAt": "2026-02-11T10:30:00.000Z"
}Observacoes sobre a response:
caseNameecaseImagesao retornados como string vazia ("") na abertura unicaflipRollso e retornado quando o FLIP ativa (isFlip = true), igual aoroll; ausente quandoisFlip = falseflipItemseundefinedquandoisFlip = false, presente quandoisFlip = truenormalItemse SEMPRE retornado (itens do tier normal,hashRangeStart < flipThreshold, ordenados por valor desc)- Todos os valores BigInt sao convertidos para string
Response (quantity > 1) (CaseOpeningResponseDto[]):
Retorna um array com o mesmo formato acima, um elemento por abertura.
GET /cases/openings
Descricao: Historico de aberturas do usuario autenticado. Autenticacao: Requerida (@UseGuards(AuthGuard)) Controller: CaseOpeningController.getOpeningHistory()
Query Parameters:
| Parametro | Tipo | Default |
|---|---|---|
| limit | number | 50 |
| offset | number | 0 |
Response (CaseOpeningResponseDto[]):
[
{
"id": "789012345",
"userId": "100",
"caseId": "123456789",
"caseName": "Snow Case",
"caseImage": "https://...",
"itemId": "456",
"itemName": "AK-47 | Redline",
"itemImage": "https://...",
"itemRarity": "CLASSIFIED",
"itemValueCents": "15000",
"itemValueFormatted": "R$ 150,00",
"pricePaidCents": "1590",
"pricePaidFormatted": "R$ 15,90",
"profitCents": "13410",
"profitFormatted": "R$ 134,10",
"serverSeedHash": "a1b2c3d4e5f6...",
"clientSeed": "minha-seed-customizada",
"nonce": 456789,
"roll": 9985432,
"isFlip": false,
"createdAt": "2026-02-11T10:30:00.000Z"
}
]POST /cases/openings/:id/verify
Descricao: Verifica a integridade de uma abertura via Provably Fair. Autenticacao: Requerida (@UseGuards(AuthGuard)) Controller: CaseOpeningController.verifyOpening()
Response (VerifyOpeningResponseDto):
{
"isValid": true,
"isServerSeedValid": true,
"isRollValid": true,
"opening": {
"id": "789012345",
"serverSeed": "a1b2c3d4e5f6789...",
"serverSeedHash": "hash-do-server-seed...",
"clientSeed": "minha-seed-customizada",
"nonce": 456789,
"roll": 9985432,
"isFlip": false
}
}Logica de verificacao (VerifyCaseOpeningUseCase):
async execute(openingId: bigint) {
const opening = await this.caseOpeningRepository.findById(openingId);
if (!opening) {
throw new NotFoundException('Opening not found');
}
const isServerSeedValid = this.provablyFairService.verifyServerSeedHash(
opening.serverSeed,
opening.serverSeedHash,
);
const isRollValid = this.provablyFairService.verifyResult(
opening.serverSeed,
opening.clientSeed,
opening.nonce,
opening.roll,
);
return {
isValid: isServerSeedValid && isRollValid,
isServerSeedValid,
isRollValid,
opening: {
id: opening.id,
serverSeed: opening.serverSeed,
serverSeedHash: opening.serverSeedHash,
clientSeed: opening.clientSeed,
nonce: opening.nonce,
roll: opening.roll,
isFlip: opening.isFlip,
},
};
}Duas verificacoes sao feitas:
- isServerSeedValid:
HMAC-SHA256(serverSeed) === serverSeedHasharmazenado - isRollValid:
calculateRoll(serverSeed, clientSeed, nonce) === rollarmazenado
GET /cases/openings/recent
Descricao: Retorna aberturas recentes de todos os usuarios (live drops publicos). Autenticacao: Publica (@Public()) Controller: CaseOpeningController.getRecentOpenings()
Query Parameters:
| Parametro | Tipo | Default | Max |
|---|---|---|---|
| limit | number | 50 | 100 |
Response (RecentOpeningDto[]):
[
{
"id": "789012345",
"username": "Player123",
"userAvatar": "https://...",
"caseName": "Snow Case",
"caseImage": "https://...",
"itemName": "AK-47 | Redline",
"itemImage": "https://...",
"itemRarity": "CLASSIFIED",
"itemValueCents": "15000",
"itemValueFormatted": "R$ 150,00",
"isFlip": false,
"createdAt": "2026-02-11T10:30:00.000Z"
}
]DTOs
OpenCaseDto
export class OpenCaseDto {
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
@Type(() => Number)
quantity?: number = 1;
@IsOptional()
@IsString()
clientSeed?: string;
}CaseOpeningResponseDto
export class CaseOpeningResponseDto {
id: string;
inventoryItemId?: string;
userId: string;
caseId: string;
caseName: string;
caseImage: string;
itemId: string;
itemName: string;
itemImage: string;
itemRarity: string;
itemValueCents: string;
itemValueFormatted: string;
pricePaidCents: string;
pricePaidFormatted: string;
profitCents: string;
profitFormatted: string;
serverSeedHash: string;
clientSeed: string;
nonce: number;
roll: number;
isFlip: boolean;
flipRoll?: number;
flipItems?: FlipItemDto[];
normalItems?: FlipItemDto[];
createdAt: Date;
}FlipItemDto
export class FlipItemDto {
id: string;
itemId: string;
itemName: string;
itemImage: string;
rarity: string;
valueCents: string;
valueFormatted: string;
dropChance: number;
weight: number;
weaponCategory: string;
exterior?: string;
isStatTrak: boolean;
}VerifyOpeningResponseDto
export class VerifyOpeningResponseDto {
isValid: boolean;
isServerSeedValid: boolean;
isRollValid: boolean;
opening: {
id: string;
serverSeed: string;
serverSeedHash: string;
clientSeed: string;
nonce: number;
roll: number;
isFlip: boolean;
};
}RecentOpeningDto
export class RecentOpeningDto {
id: string;
username: string;
userAvatar: string;
caseName: string;
caseImage: string;
itemName: string;
itemImage: string;
itemRarity: string;
itemValueCents: string;
itemValueFormatted: string;
isFlip: boolean;
createdAt: Date;
}GetCasesQueryDto
export class GetCasesQueryDto {
@IsOptional()
@IsString()
categoryId?: string;
@IsOptional()
@IsEnum(['LOW', 'MEDIUM', 'HIGH', 'MAXIMUM'])
riskLevel?: 'LOW' | 'MEDIUM' | 'HIGH' | 'MAXIMUM';
@IsOptional()
@IsEnum(['ACTIVE', 'INACTIVE', 'COMING_SOON'])
status?: 'ACTIVE' | 'INACTIVE' | 'COMING_SOON';
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isHot?: boolean;
@IsOptional()
@Type(() => Boolean)
@IsBoolean()
isNew?: boolean;
}CaseProbabilityResponseDto
export class CaseProbabilityResponseDto {
itemId: string;
itemName: string;
rarity: string;
valueCents: string;
valueFormatted: string;
dropChance: number;
weight: number;
hashRangeStart: number;
hashRangeEnd: number;
}Verificacao Provably Fair
O sistema permite que o usuario verifique a integridade de qualquer abertura:
// src/application/services/provably-fair.service.ts
verifyResult(
serverSeed: string,
clientSeed: string,
nonce: number,
expectedRoll: number,
max: number = 10000000,
): boolean {
const calculatedRoll = this.calculateRoll(serverSeed, clientSeed, nonce, max);
return calculatedRoll === expectedRoll;
}
verifyServerSeedHash(serverSeed: string, serverSeedHash: string): boolean {
const calculatedHash = this.hashServerSeed(serverSeed);
return calculatedHash === serverSeedHash;
}Fluxo de verificacao:
- Usuario recebe
serverSeedHashno momento da abertura (ANTES do resultado) - Apos o resultado, usuario acessa
POST /cases/openings/:id/verify - Backend revela o
serverSeedoriginal - Backend recalcula:
HMAC-SHA256(serverSeed) === serverSeedHashsalvo - Backend recalcula:
calculateRoll(serverSeed, clientSeed, nonce) === rollsalvo - Se ambos batem, a abertura e valida e nao foi manipulada
Casos de Erro
| Erro | HTTP Status | Mensagem | Causa |
|---|---|---|---|
| Caixa nao encontrada | 404 | Case with ID {id} not found | ID invalido ou inexistente |
| Caixa inativa | 400 | Case is not available | status !== 'ACTIVE' |
| Usuario nao encontrado | 404 | User not found | Sessao invalida ou usuario deletado |
| Saldo insuficiente | 400 | Insufficient balance | transactionService.getUserBalance(userId) < priceCents |
| Quantidade invalida | 400 | Quantity must be between 1 and 100 | `quantity < 1 |
| Saldo insuficiente (multipla) | 400 | Insufficient balance. Required: X cents, Available: Y cents | balanceCents < priceCents * quantity |
| Roll sem item | 500 | No item found for roll {roll} | Hash ranges nao cobrem todo o espectro |
| Abertura nao encontrada (verify) | 404 | Opening not found | ID de abertura invalido |
| Lock timeout | 500 | Redlock timeout | Outra operacao em andamento para o mesmo usuario |
| Weight zero | 500 | Total weight cannot be zero | Caixa sem itens ou todos com weight 0 |
Debugging
Backend
Logs ja implementados no OpenCaseUseCase:
[OpenCaseUseCase] Finding case with ID: 123456789
[OpenCaseUseCase] Case found: Snow Case Price: 1590
[OpenCaseUseCase] Finding user with ID: 100
[OpenCaseUseCase] User found. Balance BEFORE: 186321 cents = 1863.21 reais
[OpenCaseUseCase] Case price: 1590 cents = 15.90 reais
[OpenCaseUseCase] Balance OK, proceeding...
[OpenCaseUseCase] Debiting user...
[OpenCaseUseCase] Debit successful
[OpenCaseUseCase] Generating provably fair seeds...
[OpenCaseUseCase] Seeds generated
[OpenCaseUseCase] Roll generated: 9985432
[OpenCaseUseCase] Flip threshold: 9974000
[OpenCaseUseCase] Is FLIP: true (roll >= threshold)
[OpenCaseUseCase] Won item: AWP | Dragon Lore (tier FLIP)
[OpenCaseUseCase] Transaction committed. Fetching REAL balance from transactions for user 100...
[OpenCaseUseCase] Real balance from transactions: 184731 cents
[OpenCaseUseCase] Case price was: 1590 cents = 15.90 reaisLogs do WebSocket:
[Balance Update] User: 100, BalanceCents: 184731, Room: user:100
[Balance Update] Clients in room user:100: 1
[Balance Update] Event emitted to room user:100
Emitting live drop: Player123 got AWP | Dragon Lore (0.01%) - isTopDrop: falseVerificacao de Integridade de Ranges
Para verificar se os hash ranges de uma caixa cobrem todo o espectro:
const caseItems = await caseRepository.getCaseItems(caseId);
const totalCoverage = caseItems.reduce(
(sum, item) => sum + (item.hashRangeEnd - item.hashRangeStart + 1),
0,
);
// totalCoverage DEVE ser exatamente 10.000.000Divergencia de Saldo
Se houver divergencia entre o saldo exibido e o real:
- O saldo REAL sempre vem de
transactionService.getUserBalance(userId)(soma de transacoes) user.balanceCentse um CACHE atualizado apos cada transacao- O WebSocket emite o saldo REAL (pos-commit) via
emitBalanceUpdate(userId, realBalanceCents)
Arquivos Relacionados
| Arquivo | Responsabilidade |
|---|---|
src/application/use-cases/case-opening/open-case.use-case.ts | Logica principal de abertura |
src/application/use-cases/case-opening/open-multiple-cases.use-case.ts | Abertura multipla (loop) |
src/application/use-cases/case-opening/verify-case-opening.use-case.ts | Verificacao Provably Fair |
src/application/use-cases/case-opening/get-case-opening-history.use-case.ts | Historico do usuario |
src/application/use-cases/case-opening/get-recent-openings.use-case.ts | Aberturas recentes (publico) |
src/application/services/provably-fair.service.ts | Calculo de roll e verificacao |
src/application/services/nonce.service.ts | Nonce monotonico por usuario (redis.incr('nonce:{userId}')) |
src/application/services/probability.service.ts | Calculo de probabilidades e hash ranges |
src/application/services/flip.service.ts | Sistema FLIP (threshold, itens raros) |
src/application/services/transaction.service.ts | Double-entry bookkeeping |
src/application/services/money.service.ts | Formatacao de valores monetarios |
src/infrastructure/locks/lock.service.ts | Distributed locks (Redlock) |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de eventos WebSocket |
src/presentation/controllers/case.controller.ts | Endpoints CRUD de caixas |
src/presentation/controllers/case-opening.controller.ts | Endpoints de abertura |
src/application/dto/case.dto.ts | DTOs de caixas |
src/application/dto/case-opening.dto.ts | DTOs de abertura |
prisma/schema.prisma | Modelos Case, CaseItem, CaseOpening, UserInventory |
