Skip to content

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)

prisma
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:

CampoTipoDescricao
idBigIntSnowflake ID gerado pelo SnowflakeService
userIdBigIntID do usuario que realizou o upgrade
targetItemIdBigIntID do item alvo (desejado)
successBooleanSe o upgrade foi bem-sucedido
chancePercentFloatPorcentagem de chance calculada (ex: 45.32)
valueInvestedCentsBigIntValor total investido em centavos BRL (itens + saldo)
balanceUsedCentsBigIntParte do valor que veio do saldo (centavos BRL)
targetValueCentsBigIntValor do item alvo em centavos BRL (valueCentsBrl)
serverSeedStringServer seed usado no calculo do roll
serverSeedHashStringHMAC-SHA256 do server seed (entregue antes do roll)
clientSeedStringClient seed (fornecido pelo usuario ou gerado)
nonceIntNonce unico para o roll
rollIntResultado do roll (1 a 10.000.000)
createdAtDateTimeData/hora da tentativa

Tabela UpgradeItem (mapeada como upgrade_items)

prisma
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

prisma
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:

  1. AVAILABLE -> USED_IN_UPGRADE (consumo atomico via bulkUpdateStatusIf, dentro de prisma.$transaction)
  2. 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

ConstanteValor PadraoVariavel de AmbienteDescricao
houseEdge0.08 (8%)UPGRADE_HOUSE_EDGEMargem da casa
minChance0.5 (0.5%)UPGRADE_MIN_CHANCEChance minima permitida
maxChance75 (75%)UPGRADE_MAX_CHANCEChance maxima (cap)
Penalty exponent0.2HardcodedExpoente do fator de penalidade
Penalty multiplier0.28HardcodedMultiplicador do fator de penalidade

Calculo Passo a Passo

typescript
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)RatioBaseChancePenaltyFactorChance Final
R$ 10,00R$ 100,000.109.20%0.17667.57%
R$ 25,00R$ 100,000.2523.00%0.214418.07%
R$ 50,00R$ 100,000.5046.00%0.243734.79%
R$ 75,00R$ 100,000.7569.00%0.262250.91%
R$ 90,00R$ 100,000.9082.80%0.272060.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:

typescript
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.

typescript
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

typescript
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

typescript
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

typescript
// 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
typescript
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

typescript
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:

typescript
return this.lockService.withUserBalanceLock(userId, async () => {
  return this.prisma.$transaction(async () => {
    // ... toda a logica dentro da transacao
  });
});

Configuracao do Redlock:

  • driftFactor: 0.01
  • retryCount: 10
  • retryDelay: 200ms
  • retryJitter: 200ms
  • automaticExtensionThreshold: 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.

typescript
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:

typescript
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":

typescript
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

typescript
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

typescript
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

typescript
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

typescript
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:

typescript
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.

typescript
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

typescript
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

typescript
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)

typescript
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)

typescript
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):

typescript
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):

typescript
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:

