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