2025-06-12 22:35:54 +02:00
< script lang = "ts" >
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
2025-06-24 12:46:38 +03:00
2025-06-12 22:35:54 +02:00
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import confetti from 'canvas-confetti';
import { toast } from 'svelte-sonner';
import { formatValue , playSound , showConfetti , showSchoolPrideCannons } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount , onDestroy } from 'svelte';
import { ModeWatcher } from 'mode-watcher';
2025-06-24 12:34:58 +03:00
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
2025-06-12 22:35:54 +02:00
const GRID_SIZE = 5;
const TOTAL_TILES = GRID_SIZE * GRID_SIZE;
const MAX_BET_AMOUNT = 1000000;
const MIN_MINES = 3;
2025-06-24 12:46:38 +03:00
const AUTO_CASHOUT_TIME = 15;
2025-06-12 22:35:54 +02:00
let {
balance = $bindable(),
onBalanceUpdate
}: {
balance: number;
onBalanceUpdate?: (newBalance: number) => void;
} = $props();
let betAmount = $state(10);
let betAmountDisplay = $state('10');
let mineCount = $state(3);
let isPlaying = $state(false);
let revealedTiles = $state< number [ ] > ([]);
let minePositions = $state< number [ ] > ([]);
let currentMultiplier = $state(1);
let autoCashoutTimer = $state(0);
let autoCashoutProgress = $state(0);
let sessionToken = $state< string | null > (null);
let autoCashoutInterval: ReturnType< typeof setInterval > ;
let hasRevealedTile = $state(false);
let isAutoCashout = $state(false);
let lastClickedTile = $state< number | null > (null);
let clickedSafeTiles = $state< number [ ] > ([]);
let canBet = $derived(
betAmount > 0 & & betAmount < = balance & & betAmount < = MAX_BET_AMOUNT & & !isPlaying
);
function calculateProbability(picks: number, mines: number): string {
let probability = 1;
for (let i = 0; i < picks ; i ++) {
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
}
return (probability * 100).toFixed(2);
}
function calculateRawMultiplier(picks: number, mines: number): number {
let probability = 1;
for (let i = 0; i < picks ; i ++) {
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
}
2025-06-24 12:46:38 +03:00
return probability === 0 ? 1.0 : Math.max(1.0, 1 / probability);
2025-06-12 22:35:54 +02:00
}
function setBetAmount(amount: number) {
2025-06-24 12:46:38 +03:00
const clamped = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
if (clamped >= 0) {
betAmount = clamped;
betAmountDisplay = clamped.toLocaleString();
2025-06-12 22:35:54 +02:00
}
}
function handleBetAmountInput(event: Event) {
2025-06-24 12:46:38 +03:00
const value = (event.target as HTMLInputElement).value.replace(/,/g, '');
const num = parseFloat(value) || 0;
const clamped = Math.min(num, Math.min(balance, MAX_BET_AMOUNT));
betAmount = clamped;
betAmountDisplay = value;
2025-06-12 22:35:54 +02:00
}
function handleBetAmountBlur() {
betAmountDisplay = betAmount.toLocaleString();
}
function resetAutoCashoutTimer() {
2025-06-24 12:46:38 +03:00
if (autoCashoutInterval) clearInterval(autoCashoutInterval);
2025-06-12 22:35:54 +02:00
autoCashoutTimer = 0;
autoCashoutProgress = 0;
}
function startAutoCashoutTimer() {
if (!hasRevealedTile) return;
resetAutoCashoutTimer();
autoCashoutInterval = setInterval(() => {
if (autoCashoutTimer < AUTO_CASHOUT_TIME ) {
autoCashoutTimer += 0.1;
autoCashoutProgress = (autoCashoutTimer / AUTO_CASHOUT_TIME) * 100;
}
if (autoCashoutTimer >= AUTO_CASHOUT_TIME) {
isAutoCashout = true;
2025-06-15 20:50:55 +02:00
clearInterval(autoCashoutInterval);
2025-06-12 22:35:54 +02:00
cashOut();
}
}, 100);
}
async function handleTileClick(index: number) {
if (!isPlaying || revealedTiles.includes(index) || !sessionToken) return;
lastClickedTile = index;
try {
const response = await fetch('/api/gambling/mines/reveal', {
method: 'POST',
2025-06-24 12:46:38 +03:00
headers: { 'Content-Type' : 'application/json' } ,
body: JSON.stringify({ sessionToken , tileIndex : index } )
2025-06-12 22:35:54 +02:00
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to reveal tile');
}
const result = await response.json();
if (result.hitMine) {
playSound('lose');
2025-06-24 12:30:23 +03:00
revealedTiles = [...revealedTiles, index];
2025-06-12 22:35:54 +02:00
minePositions = result.minePositions;
isPlaying = false;
resetAutoCashoutTimer();
balance = result.newBalance;
onBalanceUpdate?.(result.newBalance);
2025-06-24 12:46:38 +03:00
} else {
2025-06-12 22:35:54 +02:00
playSound('flip');
revealedTiles = [...revealedTiles, index];
clickedSafeTiles = [...clickedSafeTiles, index];
currentMultiplier = result.currentMultiplier;
hasRevealedTile = true;
startAutoCashoutTimer();
if (result.status === 'won') {
showSchoolPrideCannons(confetti);
showConfetti(confetti);
await cashOut();
}
}
} catch (error) {
console.error('Mines error:', error);
toast.error('Failed to reveal tile', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
async function cashOut() {
if (!isPlaying || !sessionToken) return;
try {
const response = await fetch('/api/gambling/mines/cashout', {
method: 'POST',
2025-06-24 12:46:38 +03:00
headers: { 'Content-Type' : 'application/json' } ,
body: JSON.stringify({ sessionToken } )
2025-06-12 22:35:54 +02:00
});
if (!response.ok) {
const errorData = await response.json();
if (!isAutoCashout || errorData.error !== 'Invalid session') {
throw new Error(errorData.error || 'Failed to cash out');
}
return;
}
const result = await response.json();
balance = result.newBalance;
onBalanceUpdate?.(balance);
2025-06-24 12:46:38 +03:00
if (result.payout > betAmount) showConfetti(confetti);
2025-06-12 22:35:54 +02:00
playSound(result.isAbort ? 'flip' : 'win');
isPlaying = false;
hasRevealedTile = false;
isAutoCashout = false;
resetAutoCashoutTimer();
2025-06-24 12:30:23 +03:00
minePositions = [];
2025-06-12 22:35:54 +02:00
} catch (error) {
console.error('Cashout error:', error);
toast.error('Failed to cash out', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
async function startGame() {
if (!canBet) return;
balance -= betAmount;
onBalanceUpdate?.(balance);
try {
const response = await fetch('/api/gambling/mines/start', {
method: 'POST',
2025-06-24 12:46:38 +03:00
headers: { 'Content-Type' : 'application/json' } ,
body: JSON.stringify({ betAmount , mineCount } )
2025-06-12 22:35:54 +02:00
});
if (!response.ok) {
const errorData = await response.json();
balance += betAmount;
onBalanceUpdate?.(balance);
throw new Error(errorData.error || 'Failed to start game');
}
const result = await response.json();
isPlaying = true;
hasRevealedTile = false;
revealedTiles = [];
clickedSafeTiles = [];
currentMultiplier = 1;
sessionToken = result.sessionToken;
2025-06-24 12:30:23 +03:00
minePositions = [];
2025-06-12 22:35:54 +02:00
} catch (error) {
console.error('Start game error:', error);
toast.error('Failed to start game', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
2025-06-15 20:50:55 +02:00
onMount(async () => {
2025-06-12 22:35:54 +02:00
volumeSettings.load();
2025-06-15 20:50:55 +02:00
try {
2025-06-24 12:34:58 +03:00
const data = await fetchPortfolioSummary();
if (data) {
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
2025-06-15 20:50:55 +02:00
}
} catch (error) {
console.error('Failed to fetch balance:', error);
}
2025-06-12 22:35:54 +02:00
});
2025-06-24 12:46:38 +03:00
onDestroy(resetAutoCashoutTimer);
2025-06-12 22:35:54 +02:00
< / script >
< Card >
< CardHeader >
< CardTitle > Mines< / CardTitle >
< CardDescription >
Navigate through the minefield and cash out before hitting a mine!
< / CardDescription >
< / CardHeader >
< CardContent >
< div class = "grid grid-cols-1 gap-8 md:grid-cols-2" >
<!-- Left Side: Grid and Stats -->
< div class = "flex flex-col space-y-4" >
<!-- Balance Display -->
< div class = "text-center" >
< p class = "text-muted-foreground text-sm" > Balance< / p >
< p class = "text-2xl font-bold" > { formatValue ( balance )} </ p >
< / div >
<!-- Mines Grid -->
< div class = "mines-grid" class:pulse-warning = { isPlaying && autoCashoutTimer >= 7 } >
{ #each Array ( TOTAL_TILES ) as _ , index }
< ModeWatcher / >
< button
class="mine-tile"
onclick={() => handleTileClick ( index )}
disabled={ ! isPlaying }
2025-06-24 12:46:38 +03:00
class:revealed={ revealedTiles . includes ( index )}
class:mine={ revealedTiles . includes ( index ) &&
minePositions.includes(index) & &
!clickedSafeTiles.includes(index)}
class:safe={ revealedTiles . includes ( index ) &&
!minePositions.includes(index) & &
clickedSafeTiles.includes(index)}
class:light={ document . documentElement . classList . contains ( 'light' )}
aria-label="Tile"
2025-06-12 22:35:54 +02:00
>
{ #if revealedTiles . includes ( index )}
{ #if minePositions . includes ( index )}
2025-06-24 12:46:38 +03:00
< img src = "/facedev/avif/bussin.avif" alt = "Mine" class = "h-8 w-8 object-contain" / >
2025-06-12 22:35:54 +02:00
{ : else }
< img
src="/facedev/avif/twoblade.avif"
alt="Safe"
class="h-8 w-8 object-contain"
/>
{ /if }
{ /if }
< / button >
{ /each }
< / div >
< / div >
2025-06-24 12:46:38 +03:00
<!-- Right Side: Controls -->
2025-06-12 22:35:54 +02:00
< div class = "space-y-4" >
< div >
2025-06-24 12:46:38 +03:00
< label for = "mine-count" class = "mb-2 block text-sm font-medium" > Number of Mines< / label >
2025-06-24 12:30:23 +03:00
< div class = "flex items-center gap-2" >
< Button
variant="secondary"
size="sm"
2025-06-24 12:46:38 +03:00
onclick={() => ( mineCount = Math . max ( mineCount - 1 , MIN_MINES ))}
2025-06-24 12:30:23 +03:00
disabled={ isPlaying || mineCount <= MIN_MINES }
2025-06-24 12:46:38 +03:00
aria-label="Decrease mines">-< /Button
>
2025-06-24 12:30:23 +03:00
< Input
id="mine-count"
type="number"
min={ MIN_MINES }
max={ 24 }
value={ mineCount }
2025-06-24 12:46:38 +03:00
oninput={( e ) => {
2025-06-24 12:30:23 +03:00
const target = e.target as HTMLInputElement | null;
const val = Math.max(
MIN_MINES,
Math.min(24, parseInt(target?.value ?? '') || MIN_MINES)
);
mineCount = val;
}}
2025-06-12 22:35:54 +02:00
disabled={ isPlaying }
2025-06-24 12:30:23 +03:00
class="w-12 text-center [appearance:textfield] [& ::-webkit-inner-spin-button]:appearance-none [& ::-webkit-outer-spin-button]:appearance-none"
/>
< Button
variant="secondary"
size="sm"
2025-06-24 12:46:38 +03:00
onclick={() => ( mineCount = Math . min ( mineCount + 1 , 24 ))}
2025-06-24 12:30:23 +03:00
disabled={ isPlaying || mineCount >= 24 }
2025-06-24 12:46:38 +03:00
aria-label="Increase mines">+< /Button
>
2025-06-12 22:35:54 +02:00
< / div >
2025-06-24 12:30:23 +03:00
< p class = "text-muted-foreground mt-1 text-xs" >
You will get
2025-06-24 12:46:38 +03:00
< span class = "text-success font-semibold" >
{ calculateRawMultiplier ( isPlaying ? revealedTiles . length + 1 : 1 , mineCount ). toFixed (
2
)}x
2025-06-24 12:30:23 +03:00
< / span >
per tile, probability of winning:
2025-06-24 12:46:38 +03:00
< span class = "text-success font-semibold" >
{ calculateProbability ( isPlaying ? 1 : 1 , mineCount )} %
2025-06-24 12:30:23 +03:00
< / span >
< / p >
2025-06-12 22:35:54 +02:00
< / div >
< div >
< label for = "bet-amount" class = "mb-2 block text-sm font-medium" > Bet Amount< / label >
< Input
id="bet-amount"
type="text"
value={ betAmountDisplay }
oninput={ handleBetAmountInput }
onblur={ handleBetAmountBlur }
disabled={ isPlaying }
placeholder="Enter bet amount"
/>
< p class = "text-muted-foreground mt-1 text-xs" >
Max bet: { MAX_BET_AMOUNT . toLocaleString ()}
< / p >
< / div >
< div >
< div class = "grid grid-cols-4 gap-2" >
< Button
size="sm"
variant="outline"
onclick={() => setBetAmount ( Math . floor ( Math . min ( balance , MAX_BET_AMOUNT ) * 0.25 ))}
disabled={ isPlaying } >25%< /Button
>
< Button
size="sm"
variant="outline"
onclick={() => setBetAmount ( Math . floor ( Math . min ( balance , MAX_BET_AMOUNT ) * 0.5 ))}
disabled={ isPlaying } >50%< /Button
>
< Button
size="sm"
variant="outline"
onclick={() => setBetAmount ( Math . floor ( Math . min ( balance , MAX_BET_AMOUNT ) * 0.75 ))}
disabled={ isPlaying } >75%< /Button
>
< Button
size="sm"
variant="outline"
onclick={() => setBetAmount ( Math . floor ( Math . min ( balance , MAX_BET_AMOUNT )))}
2025-06-24 12:46:38 +03:00
disabled={ isPlaying } >Max< /Button
>
2025-06-12 22:35:54 +02:00
< / div >
< / div >
< div class = "flex flex-col gap-2" >
{ #if ! isPlaying }
< Button class = "h-12 flex-1 text-lg" onclick = { startGame } disabled= { ! canBet } >
Start Game
< / Button >
{ : else }
{ #if hasRevealedTile }
< div class = "space-y-1" >
2025-06-24 12:46:38 +03:00
< div class = "bg-border h-px w-full" > < / div >
< div class = "text-muted-foreground text-center text-xs" >
2025-06-12 22:35:54 +02:00
Auto Cashout in { Math . ceil ( AUTO_CASHOUT_TIME - autoCashoutTimer )} s
< / div >
2025-06-24 12:46:38 +03:00
< div class = "bg-muted h-1 w-full overflow-hidden rounded-full" >
2025-06-12 22:35:54 +02:00
< div
2025-06-24 12:46:38 +03:00
class="bg-primary h-full transition-all duration-100"
2025-06-12 22:35:54 +02:00
class:urgent={ autoCashoutTimer >= 7 }
style="width: { autoCashoutProgress } %"
>< / div >
< / div >
2025-06-24 12:46:38 +03:00
< div class = "bg-border h-px w-full" > < / div >
2025-06-12 22:35:54 +02:00
< / div >
{ /if }
< Button class = "h-12 flex-1 text-lg" onclick = { cashOut } disabled= { ! isPlaying } >
{ hasRevealedTile ? 'Cash Out' : 'Abort Bet' }
< / Button >
<!-- Current Stats -->
{ #if hasRevealedTile }
< div class = "bg-muted/50 space-y-2 rounded-lg p-3" >
< div class = "flex justify-between" >
< span > Current Profit:< / span >
< span class = "text-success" >
+{ formatValue ( betAmount * ( currentMultiplier - 1 ))}
< / span >
< / div >
< div class = "flex justify-between" >
< span > Next Tile:< / span >
< span >
2025-06-24 12:46:38 +03:00
+{ formatValue (
betAmount * (calculateRawMultiplier(revealedTiles.length + 1, mineCount) - 1)
)}
2025-06-12 22:35:54 +02:00
< / span >
< / div >
< div class = "flex justify-between" >
< span > Current Multiplier:< / span >
< span > { currentMultiplier . toFixed ( 2 )} x</ span >
< / div >
< / div >
{ /if }
{ /if }
< / div >
< / div >
< / div >
< / CardContent >
< / Card >
< style >
.mines-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
background: var(--muted);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 8px;
transition: all 0.3s ease;
}
.mines-grid.pulse-warning {
position: relative;
}
.mines-grid.pulse-warning::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
border: 2px solid var(--ring);
border-radius: var(--radius);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
.urgent {
position: relative;
}
.urgent::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
border: 2px solid var(--ring);
border-radius: 9999px;
animation: pulse 0.5s ease-in-out infinite;
}
.mine-tile {
aspect-ratio: 1;
background: var(--card);
border: 1px solid black;
border-radius: calc(var(--radius) - 2px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
transform-style: preserve-3d;
position: relative;
cursor: pointer;
}
.mine-tile:hover:not(:disabled) {
background: var(--accent);
}
.mine-tile.revealed {
background: var(--muted);
transform: rotateY(180deg);
}
.mine-tile.mine {
background-color: rgba(239, 68, 68, 0.3);
border: 2px solid rgb(239, 68, 68);
}
.mine-tile.mine img {
filter: brightness(0.9) contrast(1.4);
}
.mine-tile.safe {
background-color: rgba(34, 197, 94, 0.2);
border: 2px solid rgb(34, 197, 94);
}
.mine-tile img {
backface-visibility: hidden;
transform: rotateY(180deg);
width: 32px;
height: 32px;
object-fit: contain;
}
2025-06-24 12:46:38 +03:00
< / style >