Skip to content

Sistema FLIP - Roleta Dupla

O sistema FLIP é uma mecânica especial que dá ao jogador uma segunda chance de ganhar itens ainda mais raros. Quando ativado, o jogador passa por duas roletas consecutivas.

Como Funciona

Probabilidade de Ativação

A probabilidade de ativar o FLIP é dinâmica por caixa. O FLIP é ativado quando o roll cai na faixa dos itens marcados como isRare na caixa.

typescript
// flip.service.ts
async getFlipThreshold(caseId: bigint): Promise<number | null> {
  const caseItems = await this.caseRepository.getCaseItems(caseId);
  const rareItems = caseItems.filter((ci) => ci.isRare);

  if (rareItems.length === 0) {
    return null; // Sem itens raros = sem FLIP
  }

  // O threshold é o início da faixa dos itens raros
  const minRangeStart = Math.min(...rareItems.map((ci) => ci.hashRangeStart));
  return minRangeStart;
}

shouldTriggerFlip(roll: number, flipThreshold: number | null): boolean {
  if (flipThreshold === null) {
    return false;
  }
  return roll >= flipThreshold;
}

Itens Elegíveis para FLIP

Apenas itens de alta raridade aparecem na segunda roleta:

typescript
// flip.service.ts
async getFlipItems(caseId: bigint): Promise<CaseItem[]> {
  // 1. Buscar itens COVERT e EXTRAORDINARY
  let flipItems = await this.caseItemRepository.findByCaseAndRarities(
    caseId,
    ['COVERT', 'EXTRAORDINARY'],
  );
  
  // 2. Fallback para CLASSIFIED se não houver itens raros
  if (flipItems.length === 0) {
    flipItems = await this.caseItemRepository.findByCaseAndRarities(
      caseId,
      ['CLASSIFIED'],
    );
  }
  
  // 3. Ordenar por valor (mais raros primeiro)
  return flipItems.sort((a, b) => 
    Number(b.item.valueCents) - Number(a.item.valueCents)
  );
}

Provably Fair Dual

O FLIP usa dois conjuntos de seeds para garantir justiça:

typescript
interface FlipProvablyFair {
  // Roll normal (determina se FLIP ativa)
  serverSeed: string;
  serverSeedHash: string;
  clientSeed: string;
  nonce: number;
  roll: number;
  
  // Roll do FLIP (determina qual item raro)
  flipServerSeed: string;    // serverSeed + '_flip'
  flipClientSeed: string;    // mesmo clientSeed
  flipNonce: number;         // nonce + 1
  flipRoll: number;
}

Geração dos Seeds FLIP

typescript
// open-case.use-case.ts
async processFlip(
  caseData: Case,
  serverSeed: string,
  clientSeed: string,
  nonce: number,
): Promise<FlipData> {
  // 1. Buscar itens elegíveis
  const flipItems = await this.flipService.getFlipItems(caseData.id);
  
  // 2. Gerar seeds específicos para FLIP
  const flipServerSeed = `${serverSeed}_flip`;
  const flipClientSeed = clientSeed; // Mesmo client seed
  const flipNonce = nonce + 1;
  
  // 3. Calcular roll do FLIP
  const flipRoll = this.provablyFairService.calculateRoll(
    flipServerSeed,
    flipClientSeed,
    flipNonce,
  );
  
  // 4. Selecionar item
  const wonItem = this.selectFlipItem(flipItems, flipRoll);
  
  // 5. Preparar 15 slots para roleta
  const rouletteItems = this.prepareFlipItemsForRoulette(flipItems);
  
  return {
    serverSeed: flipServerSeed,
    clientSeed: flipClientSeed,
    nonce: flipNonce,
    roll: flipRoll,
    item: wonItem,
    items: rouletteItems,
  };
}

Seleção do Item FLIP