json
{
  "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

typescript
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

  1. Valida que pelo menos itens ou saldo foram fornecidos
  2. Busca o item alvo
  3. Se itens fornecidos: busca cada um no inventario, valida posse e status AVAILABLE, soma acquiredValueCents (snapshot BRL da aquisicao)
  4. Se saldo fornecido: valida que o usuario possui saldo suficiente via transactionService.getUserBalance(userId)
  5. Valida que totalValueCents < targetItem.valueCentsBrl
  6. Calcula a chance via upgradeProbabilityService.calculateChance()

Retorno

typescript
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

typescript
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

VerificacaoO que Valida
isServerSeedValidHMAC-SHA256(serverSeed) === serverSeedHash
isRollValidRecalcula o roll com os mesmos inputs e compara com o armazenado
isSuccessValidVerifica 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

typescript
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

typescript
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

typescript
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:

ParametroTipoObrigatorioDefaultDescricao
minValueCentsstring (BigInt)Nao-Valor minimo em centavos BRL
maxValueCentsstring (BigInt)Nao-Valor maximo em centavos BRL
limitstring (Number)Nao15Itens por pagina
offsetstring (Number)Nao0Offset para paginacao
sortOrder'asc' / 'desc'Nao'asc'Ordenacao por valor
searchstringNao-Busca por nome

Response 200:

json
{
  "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):

json
{
  "inventoryItemIds": ["111", "222"],
  "balanceCents": "5000",
  "targetItemId": "999"
}
CampoTipoObrigatorioValidacao
inventoryItemIdsstring[]Nao*Maximo 3 itens (@ArrayMaxSize(3))
balanceCentsstringNao*BigInt como string
targetItemIdstringSimID do item alvo

*Pelo menos um dos dois (inventoryItemIds ou balanceCents) deve ser fornecido.

Response 200 (UpgradeChanceResponseDto):

json
{
  "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:

StatusCondicao
400Nenhum item ou saldo fornecido
400Item nao pertence ao usuario
400Item nao esta com status AVAILABLE
400Saldo insuficiente
400Valor total >= valor do item alvo
400Chance abaixo do minimo (0.5%)
404Item do inventario nao encontrado
404Item alvo nao encontrado

POST /upgrade/perform (Autenticado)

Executa o upgrade.

Autenticacao: AuthGuard (session cookie)

Request Body (PerformUpgradeDto):

json
{
  "inventoryItemIds": ["111", "222"],
  "balanceCents": "5000",
  "targetItemId": "999",
  "clientSeed": "minha-seed-personalizada"
}
CampoTipoObrigatorioValidacao
inventoryItemIdsstring[]Nao*Maximo 3 itens (@ArrayMaxSize(3))
balanceCentsstringNao*BigInt como string
targetItemIdstringSimID do item alvo
clientSeedstringNaoSeed personalizada do usuario

*Pelo menos um dos dois (inventoryItemIds ou balanceCents) deve ser fornecido.

Response 200 (Sucesso) (UpgradeResponseDto):

json
{
  "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).

json
{
  "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:

StatusCondicao
400Nenhum item ou saldo fornecido
400Item nao pertence ao usuario
400Item nao esta com status AVAILABLE
400Saldo insuficiente
400Valor total <= 0
400Valor total >= valor do item alvo
400Chance muito baixa (< 0.5%)
400Chance <= 0
404Item do inventario nao encontrado
404Item alvo nao encontrado

GET /upgrade/history (Autenticado)

Retorna o historico de upgrades do usuario.

Autenticacao: AuthGuard (session cookie)

Query Parameters:

ParametroTipoDefaultDescricao
limitnumber50Quantidade por pagina
offsetnumber0Offset 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):

json
{
  "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:

StatusCondicao
404Upgrade nao encontrado

Eventos WebSocket

Evento: balance:updated

Quando: Apos debito de saldo no upgrade (se balanceCents > 0) Destino: Sala privada user:{userId}Payload:

json
{
  "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:

json
{
  "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

typescript
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

typescript
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.

typescript
// 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 ResponseTipoDescricao
valueCentsstringvalueCentsBrl como string (centavos BRL)
valueFormattedstringFormatado pelo MoneyService.format() (ex: "R$ 50,00")
balanceUsedCentsstringSaldo usado em centavos BRL
totalValueCentsstringValor 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

  1. O consumo dos itens (AVAILABLE -> USED_IN_UPGRADE) usa bulkUpdateStatusIf, que so altera itens do proprio usuario que estao AVAILABLE; contagem divergente aborta a transacao
  2. Saldo e verificado APOS o lock por usuario ser adquirido e debitado dentro da transacao atomica
  3. 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

typescript
// 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

typescript
const chancePercent = 18.12;
const successThreshold = 10000000 - (18.12 / 100) * 10000000;
// = 10000000 - 1812000 = 8188000

// Se roll >= 8188000 -> sucesso
// Se roll <  8188000 -> falha

Verificar Status dos Itens

Se itens ficaram em LOCKED apos erro:

  1. Verificar se a transacao Prisma fez rollback
  2. Verificar se o Redlock liberou o lock
  3. Se necessario, atualizar manualmente para AVAILABLE

Logs Relevantes

O emitLiveDrop loga automaticamente:

Emitting live drop: PlayerOne got AWP | Asiimov (34.79%) - isTopDrop: false

O 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:123

Arquivos do Sistema

ArquivoResponsabilidade
src/application/services/upgrade-probability.service.tsFormula de probabilidade com house edge e penalidade
src/application/services/provably-fair.service.tsGeracao de seeds, calculo de roll, verificacao
src/application/services/nonce.service.tsNonce monotonico por usuario (redis.incr('nonce:{userId}'))
src/application/use-cases/upgrade/perform-upgrade.use-case.tsLogica principal do upgrade
src/application/use-cases/upgrade/calculate-upgrade-chance.use-case.tsCalculo de chance (preview)
src/application/use-cases/upgrade/verify-upgrade.use-case.tsVerificacao Provably Fair
src/application/use-cases/upgrade/get-upgrade-targets.use-case.tsBusca de itens alvo com filtros
src/application/use-cases/upgrade/get-upgrade-history.use-case.tsHistorico de upgrades do usuario
src/application/dto/upgrade.dto.tsDTOs de entrada e saida
src/presentation/controllers/upgrade.controller.tsEndpoints HTTP
src/domain/repositories/upgrade.repository.interface.tsInterface do repositorio
src/infrastructure/database/repositories/upgrade.repository.tsImplementacao Prisma do repositorio
src/infrastructure/locks/lock.service.tsLock distribuido (Redlock)
src/infrastructure/websocket/websocket.gateway.tsEmissao de eventos WebSocket
src/application/services/transaction.service.tsDebito de saldo (debit com prefixo UPGRADE:) e logOnly para upgrades so com itens
prisma/schema.prismaModels Upgrade e UpgradeItem

Documentação Técnica - ProCases