Sistema de Upgrade
Visao Geral
O sistema de Upgrade permite ao jogador combinar itens do inventario e/ou saldo em BRL para tentar obter um item de maior valor. A probabilidade de sucesso e calculada com base no valor investido versus o valor do item alvo, aplicando house edge e um fator de penalidade progressivo. Todo o processo e Provably Fair com roll de 1 a 10.000.000.
Modelo de Dados (Prisma)
Tabela Upgrade (mapeada como upgrades)
model Upgrade {
id BigInt @id
userId BigInt @map("user_id")
targetItemId BigInt @map("target_item_id")
success Boolean
chancePercent Float @map("chance_percent")
valueInvestedCents BigInt @map("value_invested_cents")
balanceUsedCents BigInt @default(0) @map("balance_used_cents")
targetValueCents BigInt @map("target_value_cents")
serverSeed String @map("server_seed")
serverSeedHash String @map("server_seed_hash")
clientSeed String @map("client_seed")
nonce Int
roll Int
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
targetItem Item @relation(fields: [targetItemId], references: [id])
itemsUsed UpgradeItem[]
@@map("upgrades")
@@index([userId, createdAt])
@@index([success])
}Campos:
| Campo | Tipo | Descricao |
|---|---|---|
id | BigInt | Snowflake ID gerado pelo SnowflakeService |
userId | BigInt | ID do usuario que realizou o upgrade |
targetItemId | BigInt | ID do item alvo (desejado) |
success | Boolean | Se o upgrade foi bem-sucedido |
chancePercent | Float | Porcentagem de chance calculada (ex: 45.32) |
valueInvestedCents | BigInt | Valor total investido em centavos BRL (itens + saldo) |
balanceUsedCents | BigInt | Parte do valor que veio do saldo (centavos BRL) |
targetValueCents | BigInt | Valor do item alvo em centavos BRL (valueCentsBrl) |
serverSeed | String | Server seed usado no calculo do roll |
serverSeedHash | String | HMAC-SHA256 do server seed (entregue antes do roll) |
clientSeed | String | Client seed (fornecido pelo usuario ou gerado) |
nonce | Int | Nonce unico para o roll |
roll | Int | Resultado do roll (1 a 10.000.000) |
createdAt | DateTime | Data/hora da tentativa |
Tabela UpgradeItem (mapeada como upgrade_items)
model UpgradeItem {
id BigInt @id
upgradeId BigInt @map("upgrade_id")
itemId BigInt @map("item_id")
valueCents BigInt @map("value_cents")
upgrade Upgrade @relation(fields: [upgradeId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id])
@@map("upgrade_items")
@@index([upgradeId])
@@index([itemId])
}Registra cada item do inventario usado no upgrade. O campo valueCents armazena o valueCentsBrl do item no momento do upgrade (snapshot do valor).
Status de Inventario Relevantes
enum InventoryItemStatus {
AVAILABLE -- Item disponivel para uso
LOCKED -- Item temporariamente bloqueado (durante processamento)
SOLD -- Item vendido
WITHDRAWN -- Item sacado
USED_IN_UPGRADE -- Item consumido em upgrade (status final)
USED_IN_BATTLE -- Item consumido em batalha
IN_SWAP -- Item em processo de swap
}Transicoes de status durante o upgrade:
AVAILABLE->USED_IN_UPGRADE(consumo atomico viabulkUpdateStatusIf, dentro deprisma.$transaction)- Em caso de erro dentro da transacao, o rollback automatico do Prisma reverte o consumo e os itens permanecem
AVAILABLE
O upgrade NAO usa o status intermediario LOCKED: a mudanca vai direto de AVAILABLE para USED_IN_UPGRADE, de forma atomica. O status LOCKED do enum e usado por outros fluxos (ex: saque de skin).
Formula de Probabilidade
Arquivo: src/application/services/upgrade-probability.service.ts
Constantes
| Constante | Valor Padrao | Variavel de Ambiente | Descricao |
|---|---|---|---|
houseEdge | 0.08 (8%) | UPGRADE_HOUSE_EDGE | Margem da casa |
minChance | 0.5 (0.5%) | UPGRADE_MIN_CHANCE | Chance minima permitida |
maxChance | 75 (75%) | UPGRADE_MAX_CHANCE | Chance maxima (cap) |
| Penalty exponent | 0.2 | Hardcoded | Expoente do fator de penalidade |
| Penalty multiplier | 0.28 | Hardcoded | Multiplicador do fator de penalidade |
Calculo Passo a Passo
calculateChance(valueInvestedCents: bigint, targetValueCents: bigint): number {
if (valueInvestedCents <= BigInt(0) || targetValueCents <= BigInt(0)) {
throw new BadRequestException('Values must be greater than zero');
}
if (valueInvestedCents >= targetValueCents) {
throw new BadRequestException('Invested value must be less than target value');
}
const investedValue = Number(valueInvestedCents);
const targetValue = Number(targetValueCents);
// 1. Razao entre valor investido e valor alvo
const ratio = investedValue / targetValue;
// 2. Chance base: ratio * (1 - houseEdge) * 100
const baseChance = ratio * (1 - this.houseEdge) * 100;
// 3. Fator de penalidade progressivo: ratio^0.2 * 0.28
const penaltyFactor = Math.pow(ratio, 0.2) * 0.28;
// 4. Chance final: baseChance * (1 - penaltyFactor)
const chancePercent = baseChance * (1 - penaltyFactor);
// 5. Validacao de minimo
if (chancePercent < this.minChance) {
throw new BadRequestException(`Chance is too low. Minimum chance is ${this.minChance}%`);
}
// 6. Cap no maximo
if (chancePercent > this.maxChance) {
return this.maxChance;
}
// 7. Arredonda para 2 casas decimais
return Math.round(chancePercent * 100) / 100;
}Formula Matematica
ratio = valueInvested / targetValue
baseChance = ratio * (1 - 0.08) * 100
= ratio * 0.92 * 100
= ratio * 92
penaltyFactor = ratio^0.2 * 0.28
chancePercent = baseChance * (1 - penaltyFactor)
Clamped: MAX(0.5, MIN(75, chancePercent))O penaltyFactor e progressivo: quanto maior o ratio (quanto mais proximo o valor investido esta do valor alvo), MAIOR a penalidade. Isso impede que upgrades de valor proximo tenham probabilidades excessivamente altas.
Exemplos Numericos
| Investido (BRL) | Alvo (BRL) | Ratio | BaseChance | PenaltyFactor | Chance Final |
|---|---|---|---|---|---|
| R$ 10,00 | R$ 100,00 | 0.10 | 9.20% | 0.1766 | 7.57% |
| R$ 25,00 | R$ 100,00 | 0.25 | 23.00% | 0.2144 | 18.07% |
| R$ 50,00 | R$ 100,00 | 0.50 | 46.00% | 0.2437 | 34.79% |
| R$ 75,00 | R$ 100,00 | 0.75 | 69.00% | 0.2622 | 50.91% |
| R$ 90,00 | R$ 100,00 | 0.90 | 82.80% | 0.2720 | 60.28% |
Calculo detalhado para R$ 50 -> R$ 100:
ratio = 50/100 = 0.5
baseChance = 0.5 * 0.92 * 100 = 46.0
penaltyFactor = 0.5^0.2 * 0.28 = 0.8706 * 0.28 = 0.2437
chancePercent = 46.0 * (1 - 0.2437) = 46.0 * 0.7563 = 34.79%Metodo Auxiliar: getRequiredValueForChance
Calcula o valor necessario para atingir uma chance desejada. Usa busca binaria:
getRequiredValueForChance(targetValueCents: bigint, desiredChance: number): bigint {
if (desiredChance < this.minChance || desiredChance > this.maxChance) {
throw new BadRequestException(
`Desired chance must be between ${this.minChance}% and ${this.maxChance}%`,
);
}
const targetValue = Number(targetValueCents);
let low = 1;
let high = targetValue - 1;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const chance = this.calculateChanceRaw(mid, targetValue);
if (chance < desiredChance) {
low = mid + 1;
} else {
high = mid;
}
}
return BigInt(Math.ceil(low));
}Metodo Auxiliar: validateInvestedValue
Retorna true se o valor investido produz uma chance valida (>= minChance), false caso contrario. Nao lanca excecao.
validateInvestedValue(valueInvestedCents: bigint, targetValueCents: bigint): boolean {
try {
this.calculateChance(valueInvestedCents, targetValueCents);
return true;
} catch {
return false;
}
}Determinacao de Sucesso (Provably Fair)
Arquivo: src/application/services/provably-fair.service.ts
Geracao 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;
}O roll sempre retorna um valor entre 1 e 10.000.000.
Calculo do Success Threshold
const successThreshold = 10000000 - (chancePercent / 100) * 10000000;
const success = roll >= successThreshold;Exemplo com chance de 34.79%:
successThreshold = 10000000 - (34.79 / 100) * 10000000
= 10000000 - 3479000
= 6521000
Se roll >= 6521000 -> SUCESSO (34.79% do range esta acima desse threshold)
Se roll < 6521000 -> FALHA (65.21% do range esta abaixo)Os ultimos 34.79% do range (6.521.001 a 10.000.000) representam sucesso.
Geracao de Seeds e Nonce
// Server seed: 32 bytes aleatorios em hex
const serverSeed = this.provablyFairService.generateServerSeed();
// -> randomBytes(32).toString('hex')
// Hash do server seed (entregue ao usuario ANTES do roll)
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
// -> createHmac('sha256', serverSeed).digest('hex')
// Client seed: fornecido pelo usuario OU gerado automaticamente
const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
// Se clientSeed fornecido: usa ele
// Se nao: Date.now().toString() + randomBytes(16).toString('hex')
// Nonce: contador monotonico por usuario (NonceService)
const nonce = await this.nonceService.getNextNonce(userId);
// -> redis.incr('nonce:{userId}')Fluxo Completo do Upgrade
Arquivo: src/application/use-cases/upgrade/perform-upgrade.use-case.ts
Passo a Passo
1. Validacao de Entrada
O usuario deve fornecer pelo menos um dos dois:
inventoryItemIds: array de IDs de itens do inventario (maximo 3)balanceCents: valor em centavos BRL do saldo
const hasItems = inventoryItemIds.length > 0;
const hasBalance = balanceCents && balanceCents > BigInt(0);
if (!hasItems && !hasBalance) {
throw new BadRequestException('You must select items or use balance for upgrade');
}2. Busca do Item Alvo
const targetItem = await this.itemRepository.findById(targetItemId);
if (!targetItem) {
throw new NotFoundException('Target item not found');
}3. Aquisicao de Lock Distribuido
Usa Redlock com a chave lock:user:balance:{userId} e TTL de 10 segundos:
return this.lockService.withUserBalanceLock(userId, async () => {
return this.prisma.$transaction(async () => {
// ... toda a logica dentro da transacao
});
});Configuracao do Redlock:
driftFactor: 0.01retryCount: 10retryDelay: 200msretryJitter: 200msautomaticExtensionThreshold: 500ms
4. Busca e Validacao dos Itens do Inventario
Se hasItems, busca cada item pelo ID e valida existencia e a restricao de Loja. A posse (userId) e o status AVAILABLE NAO sao checados nesta fase de leitura: sao garantidos de forma atomica na fase de escrita (passo 15) via bulkUpdateStatusIf, que so consome itens do proprio usuario que estejam AVAILABLE.
for (const invItem of fetchedInventoryItems) {
if (!invItem) {
throw new NotFoundException('Inventory item not found');
}
if (invItem.restrictedToGames) {
throw new BadRequestException(
'Item da Loja não pode ser usado em upgrade — só pode ser jogado',
);
}
inventoryItems.push(invItem);
}Itens vindos da Loja (restrictedToGames = true) NAO podem ser usados em upgrade.
5. Sem Status Intermediario (LOCKED)
O upgrade NAO marca os itens como LOCKED. Na fase de leitura os itens sao apenas validados; a mudanca de status (AVAILABLE -> USED_IN_UPGRADE) acontece uma unica vez, de forma atomica, dentro da transacao (passo 15). Isso elimina a necessidade de rollback manual de status.
6. Calculo do Valor dos Itens
Usa acquiredValueCents (snapshot BRL do momento da aquisicao de cada item de inventario), nunca valueCents (USD) nem o preco atual do Item:
itemsValueCents = inventoryItems.reduce(
(sum, inv) => sum + inv.acquiredValueCents,
BigInt(0),
);7. Validacao de Saldo (se balanceCents > 0)
Como esta validacao ocorre antes de qualquer alteracao de status, um saldo insuficiente apenas lanca a excecao — nao ha itens para "desbloquear":
if (hasBalance) {
const userBalance = await this.transactionService.getUserBalance(userId);
if (userBalance < balanceUsed) {
throw new BadRequestException('Insufficient balance');
}
}O saldo vem de transactionService.getUserBalance() (source of truth via double-entry), nunca de user.balanceCents.
8. Validacao do Valor Total
const totalValueCents = itemsValueCents + balanceUsed;
if (totalValueCents <= BigInt(0)) {
throw new BadRequestException('Total value must be greater than zero');
}
if (totalValueCents >= targetItem.valueCentsBrl) {
throw new BadRequestException('Total value must be less than target item value');
}O valor investido DEVE ser estritamente menor que o valor do item alvo (valueCentsBrl).
9. Calculo da Probabilidade
const chancePercent = this.upgradeProbabilityService.calculateChance(
totalValueCents,
targetItem.valueCentsBrl,
);
if (chancePercent <= 0) {
throw new BadRequestException('Upgrade chance is too low');
}Ainda estamos fora da transacao e nenhum item mudou de status, entao qualquer excecao aqui apenas encerra a execucao (sem rollback manual).
10. Geracao de Seeds e Roll
const serverSeed = this.provablyFairService.generateServerSeed();
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
const finalClientSeed = this.provablyFairService.generateClientSeed(clientSeed);
const nonce = await this.nonceService.getNextNonce(userId);
const roll = this.provablyFairService.calculateRoll(serverSeed, finalClientSeed, nonce);O nonce vem do NonceService.getNextNonce(userId), um contador monotonico por usuario (redis.incr('nonce:{userId}')), compartilhado entre caixa, batalha e upgrade.
11. Determinacao do Resultado
const successThreshold = 10000000 - (chancePercent / 100) * 10000000;
let success = roll >= successThreshold;11b. Buff de Streamer (apenas em caso de falha)
Se o upgrade falhou E o usuario tem role === 'STREAMER' com affiliateBuffPercentage > 0, ocorre um reroll deterministico:
if (!success && userForBuff?.role === 'STREAMER' && userForBuff.affiliateBuffPercentage > 0) {
const buffCheckRoll = this.provablyFairService.calculateRoll(
serverSeed + '_buff_check', finalClientSeed, nonce,
) % 100;
if (buffCheckRoll < userForBuff.affiliateBuffPercentage) {
const buffRoll = this.provablyFairService.calculateRoll(
serverSeed + '_buff', finalClientSeed, nonce,
);
if (buffRoll >= successThreshold) {
success = true;
}
}
}O gate _buff_check % 100 decide (conforme affiliateBuffPercentage) se o reroll _buff acontece. O serverSeed original nao muda — apenas os sufixos alteram a derivacao — entao a verificacao Provably Fair do roll principal continua valida.
12. Fase de Escrita Atomica: Debito de Saldo ou Registro (logOnly)
A partir daqui tudo ocorre dentro de this.prisma.$transaction(async (tx) => { ... }). Qualquer throw reverte todos os writes automaticamente.
if (hasBalance) {
await this.transactionService.debit(
userId,
balanceUsed,
`UPGRADE: Upgrade attempt for ${targetItem.name}`,
{ action: 'UPGRADE', type: 'upgrade', /* ...metadata do alvo... */ },
tx,
);
} else {
await this.transactionService.logOnly(
userId,
totalValueCents,
`UPGRADE: Upgrade attempt for ${targetItem.name}`,
{ action: 'UPGRADE', type: 'upgrade', itemsOnly: true },
tx,
);
}Se usou saldo, ha um debito real (double-entry) com prefixo UPGRADE:. Se foi so com itens, logOnly registra o valor investido SEM mover saldo (uma transacao DEBIT de valor 0 com o valor real em metadata). Em ambos os casos o saldo NAO e devolvido em caso de falha.
13. Registro do Upgrade no Banco
const upgrade = await this.upgradeRepository.create({
userId,
targetItemId,
success,
chancePercent,
valueInvestedCents: totalValueCents,
balanceUsedCents: balanceUsed,
targetValueCents: targetItem.valueCentsBrl,
serverSeed,
serverSeedHash,
clientSeed: finalClientSeed,
nonce,
roll,
});O id e gerado pelo SnowflakeService.generate() dentro do repository.
14. Registro dos Itens Usados
for (const item of items) {
await this.upgradeRepository.addItemToUpgrade({
upgradeId: upgrade.id,
itemId: item.id,
valueCents: item.valueCentsBrl,
});
}15. Consumo Atomico dos Itens (AVAILABLE -> USED_IN_UPGRADE)
if (hasItems) {
const consumedIds = await this.userInventoryRepository.bulkUpdateStatusIf(
inventoryItemIds,
userId,
'AVAILABLE',
'USED_IN_UPGRADE',
tx,
);
if (consumedIds.length !== inventoryItemIds.length) {
throw new BadRequestException(
'One or more items are not available or do not belong to you',
);
}
}bulkUpdateStatusIf altera apenas os itens que pertencem ao usuario E estao AVAILABLE. Se a contagem retornada divergir (item de outro dono, ja consumido ou em corrida), a transacao inteira faz rollback. E aqui que posse e disponibilidade sao efetivamente garantidas — sem status LOCKED intermediario. No codigo real esse consumo e o PRIMEIRO write da transacao; independentemente do resultado (sucesso ou falha), os itens sao consumidos e o saldo NAO e devolvido.
16. Entrega do Item Alvo (se sucesso)
let inventoryItemId: bigint | undefined;
if (success) {
const createdInventory = await this.userInventoryRepository.create({
userId,
itemId: targetItemId,
status: 'AVAILABLE',
acquiredFrom: 'Upgrade',
acquiredValueCents: targetItem.valueCentsBrl,
}, tx);
inventoryItemId = createdInventory.id;
}O item alvo entra no inventario com acquiredValueCents = targetItem.valueCentsBrl (snapshot BRL). Em seguida, ainda dentro da transacao, firstBetAt e marcado atomicamente na primeira aposta (WHERE firstBetAt IS NULL) e totalWageredCents e incrementado pelo valor investido. O inventoryItemId e retornado na resposta para que o frontend possa oferecer a opcao de vender o item imediatamente.
17. Emissao de Eventos WebSocket
Atualizacao de saldo (se saldo foi usado):
if (hasBalance) {
const newBalanceCents = await this.transactionService.getUserBalance(userId);
this.webSocketGateway.emitBalanceUpdate(userId, newBalanceCents);
}Evento: balance:updated na sala user:{userId} Payload: { balanceCents: string } (centavos como string)
Live drop (se sucesso, com delay de 12 segundos para animacao):
if (success) {
const user = await this.userRepository.findById(userId);
if (user) {
setTimeout(() => {
this.webSocketGateway.emitLiveDrop({
userName: user.username,
userAvatar: user.avatarUrl || undefined,
itemName: targetItem.name,
itemImage: targetItem.imageUrl,
itemValueCents: targetItem.valueCentsBrl,
rarity: targetItem.rarity,
source: 'upgrade',
timestamp: new Date(),
dropChance: chancePercent,
});
}, 12000);
}
}Evento: live:drop (broadcast global para todos os clientes conectados) Payload:
{
"userName": "string",
"userAvatar": "string | undefined",
"itemName": "string",
"itemImage": "string",
"itemValueCents": "string",
"rarity": "string",
"source": "upgrade",
"timestamp": "ISO date",
"dropChance": 34.79,
"isTopDrop": true,
"caseImage": "undefined"
}O campo isTopDrop e true quando valueCents >= 100000 (R$ 1.000,00 ou mais).
18. Retorno
return {
upgrade, // Registro completo do Upgrade no banco
success, // boolean
targetItem, // Item alvo completo
itemsUsed: items, // Array de itens usados
balanceUsedCents: balanceUsed, // BigInt
chancePercent, // number (ex: 34.79)
serverSeedHash, // string (hash do server seed)
clientSeed: finalClientSeed, // string
nonce, // number
roll, // number (1 a 10.000.000)
inventoryItemId, // bigint | undefined (ID no inventario se sucesso)
};Calculo de Chance (Preview para Frontend)
Arquivo: src/application/use-cases/upgrade/calculate-upgrade-chance.use-case.ts
Endpoint usado pelo frontend para mostrar a probabilidade em tempo real conforme o usuario seleciona itens e/ou define saldo. Nao executa o upgrade, apenas calcula.
Fluxo
- Valida que pelo menos itens ou saldo foram fornecidos
- Busca o item alvo
- Se itens fornecidos: busca cada um no inventario, valida posse e status
AVAILABLE, somaacquiredValueCents(snapshot BRL da aquisicao) - Se saldo fornecido: valida que o usuario possui saldo suficiente via
transactionService.getUserBalance(userId) - Valida que
totalValueCents < targetItem.valueCentsBrl - Calcula a chance via
upgradeProbabilityService.calculateChance()
Retorno
return {
targetItem: {
id: string,
name: string,
imageUrl: string,
rarity: string,
valueCents: string, // valueCentsBrl como string
valueFormatted: string, // MoneyService.format() (ex: "R$ 100,00")
},
itemsUsed: [{
id: string,
name: string,
imageUrl: string,
rarity: string,
valueCents: string,
valueFormatted: string,
}],
balanceUsedCents: string,
balanceUsedFormatted: string,
totalValueCents: string,
totalValueFormatted: string,
chancePercent: number, // Ex: 34.79
houseEdge: number, // Ex: 8 (percentual, nao decimal)
minChance: number, // Ex: 0.5
maxChance: number, // Ex: 75
};Verificacao Provably Fair
Arquivo: src/application/use-cases/upgrade/verify-upgrade.use-case.ts
Permite qualquer pessoa verificar se um upgrade foi justo. Expoe o serverSeed (que antes era secreto, identificado apenas pelo serverSeedHash).
Fluxo
async execute(upgradeId: bigint) {
const upgrade = await this.upgradeRepository.findById(upgradeId);
if (!upgrade) {
throw new NotFoundException('Upgrade not found');
}
// 1. Verifica se o hash do server seed bate
const isServerSeedValid = this.provablyFairService.verifyServerSeedHash(
upgrade.serverSeed,
upgrade.serverSeedHash,
);
// 2. Recalcula o roll e compara com o armazenado
const isRollValid = this.provablyFairService.verifyResult(
upgrade.serverSeed,
upgrade.clientSeed,
upgrade.nonce,
upgrade.roll,
);
// 3. Recalcula o threshold e verifica se o resultado (success) esta correto
const successThreshold = 10000000 - (upgrade.chancePercent / 100) * 10000000;
const isSuccessValid = upgrade.success === upgrade.roll >= successThreshold;
return {
isValid: isServerSeedValid && isRollValid && isSuccessValid,
isServerSeedValid,
isRollValid,
isSuccessValid,
upgrade: {
id: upgrade.id,
serverSeed: upgrade.serverSeed,
serverSeedHash: upgrade.serverSeedHash,
clientSeed: upgrade.clientSeed,
nonce: upgrade.nonce,
roll: upgrade.roll,
chancePercent: upgrade.chancePercent,
success: upgrade.success,
successThreshold,
},
};
}Tres Verificacoes
| Verificacao | O que Valida |
|---|---|
isServerSeedValid | HMAC-SHA256(serverSeed) === serverSeedHash |
isRollValid | Recalcula o roll com os mesmos inputs e compara com o armazenado |
isSuccessValid | Verifica se success === (roll >= successThreshold) |
Busca de Itens Alvo
Arquivo: src/application/use-cases/upgrade/get-upgrade-targets.use-case.ts
Endpoint publico que retorna itens disponiveis como alvo de upgrade com paginacao e filtros.
Filtros Disponiveis
interface UpgradeTargetFilters {
minValueCents?: bigint; // Filtro por valor minimo em centavos BRL
maxValueCents?: bigint; // Filtro por valor maximo em centavos BRL
search?: string; // Busca por nome do item (trim aplicado)
sortOrder?: 'asc' | 'desc'; // Ordenacao por valor (default: 'asc')
limit?: number; // Itens por pagina (default: 15)
offset?: number; // Offset para paginacao (default: 0)
}Retorno
return {
items: [{
id: string,
name: string,
imageUrl: string,
rarity: string,
valueCents: string, // valueCentsBrl como string
valueFormatted: string, // MoneyService.format()
weaponCategory: string,
exterior: string | undefined,
isStatTrak: boolean,
}],
total: number,
limit: number,
offset: number,
hasMore: boolean, // offset + limit < total
};Historico de Upgrades
Arquivo: src/application/use-cases/upgrade/get-upgrade-history.use-case.ts
Retorna todos os upgrades de um usuario com paginacao, ordenados por createdAt DESC.
Filtros
filters?: {
limit?: number; // default: 50
offset?: number; // default: 0
}Inclui relacoes: user, targetItem, itemsUsed.item.
Endpoints da API
Arquivo: src/presentation/controllers/upgrade.controller.ts
Todos os endpoints estao sob o prefixo /upgrade com tag Swagger Upgrade.
GET /upgrade/targets (Publico)
Busca itens disponiveis como alvo de upgrade.
Autenticacao: Nenhuma (decorado com @Public())
Query Parameters:
| Parametro | Tipo | Obrigatorio | Default | Descricao |
|---|---|---|---|---|
minValueCents | string (BigInt) | Nao | - | Valor minimo em centavos BRL |
maxValueCents | string (BigInt) | Nao | - | Valor maximo em centavos BRL |
limit | string (Number) | Nao | 15 | Itens por pagina |
offset | string (Number) | Nao | 0 | Offset para paginacao |
sortOrder | 'asc' / 'desc' | Nao | 'asc' | Ordenacao por valor |
search | string | Nao | - | Busca por nome |
Response 200:
{
"items": [
{
"id": "123456789",
"name": "AWP | Dragon Lore (Factory New)",
"imageUrl": "https://...",
"rarity": "COVERT",
"valueCents": "350000",
"valueFormatted": "R$ 3.500,00",
"weaponCategory": "SNIPER",
"exterior": "FACTORY_NEW",
"isStatTrak": false
}
],
"total": 1500,
"limit": 15,
"offset": 0,
"hasMore": true
}POST /upgrade/calculate (Autenticado)
Calcula a chance de sucesso sem executar o upgrade.
Autenticacao: AuthGuard (session cookie)
Request Body (CalculateUpgradeDto):
{
"inventoryItemIds": ["111", "222"],
"balanceCents": "5000",
"targetItemId": "999"
}| Campo | Tipo | Obrigatorio | Validacao |
|---|---|---|---|
inventoryItemIds | string[] | Nao* | Maximo 3 itens (@ArrayMaxSize(3)) |
balanceCents | string | Nao* | BigInt como string |
targetItemId | string | Sim | ID do item alvo |
*Pelo menos um dos dois (inventoryItemIds ou balanceCents) deve ser fornecido.
Response 200 (UpgradeChanceResponseDto):
{
"targetItem": {
"id": "999",
"name": "AWP | Asiimov (Field-Tested)",
"imageUrl": "https://...",
"rarity": "COVERT",
"valueCents": "20000",
"valueFormatted": "R$ 200,00"
},
"itemsUsed": [
{
"id": "111",
"name": "AK-47 | Redline (Field-Tested)",
"imageUrl": "https://...",
"rarity": "CLASSIFIED",
"valueCents": "5000",
"valueFormatted": "R$ 50,00"
}
],
"balanceUsedCents": "5000",
"balanceUsedFormatted": "R$ 50,00",
"totalValueCents": "10000",
"totalValueFormatted": "R$ 100,00",
"chancePercent": 34.79,
"houseEdge": 8,
"minChance": 0.5,
"maxChance": 75
}Erros:
| Status | Condicao |
|---|---|
| 400 | Nenhum item ou saldo fornecido |
| 400 | Item nao pertence ao usuario |
| 400 | Item nao esta com status AVAILABLE |
| 400 | Saldo insuficiente |
| 400 | Valor total >= valor do item alvo |
| 400 | Chance abaixo do minimo (0.5%) |
| 404 | Item do inventario nao encontrado |
| 404 | Item alvo nao encontrado |
POST /upgrade/perform (Autenticado)
Executa o upgrade.
Autenticacao: AuthGuard (session cookie)
Request Body (PerformUpgradeDto):
{
"inventoryItemIds": ["111", "222"],
"balanceCents": "5000",
"targetItemId": "999",
"clientSeed": "minha-seed-personalizada"
}| Campo | Tipo | Obrigatorio | Validacao |
|---|---|---|---|
inventoryItemIds | string[] | Nao* | Maximo 3 itens (@ArrayMaxSize(3)) |
balanceCents | string | Nao* | BigInt como string |
targetItemId | string | Sim | ID do item alvo |
clientSeed | string | Nao | Seed personalizada do usuario |
*Pelo menos um dos dois (inventoryItemIds ou balanceCents) deve ser fornecido.
Response 200 (Sucesso) (UpgradeResponseDto):
{
"id": "456789012345",
"userId": "123456789",
"success": true,
"chancePercent": 34.79,
"targetItem": {
"id": "999",
"name": "AWP | Asiimov (Field-Tested)",
"imageUrl": "https://...",
"rarity": "COVERT",
"valueCents": "20000",
"valueFormatted": "R$ 200,00"
},
"itemsUsed": [
{
"id": "111",
"name": "AK-47 | Redline (Field-Tested)",
"imageUrl": "https://...",
"rarity": "CLASSIFIED",
"valueCents": "5000",
"valueFormatted": "R$ 50,00"
}
],
"balanceUsedCents": "5000",
"balanceUsedFormatted": "R$ 50,00",
"totalValueCents": "10000",
"totalValueFormatted": "R$ 100,00",
"serverSeedHash": "a1b2c3d4e5f6...",
"clientSeed": "minha-seed-personalizada",
"nonce": 845231,
"roll": 7823456,
"createdAt": "2026-02-11T15:30:00.000Z",
"inventoryItemId": "789012345678"
}Response 200 (Falha):
Mesma estrutura, com success: false e inventoryItemId ausente (undefined).
{
"id": "456789012345",
"userId": "123456789",
"success": false,
"chancePercent": 34.79,
"targetItem": { ... },
"itemsUsed": [ ... ],
"balanceUsedCents": "5000",
"balanceUsedFormatted": "R$ 50,00",
"totalValueCents": "10000",
"totalValueFormatted": "R$ 100,00",
"serverSeedHash": "a1b2c3d4e5f6...",
"clientSeed": "minha-seed-personalizada",
"nonce": 845231,
"roll": 3215678,
"createdAt": "2026-02-11T15:30:00.000Z"
}Erros:
| Status | Condicao |
|---|---|
| 400 | Nenhum item ou saldo fornecido |
| 400 | Item nao pertence ao usuario |
| 400 | Item nao esta com status AVAILABLE |
| 400 | Saldo insuficiente |
| 400 | Valor total <= 0 |
| 400 | Valor total >= valor do item alvo |
| 400 | Chance muito baixa (< 0.5%) |
| 400 | Chance <= 0 |
| 404 | Item do inventario nao encontrado |
| 404 | Item alvo nao encontrado |
GET /upgrade/history (Autenticado)
Retorna o historico de upgrades do usuario.
Autenticacao: AuthGuard (session cookie)
Query Parameters:
| Parametro | Tipo | Default | Descricao |
|---|---|---|---|
limit | number | 50 | Quantidade por pagina |
offset | number | 0 | Offset para paginacao |
Response 200: Array de UpgradeResponseDto (mesma estrutura do perform, sem inventoryItemId).
POST /upgrade/:id/verify (Publico)
Verifica a integridade Provably Fair de um upgrade.
Autenticacao: Nenhuma
Path Parameters: id (string) - ID do upgrade
Response 200 (VerifyUpgradeResponseDto):
{
"isValid": true,
"isServerSeedValid": true,
"isRollValid": true,
"isSuccessValid": true,
"upgrade": {
"id": "456789012345",
"serverSeed": "abc123def456...",
"serverSeedHash": "a1b2c3d4e5f6...",
"clientSeed": "minha-seed-personalizada",
"nonce": 845231,
"roll": 7823456,
"chancePercent": 34.79,
"success": true,
"successThreshold": 6521000
}
}Erros:
| Status | Condicao |
|---|---|
| 404 | Upgrade nao encontrado |
Eventos WebSocket
Evento: balance:updated
Quando: Apos debito de saldo no upgrade (se balanceCents > 0) Destino: Sala privada user:{userId}Payload:
{
"balanceCents": "186321"
}O valor e o saldo ATUALIZADO (pos-debito) em centavos BRL como string.
Evento: live:drop
Quando: 12 segundos apos upgrade bem-sucedido (delay para animacao do frontend) Destino: Broadcast global (todos os clientes conectados) Payload:
{
"userName": "PlayerOne",
"userAvatar": "https://steamcdn-a.akamaihd.net/...",
"itemName": "AWP | Asiimov (Field-Tested)",
"itemImage": "https://...",
"itemValueCents": "20000",
"rarity": "COVERT",
"source": "upgrade",
"timestamp": "2026-02-11T15:30:12.000Z",
"dropChance": 34.79,
"isTopDrop": false,
"caseImage": undefined
}Venda Apos Vitoria (Sell After Win)
Quando o upgrade e bem-sucedido, a resposta inclui inventoryItemId com o ID do item ganho no inventario do usuario. O frontend pode usar esse ID para oferecer a opcao de vender o item imediatamente atraves do endpoint de venda de inventario, sem que o usuario precise navegar ate o inventario.
Validacoes e DTOs
CalculateUpgradeDto
class CalculateUpgradeDto {
@IsOptional()
@IsArray()
@ArrayMaxSize(3, { message: 'Maximum of 3 items allowed for upgrade' })
@IsString({ each: true })
inventoryItemIds?: string[];
@IsOptional()
@IsString()
balanceCents?: string;
@IsString()
targetItemId: string;
}PerformUpgradeDto
class PerformUpgradeDto {
@IsOptional()
@IsArray()
@ArrayMaxSize(3, { message: 'Maximum of 3 items allowed for upgrade' })
@IsString({ each: true })
inventoryItemIds?: string[];
@IsOptional()
@IsString()
balanceCents?: string;
@IsString()
targetItemId: string;
@IsOptional()
@IsString()
clientSeed?: string;
}Ambos possuem validacao condicional: se inventoryItemIds esta vazio E balanceCents nao foi fornecido/e zero, a validacao falha.
Rollback em Caso de Erro
Todas as validacoes (saldo, valor total, chance minima) acontecem ANTES da transacao, quando nenhum item mudou de status. O consumo dos itens (AVAILABLE -> USED_IN_UPGRADE) so acontece DENTRO de prisma.$transaction, via bulkUpdateStatusIf. Por isso NAO existe rollback manual: qualquer throw dentro da transacao reverte automaticamente o consumo (os itens voltam a AVAILABLE) e o debito de saldo.
// Cenarios que abortam a transacao (rollback automatico do Prisma):
// 1. Saldo insuficiente (validado antes da transacao)
// 2. Valor total <= 0
// 3. Valor total >= valor do item alvo
// 4. Chance abaixo do minimo (0.5%)
// 5. bulkUpdateStatusIf com contagem divergente (item indisponivel ou de outro dono)Uma vez que a transacao commita, os itens sao consumidos independentemente do resultado (sucesso ou falha) e o saldo NAO e devolvido.
Valores Monetarios
Todos os valores internos sao em centavos BRL (valueCentsBrl). Nunca valueCents (USD).
| Campo na Response | Tipo | Descricao |
|---|---|---|
valueCents | string | valueCentsBrl como string (centavos BRL) |
valueFormatted | string | Formatado pelo MoneyService.format() (ex: "R$ 50,00") |
balanceUsedCents | string | Saldo usado em centavos BRL |
totalValueCents | string | Valor total investido em centavos BRL |
Diagrama de Sequencia
Frontend Controller UseCase Services/DB
| | | |
|-- POST /upgrade/perform -->| | |
| |-- execute() ------------->| |
| | |-- withUserBalanceLock --->|
| | | (Redlock 10s TTL) |
| | |<-- lock acquired ---------|
| | | |
| | |-- findById (inventory) -->|
| | |<-- validate items --------|
| | | (existencia + Loja) |
| | | |
| | |-- getUserBalance -------->|
| | |<-- validate balance ------|
| | | |
| | |-- calculateChance ------->|
| | |<-- chancePercent ---------|
| | | |
| | |-- generateServerSeed ---->|
| | |-- getNextNonce (redis) -->|
| | |-- calculateRoll --------->|
| | |<-- roll (1-10M) ----------|
| | | |
| | |-- successThreshold ------>|
| | | success = roll >= th |
| | | (+ buff STREAMER) |
| | | |
| | |-- $transaction (writes) ->|
| | | |
| | |-- bulkUpdateStatusIf ---->|
| | | (AVAILABLE->USED) |
| | | |
| | |-- debit | logOnly ------->|
| | | (saldo ou so-itens) |
| | | |
| | |-- upgradeRepo.create ---->|
| | |-- addItemToUpgrade ------>|
| | | |
| | |-- [se sucesso] ---------->|
| | | create(inventory) |
| | | |
| | |-- firstBetAt/wagered ---->|
| | | |
| | |<-- commit transaction -----|
| | |<-- release lock ----------|
| | | |
| | |-- emitBalanceUpdate ----->| (WS)
| | |-- emitLiveDrop (12s) ---->| (WS)
| | | |
|<-- UpgradeResponseDto -----|<-- result ----------------| |Concorrencia e Seguranca
Lock Distribuido
O LockService.withUserBalanceLock() garante que apenas UMA operacao financeira por usuario acontece por vez. O lock usa Redlock com chave lock:user:balance:{userId} e TTL de 10 segundos.
Transacao Atomica
Apenas os WRITES do upgrade ocorrem dentro de prisma.$transaction() (consumo de itens, debito/logOnly, criacao do Upgrade/itens, entrega do alvo, firstBetAt/totalWageredCents). A leitura, a validacao e o roll ficam dentro do lock por usuario, mas FORA da transacao. Se qualquer write falhar, todos os writes sao revertidos.
Prevencao de Double-Spend
- O consumo dos itens (
AVAILABLE -> USED_IN_UPGRADE) usabulkUpdateStatusIf, que so altera itens do proprio usuario que estaoAVAILABLE; contagem divergente aborta a transacao - Saldo e verificado APOS o lock por usuario ser adquirido e debitado dentro da transacao atomica
- Lock distribuido (
withUserBalanceLock) previne operacoes financeiras concorrentes do mesmo usuario
Limite de Itens
Maximo de 3 itens por upgrade, validado via @ArrayMaxSize(3) no DTO.
Debugging
Verificar Probabilidade
// Testar calculo manualmente
const invested = BigInt(5000); // R$ 50,00
const target = BigInt(20000); // R$ 200,00
const ratio = 5000 / 20000; // 0.25
const baseChance = 0.25 * 0.92 * 100; // 23.0
const penaltyFactor = Math.pow(0.25, 0.2) * 0.28; // 0.7579 * 0.28 = 0.2122
const chance = 23.0 * (1 - 0.2122); // 23.0 * 0.7878 = 18.12%Verificar Roll e Threshold
const chancePercent = 18.12;
const successThreshold = 10000000 - (18.12 / 100) * 10000000;
// = 10000000 - 1812000 = 8188000
// Se roll >= 8188000 -> sucesso
// Se roll < 8188000 -> falhaVerificar Status dos Itens
Se itens ficaram em LOCKED apos erro:
- Verificar se a transacao Prisma fez rollback
- Verificar se o Redlock liberou o lock
- Se necessario, atualizar manualmente para
AVAILABLE
Logs Relevantes
O emitLiveDrop loga automaticamente:
Emitting live drop: PlayerOne got AWP | Asiimov (34.79%) - isTopDrop: falseO emitBalanceUpdate loga:
[Balance Update] User: 123, BalanceCents: 186321, Room: user:123
[Balance Update] Clients in room user:123: 1
[Balance Update] Event emitted to room user:123Arquivos do Sistema
| Arquivo | Responsabilidade |
|---|---|
src/application/services/upgrade-probability.service.ts | Formula de probabilidade com house edge e penalidade |
src/application/services/provably-fair.service.ts | Geracao de seeds, calculo de roll, verificacao |
src/application/services/nonce.service.ts | Nonce monotonico por usuario (redis.incr('nonce:{userId}')) |
src/application/use-cases/upgrade/perform-upgrade.use-case.ts | Logica principal do upgrade |
src/application/use-cases/upgrade/calculate-upgrade-chance.use-case.ts | Calculo de chance (preview) |
src/application/use-cases/upgrade/verify-upgrade.use-case.ts | Verificacao Provably Fair |
src/application/use-cases/upgrade/get-upgrade-targets.use-case.ts | Busca de itens alvo com filtros |
src/application/use-cases/upgrade/get-upgrade-history.use-case.ts | Historico de upgrades do usuario |
src/application/dto/upgrade.dto.ts | DTOs de entrada e saida |
src/presentation/controllers/upgrade.controller.ts | Endpoints HTTP |
src/domain/repositories/upgrade.repository.interface.ts | Interface do repositorio |
src/infrastructure/database/repositories/upgrade.repository.ts | Implementacao Prisma do repositorio |
src/infrastructure/locks/lock.service.ts | Lock distribuido (Redlock) |
src/infrastructure/websocket/websocket.gateway.ts | Emissao de eventos WebSocket |
src/application/services/transaction.service.ts | Debito de saldo (debit com prefixo UPGRADE:) e logOnly para upgrades so com itens |
prisma/schema.prisma | Models Upgrade e UpgradeItem |