typescript
// flip.service.ts
selectFlipItem(flipItems: CaseItem[], roll: number): Item {
  // Distribuir probabilidades proporcionalmente ao valor
  const totalValue = flipItems.reduce(
    (sum, item) => sum + Number(item.item.valueCents),
    0,
  );
  
  // Itens mais caros têm MENOR probabilidade (balanceamento)
  const weights = flipItems.map((item) => {
    const valueRatio = Number(item.item.valueCents) / totalValue;
    return 1 - valueRatio; // Inverter: mais caro = menos chance
  });
  
  // Normalizar weights
  const totalWeight = weights.reduce((a, b) => a + b, 0);
  const normalizedWeights = weights.map((w) => w / totalWeight);
  
  // Calcular ranges
  let currentPos = 0;
  const ranges = normalizedWeights.map((weight) => {
    const start = currentPos;
    const end = currentPos + weight * 100000 - 1;
    currentPos = end + 1;
    return { start, end };
  });
  
  // Encontrar item pelo roll
  for (let i = 0; i < flipItems.length; i++) {
    if (roll >= ranges[i].start && roll <= ranges[i].end) {
      return flipItems[i].item;
    }
  }
  
  // Fallback: retornar item mais comum
  return flipItems[flipItems.length - 1].item;
}

Preparação da Roleta FLIP

A segunda roleta exibe 15 slots com itens raros:

typescript
// flip.service.ts
prepareFlipItemsForRoulette(flipItems: CaseItem[]): CaseItem[] {
  const ROULETTE_SLOTS = 15;
  const rouletteItems: CaseItem[] = [];
  
  // Se há menos de 15 itens, repetir em ordem circular
  if (flipItems.length < ROULETTE_SLOTS) {
    let index = 0;
    for (let i = 0; i < ROULETTE_SLOTS; i++) {
      rouletteItems.push(flipItems[index]);
      index = (index + 1) % flipItems.length;
    }
  } else {
    // Se há 15+, pegar os 15 primeiros (mais raros)
    return flipItems.slice(0, ROULETTE_SLOTS);
  }
  
  return rouletteItems;
}

Frontend - Timeline

Estados Necessários

tsx
// case/[id]/page.tsx
const [isFlip, setIsFlip] = useState(false);
const [showKnifeAnimation, setShowKnifeAnimation] = useState(false);
const [showFlipRoulette, setShowFlipRoulette] = useState(false);
const [flipItems, setFlipItems] = useState<CaseItem[]>([]);
const [flipRoll, setFlipRoll] = useState<number | null>(null);
const [currentPhase, setCurrentPhase] = useState<
  'idle' | 'first-roulette' | 'knife' | 'second-roulette' | 'won'
>('idle');

Timeline Completa

typescript
const handleFlipOpen = async (result: OpenCaseResult) => {
  if (!result.isFlip) return;
  
  // Configurar estados FLIP
  setIsFlip(true);
  setFlipItems(result.flipItems);
  setFlipRoll(result.flipRoll);
  
  // === FASE 1: Primeira Roleta (t=0 a t=11.5s) ===
  setCurrentPhase('first-roulette');
  
  // t=1.5s: Iniciar spin para KNIFE
  setTimeout(() => {
    setIsSpinning(true);
    setRouletteOffset(offsetToKnife); // Para no card KNIFE
  }, 1500);
  
  // t=11.5s: Parar na KNIFE
  setTimeout(() => {
    setIsSpinning(false);
    setCurrentPhase('knife');
    setShowKnifeAnimation(true);
  }, 11500);
  
  // === FASE 2: Animação KNIFE (t=11.5s a t=14.5s) ===
  // KNIFE girando com efeito 3D
  
  // === FASE 3: Segunda Roleta (t=14.5s a t=25.5s) ===
  setTimeout(() => {
    setShowKnifeAnimation(false);
    setCurrentPhase('second-roulette');
    setShowFlipRoulette(true);
    setRouletteOffset(0); // Reset offset
  }, 14500);
  
  // t=15.5s: Iniciar spin da segunda roleta
  setTimeout(() => {
    setIsSpinning(true);
    const targetOffset = calculateFlipOffset(result.flipItems, result.flipRoll);
    setRouletteOffset(targetOffset);
  }, 15500);
  
  // t=25.5s: Mostrar tela de vitória
  setTimeout(() => {
    setIsSpinning(false);
    setCurrentPhase('won');
    setShowWinScreen(true);
  }, 25500);
};

