Merge pull request #77 from MD1125/main
Add Mines Gambling Game to Rugplay
This commit is contained in:
commit
05b44be32e
13 changed files with 1563 additions and 12 deletions
|
|
@ -7,6 +7,7 @@ import { redirect, type Handle } from '@sveltejs/kit';
|
|||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { minesCleanupInactiveGames, minesAutoCashout } from '$lib/server/games/mines';
|
||||
|
||||
async function initializeScheduler() {
|
||||
if (building) return;
|
||||
|
|
@ -49,10 +50,16 @@ async function initializeScheduler() {
|
|||
processAccountDeletions().catch(console.error);
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const minesCleanupInterval = setInterval(() => {
|
||||
minesCleanupInactiveGames().catch(console.error);
|
||||
minesAutoCashout().catch(console.error);
|
||||
}, 60 * 1000);
|
||||
|
||||
// Cleanup on process exit
|
||||
const cleanup = async () => {
|
||||
clearInterval(renewInterval);
|
||||
clearInterval(schedulerInterval);
|
||||
clearInterval(minesCleanupInterval);
|
||||
const currentValue = await redis.get(lockKey);
|
||||
if (currentValue === lockValue) {
|
||||
await redis.del(lockKey);
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@
|
|||
import { formatValue, playSound, showConfetti } from '$lib/utils';
|
||||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
|
||||
|
||||
interface CoinflipResult {
|
||||
won: boolean;
|
||||
|
|
@ -314,8 +315,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const data = await fetchPortfolioSummary();
|
||||
if (data) {
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
419
website/src/lib/components/self/games/Dice.svelte
Normal file
419
website/src/lib/components/self/games/Dice.svelte
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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 } from 'svelte';
|
||||
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
|
||||
|
||||
interface DiceResult {
|
||||
won: boolean;
|
||||
result: number;
|
||||
newBalance: number;
|
||||
payout: number;
|
||||
amountWagered: number;
|
||||
}
|
||||
|
||||
const MAX_BET_AMOUNT = 1000000;
|
||||
const baseRotation = "rotate3d(1, 1, 0, 340deg)";
|
||||
|
||||
const faceRotations = {
|
||||
1: { x: 0, y: 0 },
|
||||
2: { x: 0, y: 90 },
|
||||
3: { x: 90, y: 0 },
|
||||
4: { x: -90, y: 0 },
|
||||
5: { x: 0, y: -90 },
|
||||
6: { x: 0, y: 180 }
|
||||
};
|
||||
|
||||
const diceRotations = {
|
||||
1: { x: 0, y: 0, z: 0 },
|
||||
2: { x: 0, y: -90, z: 0 },
|
||||
3: { x: -90, y: 0, z: 0 },
|
||||
4: { x: 90, y: 0, z: 0 },
|
||||
5: { x: 0, y: 90, z: 0 },
|
||||
6: { x: 0, y: 180, z: 0 }
|
||||
};
|
||||
|
||||
function getExtraSpin(spinFactor = 4) {
|
||||
const extraSpinsX = spinFactor * 360;
|
||||
const extraSpinsY = spinFactor * 360;
|
||||
const extraSpinsZ = spinFactor * 360;
|
||||
|
||||
return {
|
||||
x: extraSpinsX,
|
||||
y: extraSpinsY,
|
||||
z: extraSpinsZ
|
||||
};
|
||||
}
|
||||
|
||||
function getFaceRotation(face: number) {
|
||||
return faceRotations[face as keyof typeof faceRotations];
|
||||
}
|
||||
|
||||
function getFaceTransform(face: number): string {
|
||||
const rotation = getFaceRotation(face);
|
||||
return `${getRotate(rotation.x, rotation.y)} translateZ(50px)`;
|
||||
}
|
||||
|
||||
function getDiceRotation(face: number, addExtraSpin = false, spinFactor = 4) {
|
||||
let extraSpin = { x: 0, y: 0, z: 0 };
|
||||
|
||||
if (addExtraSpin) {
|
||||
extraSpin = getExtraSpin(spinFactor);
|
||||
}
|
||||
|
||||
const rotation = diceRotations[face as keyof typeof diceRotations];
|
||||
return {
|
||||
x: rotation.x + extraSpin.x,
|
||||
y: rotation.y + extraSpin.y,
|
||||
z: rotation.z + extraSpin.z
|
||||
};
|
||||
}
|
||||
|
||||
function getDiceTransform(face: number, addExtraSpin = false, spinFactor = 4): string {
|
||||
const rotation = getDiceRotation(face, addExtraSpin, spinFactor);
|
||||
return `${baseRotation} ${getRotate(rotation.x, rotation.y, rotation.z)}`;
|
||||
}
|
||||
|
||||
function getRotate(x?: number, y?: number, z?: number) {
|
||||
const rotateX = x !== undefined ? `rotateX(${x}deg)` : "";
|
||||
const rotateY = y !== undefined ? `rotateY(${y}deg)` : "";
|
||||
const rotateZ = z !== undefined ? `rotateZ(${z}deg)` : "";
|
||||
return `${rotateX} ${rotateY} ${rotateZ}`;
|
||||
}
|
||||
|
||||
let {
|
||||
balance = $bindable(),
|
||||
onBalanceUpdate
|
||||
}: {
|
||||
balance: number;
|
||||
onBalanceUpdate?: (newBalance: number) => void;
|
||||
} = $props();
|
||||
|
||||
let betAmount = $state(10);
|
||||
let betAmountDisplay = $state('10');
|
||||
let selectedNumber = $state(1);
|
||||
let isRolling = $state(false);
|
||||
let lastResult = $state<DiceResult | null>(null);
|
||||
let activeSoundTimeouts = $state<NodeJS.Timeout[]>([]);
|
||||
let diceElement: HTMLElement | null = null;
|
||||
|
||||
let canBet = $derived(
|
||||
betAmount > 0 && betAmount <= balance && betAmount <= MAX_BET_AMOUNT && !isRolling
|
||||
);
|
||||
|
||||
function selectNumber(num: number) {
|
||||
if (!isRolling) {
|
||||
selectedNumber = num;
|
||||
playSound('click');
|
||||
}
|
||||
}
|
||||
|
||||
function setBetAmount(amount: number) {
|
||||
const clampedAmount = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
|
||||
if (clampedAmount >= 0) {
|
||||
betAmount = clampedAmount;
|
||||
betAmountDisplay = clampedAmount.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBetAmountInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.value.replace(/,/g, '');
|
||||
const numValue = parseFloat(value) || 0;
|
||||
const clampedValue = Math.min(numValue, Math.min(balance, MAX_BET_AMOUNT));
|
||||
|
||||
betAmount = clampedValue;
|
||||
betAmountDisplay = target.value;
|
||||
}
|
||||
|
||||
function handleBetAmountBlur() {
|
||||
betAmountDisplay = betAmount.toLocaleString();
|
||||
}
|
||||
|
||||
async function rollDice() {
|
||||
if (!canBet) return;
|
||||
|
||||
isRolling = true;
|
||||
lastResult = null;
|
||||
|
||||
activeSoundTimeouts.forEach(clearTimeout);
|
||||
activeSoundTimeouts = [];
|
||||
|
||||
const spinFactor = 20;
|
||||
const animationDuration = 1500;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/gambling/dice', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
selectedNumber,
|
||||
amount: betAmount
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to place bet');
|
||||
}
|
||||
|
||||
const resultData: DiceResult = await response.json();
|
||||
|
||||
playSound('dice');
|
||||
if (diceElement) {
|
||||
diceElement.style.transition = 'none';
|
||||
diceElement.style.transform = getDiceTransform(selectedNumber, false);
|
||||
void diceElement.offsetHeight;
|
||||
diceElement.style.transition = 'transform 1.5s cubic-bezier(0.1, 0.9, 0.1, 1)';
|
||||
diceElement.style.transform = getDiceTransform(resultData.result, true, spinFactor);
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, animationDuration));
|
||||
await new Promise(resolve => setTimeout(resolve, 200)); // Small delay to show the Result
|
||||
|
||||
balance = resultData.newBalance;
|
||||
lastResult = resultData;
|
||||
onBalanceUpdate?.(resultData.newBalance);
|
||||
|
||||
if (resultData.won) {
|
||||
showConfetti(confetti);
|
||||
showSchoolPrideCannons(confetti);
|
||||
} else {
|
||||
playSound('lose');
|
||||
}
|
||||
|
||||
isRolling = false;
|
||||
} catch (error) {
|
||||
console.error('Dice roll error:', error);
|
||||
toast.error('Roll failed', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
isRolling = false;
|
||||
activeSoundTimeouts.forEach(clearTimeout);
|
||||
activeSoundTimeouts = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const data = await fetchPortfolioSummary();
|
||||
if (data) {
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Dice</CardTitle>
|
||||
<CardDescription>Choose a number and roll the dice to win 3x your bet!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="text-center">
|
||||
<p class="text-muted-foreground text-sm">Balance</p>
|
||||
<p class="text-2xl font-bold">{formatValue(balance)}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="dice-container">
|
||||
<div class="dice" bind:this={diceElement}>
|
||||
{#each Array(6) as _, i}
|
||||
<div class="face" style="transform: {getFaceTransform(i + 1)}">
|
||||
<div class="dot-container">
|
||||
{#each Array(i + 1) as _}
|
||||
<div class="dot"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center text-center">
|
||||
{#if lastResult && !isRolling}
|
||||
<div class="bg-muted/50 w-full rounded-lg p-3">
|
||||
{#if lastResult.won}
|
||||
<p class="text-success font-semibold">WIN</p>
|
||||
<p class="text-sm">
|
||||
Won {formatValue(lastResult.payout)} on {lastResult.result}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-destructive font-semibold">LOSS</p>
|
||||
<p class="text-sm">
|
||||
Lost {formatValue(lastResult.amountWagered)} on {lastResult.result}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="mb-2 block text-sm font-medium">Choose Number</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
{#each Array(6) as _, i}
|
||||
<Button
|
||||
variant={selectedNumber === i + 1 ? 'default' : 'outline'}
|
||||
onclick={() => selectNumber(i + 1)}
|
||||
disabled={isRolling}
|
||||
class="h-16"
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</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={isRolling}
|
||||
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 || 0, MAX_BET_AMOUNT) * 0.25))}
|
||||
disabled={isRolling}>25%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.5))}
|
||||
disabled={isRolling}>50%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() =>
|
||||
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.75))}
|
||||
disabled={isRolling}>75%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT)))}
|
||||
disabled={isRolling}>Max</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button class="h-12 w-full text-lg" onclick={rollDice} disabled={!canBet}>
|
||||
{isRolling ? 'Rolling...' : 'Roll'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.dice-container {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.dice {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotate3d(0.9, 1, 0, 340deg);
|
||||
transition: transform 4s cubic-bezier(0.1, 0.9, 0.1, 1);
|
||||
}
|
||||
|
||||
.face {
|
||||
position: absolute;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #fff;
|
||||
border: 2px solid #363131;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backface-visibility: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.face:nth-child(1) { transform: translateZ(50px); }
|
||||
.face:nth-child(2) { transform: rotateY(90deg) translateZ(50px); }
|
||||
.face:nth-child(3) { transform: rotateX(90deg) translateZ(50px); }
|
||||
.face:nth-child(4) { transform: rotateX(-90deg) translateZ(50px); }
|
||||
.face:nth-child(5) { transform: rotateY(-90deg) translateZ(50px); }
|
||||
.face:nth-child(6) { transform: rotateY(180deg) translateZ(50px); }
|
||||
|
||||
.dot-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
padding: 15%;
|
||||
gap: 10%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dot {
|
||||
background: #363131;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Dot positions for each face */
|
||||
.face:nth-child(1) .dot { grid-area: 2 / 2; }
|
||||
.face:nth-child(2) .dot:nth-child(1) { grid-area: 1 / 1; }
|
||||
.face:nth-child(2) .dot:nth-child(2) { grid-area: 3 / 3; }
|
||||
.face:nth-child(3) .dot:nth-child(1) { grid-area: 1 / 1; }
|
||||
.face:nth-child(3) .dot:nth-child(2) { grid-area: 2 / 2; }
|
||||
.face:nth-child(3) .dot:nth-child(3) { grid-area: 3 / 3; }
|
||||
.face:nth-child(4) .dot:nth-child(1) { grid-area: 1 / 1; }
|
||||
.face:nth-child(4) .dot:nth-child(2) { grid-area: 1 / 3; }
|
||||
.face:nth-child(4) .dot:nth-child(3) { grid-area: 3 / 1; }
|
||||
.face:nth-child(4) .dot:nth-child(4) { grid-area: 3 / 3; }
|
||||
.face:nth-child(5) .dot:nth-child(1) { grid-area: 1 / 1; }
|
||||
.face:nth-child(5) .dot:nth-child(2) { grid-area: 1 / 3; }
|
||||
.face:nth-child(5) .dot:nth-child(3) { grid-area: 2 / 2; }
|
||||
.face:nth-child(5) .dot:nth-child(4) { grid-area: 3 / 1; }
|
||||
.face:nth-child(5) .dot:nth-child(5) { grid-area: 3 / 3; }
|
||||
.face:nth-child(6) .dot:nth-child(1) { grid-area: 1 / 1; }
|
||||
.face:nth-child(6) .dot:nth-child(2) { grid-area: 1 / 3; }
|
||||
.face:nth-child(6) .dot:nth-child(3) { grid-area: 2 / 1; }
|
||||
.face:nth-child(6) .dot:nth-child(4) { grid-area: 2 / 3; }
|
||||
.face:nth-child(6) .dot:nth-child(5) { grid-area: 3 / 1; }
|
||||
.face:nth-child(6) .dot:nth-child(6) { grid-area: 3 / 3; }
|
||||
</style>
|
||||
537
website/src/lib/components/self/games/Mines.svelte
Normal file
537
website/src/lib/components/self/games/Mines.svelte
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
|
||||
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';
|
||||
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
|
||||
|
||||
const GRID_SIZE = 5;
|
||||
const TOTAL_TILES = GRID_SIZE * GRID_SIZE;
|
||||
const MAX_BET_AMOUNT = 1000000;
|
||||
const MIN_MINES = 3;
|
||||
const AUTO_CASHOUT_TIME = 15;
|
||||
|
||||
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);
|
||||
}
|
||||
return probability === 0 ? 1.0 : Math.max(1.0, 1 / probability);
|
||||
}
|
||||
|
||||
function setBetAmount(amount: number) {
|
||||
const clamped = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
|
||||
if (clamped >= 0) {
|
||||
betAmount = clamped;
|
||||
betAmountDisplay = clamped.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
function handleBetAmountInput(event: Event) {
|
||||
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;
|
||||
}
|
||||
|
||||
function handleBetAmountBlur() {
|
||||
betAmountDisplay = betAmount.toLocaleString();
|
||||
}
|
||||
|
||||
function resetAutoCashoutTimer() {
|
||||
if (autoCashoutInterval) clearInterval(autoCashoutInterval);
|
||||
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;
|
||||
clearInterval(autoCashoutInterval);
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionToken, tileIndex: index })
|
||||
});
|
||||
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');
|
||||
revealedTiles = [...revealedTiles, index];
|
||||
minePositions = result.minePositions;
|
||||
isPlaying = false;
|
||||
resetAutoCashoutTimer();
|
||||
balance = result.newBalance;
|
||||
onBalanceUpdate?.(result.newBalance);
|
||||
} else {
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionToken })
|
||||
});
|
||||
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);
|
||||
if (result.payout > betAmount) showConfetti(confetti);
|
||||
playSound(result.isAbort ? 'flip' : 'win');
|
||||
isPlaying = false;
|
||||
hasRevealedTile = false;
|
||||
isAutoCashout = false;
|
||||
resetAutoCashoutTimer();
|
||||
minePositions = [];
|
||||
} 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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ betAmount, mineCount })
|
||||
});
|
||||
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;
|
||||
minePositions = [];
|
||||
} catch (error) {
|
||||
console.error('Start game error:', error);
|
||||
toast.error('Failed to start game', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
try {
|
||||
const data = await fetchPortfolioSummary();
|
||||
if (data) {
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(resetAutoCashoutTimer);
|
||||
</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}
|
||||
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"
|
||||
>
|
||||
{#if revealedTiles.includes(index)}
|
||||
{#if minePositions.includes(index)}
|
||||
<img src="/facedev/avif/bussin.avif" alt="Mine" class="h-8 w-8 object-contain" />
|
||||
{:else}
|
||||
<img
|
||||
src="/facedev/avif/twoblade.avif"
|
||||
alt="Safe"
|
||||
class="h-8 w-8 object-contain"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Side: Controls -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="mine-count" class="mb-2 block text-sm font-medium">Number of Mines</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => (mineCount = Math.max(mineCount - 1, MIN_MINES))}
|
||||
disabled={isPlaying || mineCount <= MIN_MINES}
|
||||
aria-label="Decrease mines">-</Button
|
||||
>
|
||||
<Input
|
||||
id="mine-count"
|
||||
type="number"
|
||||
min={MIN_MINES}
|
||||
max={24}
|
||||
value={mineCount}
|
||||
oninput={(e) => {
|
||||
const target = e.target as HTMLInputElement | null;
|
||||
const val = Math.max(
|
||||
MIN_MINES,
|
||||
Math.min(24, parseInt(target?.value ?? '') || MIN_MINES)
|
||||
);
|
||||
mineCount = val;
|
||||
}}
|
||||
disabled={isPlaying}
|
||||
class="w-12 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onclick={() => (mineCount = Math.min(mineCount + 1, 24))}
|
||||
disabled={isPlaying || mineCount >= 24}
|
||||
aria-label="Increase mines">+</Button
|
||||
>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-1 text-xs">
|
||||
You will get
|
||||
<span class="text-success font-semibold">
|
||||
{calculateRawMultiplier(isPlaying ? revealedTiles.length + 1 : 1, mineCount).toFixed(
|
||||
2
|
||||
)}x
|
||||
</span>
|
||||
per tile, probability of winning:
|
||||
<span class="text-success font-semibold">
|
||||
{calculateProbability(isPlaying ? 1 : 1, mineCount)}%
|
||||
</span>
|
||||
</p>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
Note: Maximum payout per game is capped at $2,000,000.
|
||||
</span>
|
||||
</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)))}
|
||||
disabled={isPlaying}>Max</Button
|
||||
>
|
||||
</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">
|
||||
<div class="bg-border h-px w-full"></div>
|
||||
<div class="text-muted-foreground text-center text-xs">
|
||||
Auto Cashout in {Math.ceil(AUTO_CASHOUT_TIME - autoCashoutTimer)}s
|
||||
</div>
|
||||
<div class="bg-muted h-1 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
class="bg-primary h-full transition-all duration-100"
|
||||
class:urgent={autoCashoutTimer >= 7}
|
||||
style="width: {autoCashoutProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="bg-border h-px w-full"></div>
|
||||
</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>
|
||||
+{formatValue(
|
||||
betAmount * (calculateRawMultiplier(revealedTiles.length + 1, mineCount) - 1)
|
||||
)}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
|
||||
import { volumeSettings } from '$lib/stores/volume-settings';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
|
||||
|
||||
interface SlotsResult {
|
||||
won: boolean;
|
||||
|
|
@ -211,8 +212,19 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Dynmaically fetch the correct balance.
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const data = await fetchPortfolioSummary();
|
||||
if (data) {
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
163
website/src/lib/server/games/mines.ts
Normal file
163
website/src/lib/server/games/mines.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redis } from '$lib/server/redis';
|
||||
|
||||
interface MinesSession {
|
||||
sessionToken: string;
|
||||
betAmount: number;
|
||||
mineCount: number;
|
||||
minePositions: number[];
|
||||
revealedTiles: number[];
|
||||
startTime: number;
|
||||
currentMultiplier: number;
|
||||
status: 'active' | 'won' | 'lost';
|
||||
lastActivity: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
const MINES_SESSION_PREFIX = 'mines:session:';
|
||||
export const getSessionKey = (token: string) => `${MINES_SESSION_PREFIX}${token}`;
|
||||
|
||||
// --- Mines cleanup logic for scheduler ---
|
||||
export async function minesCleanupInactiveGames() {
|
||||
const now = Date.now();
|
||||
const keys: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
const scanResult = await redis.scan(cursor, { MATCH: `${MINES_SESSION_PREFIX}*` });
|
||||
cursor = scanResult.cursor;
|
||||
keys.push(...scanResult.keys);
|
||||
} while (cursor !== '0');
|
||||
for (const key of keys) {
|
||||
const sessionRaw = await redis.get(key);
|
||||
if (!sessionRaw) continue;
|
||||
const game = JSON.parse(sessionRaw) as MinesSession;
|
||||
if (now - game.lastActivity > 5 * 60 * 1000) {
|
||||
if (game.revealedTiles.length === 0) {
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, game.userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
const newBalance = Math.round((currentBalance + game.betAmount) * 100000000) / 100000000;
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, game.userId));
|
||||
} catch (error) {
|
||||
console.error(`Failed to refund inactive game ${game.sessionToken}:`, error);
|
||||
}
|
||||
}
|
||||
await redis.del(getSessionKey(game.sessionToken));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function minesAutoCashout() {
|
||||
const now = Date.now();
|
||||
const keys: string[] = [];
|
||||
let cursor = '0';
|
||||
do {
|
||||
const scanResult = await redis.scan(cursor, { MATCH: `${MINES_SESSION_PREFIX}*` });
|
||||
cursor = scanResult.cursor;
|
||||
keys.push(...scanResult.keys);
|
||||
} while (cursor !== '0');
|
||||
for (const key of keys) {
|
||||
const sessionRaw = await redis.get(key);
|
||||
if (!sessionRaw) continue;
|
||||
const game = JSON.parse(sessionRaw) as MinesSession;
|
||||
|
||||
if (
|
||||
game.status === 'active' &&
|
||||
game.revealedTiles.length > 0 &&
|
||||
now - game.lastActivity > 20000 &&
|
||||
!game.revealedTiles.some(idx => game.minePositions.includes(idx))
|
||||
) {
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, game.userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
const payout = game.betAmount * game.currentMultiplier;
|
||||
const roundedPayout = Math.round(payout * 100000000) / 100000000;
|
||||
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, game.userId));
|
||||
|
||||
await redis.del(getSessionKey(game.sessionToken));
|
||||
} catch (error) {
|
||||
console.error(`Failed to auto cashout game ${game.sessionToken}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getMaxPayout = (bet: number, picks: number, mines: number): number => {
|
||||
const MAX_PAYOUT = 2_000_000;
|
||||
const HIGH_BET_THRESHOLD = 50_000;
|
||||
|
||||
const mineFactor = 1 + (mines / 25);
|
||||
const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor;
|
||||
|
||||
if (bet > HIGH_BET_THRESHOLD) {
|
||||
const betRatio = Math.pow(Math.min(1, (bet - HIGH_BET_THRESHOLD) / (MAX_PAYOUT - HIGH_BET_THRESHOLD)), 1);
|
||||
|
||||
// Direct cap on multiplier for high bets
|
||||
const maxAllowedMultiplier = 1.05 + (picks * 0.1);
|
||||
const highBetMultiplier = Math.min(baseMultiplier, maxAllowedMultiplier) * (1 - (bet / MAX_PAYOUT) * 0.9);
|
||||
const betSizeFactor = Math.max(0.1, 1 - (bet / MAX_PAYOUT) * 0.9);
|
||||
const minMultiplier = (1.1 + (picks * 0.15 * betSizeFactor)) * mineFactor;
|
||||
|
||||
const reducedMultiplier = highBetMultiplier - ((highBetMultiplier - minMultiplier) * betRatio);
|
||||
const payout = Math.min(bet * reducedMultiplier, MAX_PAYOUT);
|
||||
|
||||
return payout;
|
||||
}
|
||||
|
||||
const payout = Math.min(bet * baseMultiplier, MAX_PAYOUT);
|
||||
return payout;
|
||||
};
|
||||
|
||||
export function calculateMultiplier(picks: number, mines: number, betAmount: number): number {
|
||||
const TOTAL_TILES = 25;
|
||||
const HOUSE_EDGE = 0.05;
|
||||
|
||||
// Calculate probability of winning based on picks and mines
|
||||
let probability = 1;
|
||||
for (let i = 0; i < picks; i++) {
|
||||
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
|
||||
}
|
||||
|
||||
if (probability <= 0) return 1.0;
|
||||
|
||||
// Calculate fair multiplier based on probability and house edge
|
||||
const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE);
|
||||
|
||||
const rawPayout = fairMultiplier * betAmount;
|
||||
const maxPayout = getMaxPayout(betAmount, picks, mines);
|
||||
const cappedPayout = Math.min(rawPayout, maxPayout);
|
||||
const effectiveMultiplier = cappedPayout / betAmount;
|
||||
|
||||
return Math.max(1.0, Number(effectiveMultiplier.toFixed(2)));
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -95,11 +95,7 @@
|
|||
class="text-primary underline hover:cursor-pointer"
|
||||
onclick={() => (shouldSignIn = !shouldSignIn)}>sign in</button
|
||||
>
|
||||
or{' '}
|
||||
<button
|
||||
class="text-primary underline hover:cursor-pointer"
|
||||
onclick={() => (shouldSignIn = !shouldSignIn)}>create an account</button
|
||||
> to play.
|
||||
to play.
|
||||
{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
|
|
|||
87
website/src/routes/api/gambling/dice/+server.ts
Normal file
87
website/src/routes/api/gambling/dice/+server.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
interface DiceRequest {
|
||||
selectedNumber: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const { selectedNumber, amount }: DiceRequest = await request.json();
|
||||
|
||||
if (!selectedNumber || selectedNumber < 1 || selectedNumber > 6 || !Number.isInteger(selectedNumber)) {
|
||||
return json({ error: 'Invalid number selection' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!amount || amount <= 0 || !Number.isFinite(amount)) {
|
||||
return json({ error: 'Invalid bet amount' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (amount > 1000000) {
|
||||
return json({ error: 'Bet amount too large' }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [userData] = await tx
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
|
||||
const roundedAmount = Math.round(amount * 100000000) / 100000000;
|
||||
const roundedBalance = Math.round(currentBalance * 100000000) / 100000000;
|
||||
|
||||
if (roundedAmount > roundedBalance) {
|
||||
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
|
||||
}
|
||||
|
||||
const gameResult = Math.floor(randomBytes(1)[0] / 42.67) + 1; // This gives us a number between 1-6
|
||||
const won = gameResult === selectedNumber;
|
||||
|
||||
const multiplier = 3;
|
||||
const payout = won ? roundedAmount * multiplier : 0;
|
||||
const newBalance = roundedBalance - roundedAmount + payout;
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return {
|
||||
won,
|
||||
result: gameResult,
|
||||
newBalance,
|
||||
payout,
|
||||
amountWagered: roundedAmount
|
||||
};
|
||||
});
|
||||
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
console.error('Dice API error:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
|
||||
return json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
};
|
||||
76
website/src/routes/api/gambling/mines/cashout/+server.ts
Normal file
76
website/src/routes/api/gambling/mines/cashout/+server.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redis } from '$lib/server/redis';
|
||||
import { getSessionKey } from '$lib/server/games/mines';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionToken } = await request.json();
|
||||
const sessionRaw = await redis.get(getSessionKey(sessionToken));
|
||||
const game = sessionRaw ? JSON.parse(sessionRaw) : null;
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
if (!game) {
|
||||
return json({ error: 'Invalid session' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [userData] = await tx
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
let payout: number;
|
||||
let newBalance: number;
|
||||
|
||||
// If no tiles revealed, treat as abort and return full bet.
|
||||
if (game.revealedTiles.length === 0) {
|
||||
payout = game.betAmount;
|
||||
newBalance = Math.round((currentBalance + payout) * 100000000) / 100000000;
|
||||
} else {
|
||||
payout = game.betAmount * game.currentMultiplier;
|
||||
const roundedPayout = Math.round(payout * 100000000) / 100000000;
|
||||
newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
await redis.del(getSessionKey(sessionToken));
|
||||
|
||||
return {
|
||||
newBalance,
|
||||
payout,
|
||||
amountWagered: game.betAmount,
|
||||
isAbort: game.revealedTiles.length === 0,
|
||||
minePositions: game.minePositions
|
||||
};
|
||||
});
|
||||
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
console.error('Mines cashout error:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
|
||||
return json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
};
|
||||
127
website/src/routes/api/gambling/mines/reveal/+server.ts
Normal file
127
website/src/routes/api/gambling/mines/reveal/+server.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { calculateMultiplier } from '$lib/server/games/mines';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redis, } from '$lib/server/redis';
|
||||
import { getSessionKey } from '$lib/server/games/mines';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionToken, tileIndex } = await request.json();
|
||||
|
||||
if (!Number.isInteger(tileIndex) || tileIndex < 0 || tileIndex > 24) {
|
||||
return json({ error: 'Invalid tileIndex' }, { status: 400 });
|
||||
}
|
||||
|
||||
const sessionRaw = await redis.get(getSessionKey(sessionToken));
|
||||
const game = sessionRaw ? JSON.parse(sessionRaw) : null;
|
||||
|
||||
if (!game) {
|
||||
return json({ error: 'Invalid session' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (game.revealedTiles.includes(tileIndex)) {
|
||||
return json({ error: 'Tile already revealed' }, { status: 400 });
|
||||
}
|
||||
|
||||
game.lastActivity = Date.now();
|
||||
|
||||
if (game.minePositions.includes(tileIndex)) {
|
||||
game.status = 'lost';
|
||||
const minePositions = game.minePositions;
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: currentBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
await redis.del(getSessionKey(sessionToken));
|
||||
|
||||
return json({
|
||||
hitMine: true,
|
||||
minePositions,
|
||||
newBalance: currentBalance,
|
||||
status: 'lost',
|
||||
amountWagered: game.betAmount
|
||||
});
|
||||
}
|
||||
|
||||
// Safe tile
|
||||
game.revealedTiles.push(tileIndex);
|
||||
game.currentMultiplier = calculateMultiplier(
|
||||
game.revealedTiles.length,
|
||||
game.mineCount,
|
||||
game.betAmount
|
||||
);
|
||||
|
||||
if (game.revealedTiles.length === 25 - game.mineCount) {
|
||||
game.status = 'won';
|
||||
const userId = Number(session.user.id);
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
const payout = game.betAmount * game.currentMultiplier;
|
||||
const roundedPayout = Math.round(payout * 100000000) / 100000000;
|
||||
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
await redis.del(getSessionKey(sessionToken));
|
||||
|
||||
return json({
|
||||
hitMine: false,
|
||||
currentMultiplier: game.currentMultiplier,
|
||||
status: 'won',
|
||||
newBalance,
|
||||
payout
|
||||
});
|
||||
}
|
||||
|
||||
await redis.set(getSessionKey(sessionToken), JSON.stringify(game));
|
||||
|
||||
return json({
|
||||
hitMine: false,
|
||||
currentMultiplier: game.currentMultiplier,
|
||||
status: game.status
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Mines reveal error:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
|
||||
return json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
};
|
||||
100
website/src/routes/api/gambling/mines/start/+server.ts
Normal file
100
website/src/routes/api/gambling/mines/start/+server.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { auth } from '$lib/auth';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { user } from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redis } from '$lib/server/redis';
|
||||
import { getSessionKey } from '$lib/server/games/mines';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
if (!session?.user) {
|
||||
throw error(401, 'Not authenticated');
|
||||
}
|
||||
|
||||
try {
|
||||
const { betAmount, mineCount } = await request.json();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
if (!betAmount || betAmount <= 0 || !mineCount || mineCount < 3 || mineCount > 24) {
|
||||
return json({ error: 'Invalid bet amount or mine count' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (betAmount > 1000000) {
|
||||
return json({ error: 'Bet amount too large' }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [userData] = await tx
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
const roundedAmount = Math.round(betAmount * 100000000) / 100000000;
|
||||
const roundedBalance = Math.round(currentBalance * 100000000) / 100000000;
|
||||
|
||||
if (roundedAmount > roundedBalance) {
|
||||
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// Generate mine positions
|
||||
const positions = new Set<number>();
|
||||
while (positions.size < mineCount) {
|
||||
positions.add(Math.floor(Math.random() * 25));
|
||||
}
|
||||
|
||||
// transaction token for authentication stuff
|
||||
const randomBytes = new Uint8Array(8);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
const sessionToken = Array.from(randomBytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const now = Date.now();
|
||||
const newBalance = roundedBalance - roundedAmount;
|
||||
|
||||
await redis.set(
|
||||
getSessionKey(sessionToken),
|
||||
JSON.stringify({
|
||||
sessionToken,
|
||||
betAmount: roundedAmount,
|
||||
mineCount,
|
||||
minePositions: Array.from(positions),
|
||||
revealedTiles: [],
|
||||
startTime: now,
|
||||
lastActivity: now,
|
||||
currentMultiplier: 1,
|
||||
status: 'active',
|
||||
userId
|
||||
})
|
||||
);
|
||||
|
||||
// Update user balance
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return {
|
||||
sessionToken,
|
||||
newBalance
|
||||
};
|
||||
});
|
||||
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
console.error('Mines start error:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
|
||||
return json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import Coinflip from '$lib/components/self/games/Coinflip.svelte';
|
||||
import Slots from '$lib/components/self/games/Slots.svelte';
|
||||
import Mines from '$lib/components/self/games/Mines.svelte';
|
||||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import { PORTFOLIO_SUMMARY, fetchPortfolioSummary } from '$lib/stores/portfolio-data';
|
||||
import { onMount } from 'svelte';
|
||||
|
|
@ -8,6 +9,7 @@
|
|||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import SEO from '$lib/components/self/SEO.svelte';
|
||||
import Dice from '$lib/components/self/games/Dice.svelte'
|
||||
|
||||
let shouldSignIn = $state(false);
|
||||
let balance = $state(0);
|
||||
|
|
@ -39,14 +41,12 @@
|
|||
|
||||
<SEO
|
||||
title="Gambling - Rugplay"
|
||||
description="Play virtual gambling games with simulated currency in Rugplay. Try coinflip and slots games using virtual money with no real-world value - purely for entertainment."
|
||||
keywords="virtual gambling simulation, coinflip game, slots game, virtual casino, simulated gambling, entertainment games"
|
||||
description="Play virtual gambling games with simulated currency in Rugplay. Try coinflip, slots, and mines games using virtual money with no real-world value - purely for entertainment."
|
||||
keywords="virtual gambling simulation, coinflip game, slots game, mines game, virtual casino, simulated gambling, entertainment games"
|
||||
/>
|
||||
|
||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||
|
||||
|
||||
|
||||
<div class="container mx-auto max-w-4xl p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Gambling</h1>
|
||||
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
<div class="flex h-96 items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-muted-foreground mb-4 text-xl">Sign in to start gambling</div>
|
||||
<p class="text-muted-foreground mb-4 text-sm">You need an account to place bets</p>
|
||||
<p class="text-muted-foreground mb-4 text-sm">You need an account to gamble away your life savings</p>
|
||||
<Button onclick={() => (shouldSignIn = true)}>Sign In</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -73,6 +73,18 @@
|
|||
>
|
||||
Slots
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeGame === 'mines' ? 'default' : 'outline'}
|
||||
onclick={() => (activeGame = 'mines')}
|
||||
>
|
||||
Mines
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeGame === 'dice' ? 'default' : 'outline'}
|
||||
onclick={() => (activeGame = 'dice')}
|
||||
>
|
||||
Dice
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Game Content -->
|
||||
|
|
@ -80,6 +92,10 @@
|
|||
<Coinflip bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||
{:else if activeGame === 'slots'}
|
||||
<Slots bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||
{:else if activeGame === 'mines'}
|
||||
<Mines bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||
{:else if activeGame === 'dice'}
|
||||
<Dice bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
BIN
website/static/sound/dice.mp3
Normal file
BIN
website/static/sound/dice.mp3
Normal file
Binary file not shown.
Reference in a new issue