Primeira Roleta (com KNIFE)

tsx
{/* Roleta Normal - Com KNIFE no meio */}
{currentPhase === 'first-roulette' && (
  <motion.div className="roulette-track">
    {[...Array(5)].map((_, repeatIndex) =>
      caseItems.map((item, itemIndex) => {
        const showKnife = repeatIndex === 2 && itemIndex === halfIndex;
        
        return (
          <>
            <RouletteCard key={`${repeatIndex}-${itemIndex}`} item={item} />
            
            {showKnife && (
              <div className="knife-card">
                <img src="/knife.png" alt="FLIP" />
                <span className="flip-badge">FLIP!</span>
              </div>
            )}
          </>
        );
      })
    )}
  </motion.div>
)}

Animação KNIFE

tsx
{/* KNIFE Girando */}
{showKnifeAnimation && (
  <motion.div
    className="knife-animation"
    animate={{
      rotateY: [0, 180, 360],
      scale: [1, 1.2, 1],
    }}
    transition={{
      duration: 3,
      repeat: Infinity,
      ease: "easeInOut",
    }}
  >
    <img src="/knife-3d.png" alt="FLIP!" />
    <motion.div
      className="flip-text"
      animate={{ opacity: [0, 1, 0] }}
      transition={{ duration: 1, repeat: Infinity }}
    >
      FLIP!
    </motion.div>
  </motion.div>
)}

Segunda Roleta (Itens Raros)

tsx
{/* Roleta FLIP - Apenas itens raros */}
{showFlipRoulette && (
  <motion.div
    className="roulette-track flip-roulette"
    animate={{ x: -rouletteOffset }}
    transition={{
      duration: 10,
      ease: [0.1, 0.8, 0.2, 1],
    }}
  >
    {[...Array(5)].map((_, repeatIndex) =>
      flipItems.map((item, itemIndex) => (
        <div
          key={`flip-${repeatIndex}-${itemIndex}`}
          className="flip-item-card"
          style={{ borderColor: getRarityColor(item.rarity) }}
        >
          <img src={item.imageUrl} alt={item.name} />
          <span className="flip-badge">FLIP</span>
          <span className="item-name">{item.name}</span>
          <span className="item-value">
            R$ {(item.valueCents / 100).toFixed(2)}
          </span>
        </div>
      ))
    )}
  </motion.div>
)}

Estilos FLIP

css
/* Card KNIFE na primeira roleta */
.knife-card {
  @apply relative flex items-center justify-center;
  @apply w-44 h-44 mx-2;
  @apply bg-gradient-to-b from-yellow-500/20 to-yellow-600/20;
  @apply border-2 border-yellow-500;
  @apply rounded-lg;
}

.flip-badge {
  @apply absolute top-2 right-2;
  @apply px-2 py-1;
  @apply bg-yellow-500 text-black;
  @apply font-bold text-xs;
  @apply rounded;
  animation: pulse 0.5s infinite;
}

/* Animação da KNIFE */
.knife-animation {
  @apply fixed inset-0 z-50;
  @apply flex items-center justify-center;
  @apply bg-black/80;
  perspective: 1000px;
}

.knife-animation img {
  @apply w-64 h-64;
  transform-style: preserve-3d;
}

/* Roleta FLIP */
.flip-roulette {
  @apply bg-gradient-to-r from-yellow-900/30 via-yellow-500/10 to-yellow-900/30;
}

.flip-item-card {
  @apply relative;
  @apply border-2 border-yellow-500;
  @apply bg-gradient-to-b from-yellow-500/10 to-transparent;
  box-shadow: 0 0 20px rgba(234, 179, 8, 0.3);
}

Verificação Provably Fair

O usuário pode verificar ambos os rolls:

typescript
// verify-flip.service.ts
async verifyFlip(openingId: bigint): Promise<FlipVerification> {
  const opening = await this.caseOpeningRepository.findById(openingId);
  
  if (!opening.isFlip) {
    throw new NotAFlipException();
  }
  
  // 1. Verificar roll normal (FLIP ativou?)
  const calculatedRoll = this.provablyFairService.calculateRoll(
    opening.serverSeed,
    opening.clientSeed,
    opening.nonce,
  );
  
  const shouldBeFlip = calculatedRoll < 5000;
  const rollValid = calculatedRoll === opening.roll;
  
  // 2. Verificar roll do FLIP
  const calculatedFlipRoll = this.provablyFairService.calculateRoll(
    opening.flipServerSeed,
    opening.flipClientSeed,
    opening.flipNonce,
  );
  
  const flipRollValid = calculatedFlipRoll === opening.flipRoll;
  
  return {
    openingId,
    
    // Roll normal
    roll: {
      serverSeed: opening.serverSeed,
      serverSeedHash: opening.serverSeedHash,
      clientSeed: opening.clientSeed,
      nonce: opening.nonce,
      calculated: calculatedRoll,
      stored: opening.roll,
      valid: rollValid,
    },
    
    // Roll FLIP
    flipRoll: {
      serverSeed: opening.flipServerSeed,
      clientSeed: opening.flipClientSeed,
      nonce: opening.flipNonce,
      calculated: calculatedFlipRoll,
      stored: opening.flipRoll,
      valid: flipRollValid,
    },
    
    // Resultado
    flipActivatedCorrectly: shouldBeFlip === opening.isFlip,
    allValid: rollValid && flipRollValid && shouldBeFlip === opening.isFlip,
  };
}

Banco de Dados

prisma
model CaseOpening {
  // ... campos existentes
  
  // Sistema FLIP
  isFlip          Boolean   @default(false)
  flipServerSeed  String?   // serverSeed + '_flip'
  flipClientSeed  String?   // Mesmo clientSeed
  flipNonce       Int?      // nonce + 1
  flipRoll        Int?      // Roll da segunda roleta
  
  // ...
}

Response da API

json
{
  "openingId": "123456789",
  "item": {
    "id": "999",
    "name": "AWP | Dragon Lore",
    "rarity": "EXTRAORDINARY",
    "valueCents": 250000,
    "imageUrl": "https://..."
  },
  "roll": 3888,
  "serverSeedHash": "abc123...",
  "isFlip": true,
  "flipRoll": 87543,
  "flipItems": [
    {
      "id": "999",
      "name": "AWP | Dragon Lore",
      "rarity": "EXTRAORDINARY",
      "valueCents": 250000
    },
    {
      "id": "998",
      "name": "M4A4 | Howl",
      "rarity": "COVERT",
      "valueCents": 180000
    },
    {
      "id": "997",
      "name": "AK-47 | Fire Serpent",
      "rarity": "COVERT",
      "valueCents": 120000
    }
  ]
}

Debugging

typescript
// Backend
console.log('[OpenCase] Roll:', roll);
console.log('[OpenCase] Is FLIP:', roll < 5000);
console.log('[OpenCase] FLIP Items:', flipItems.length);
console.log('[OpenCase] FLIP Roll:', flipRoll);

// Frontend
console.log('[FLIP] Phase:', currentPhase);
console.log('[FLIP] Timeline:');
console.log('  0.0s: Start');
console.log('  1.5s: Begin first roulette');
console.log(' 11.5s: Stop on KNIFE');
console.log(' 14.5s: Begin second roulette');
console.log(' 25.5s: Show win');

Regras Importantes

  1. FLIP é determinado pelo roll normal (< 5000)
  2. Dois conjuntos de seeds são salvos para verificação
  3. flipServerSeed = serverSeed + '_flip'
  4. flipNonce = nonce + 1
  5. 15 slots na segunda roleta (itens podem repetir)
  6. Timeline total: 25.5 segundos
  7. KNIFE aparece no centro da primeira roleta

Documentação Técnica CSGOFlip