diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte new file mode 100644 index 0000000..affd016 --- /dev/null +++ b/website/src/lib/components/self/games/Mines.svelte @@ -0,0 +1,614 @@ + + + + + Mines + + Navigate through the minefield and cash out before hitting a mine! + + + Info + + + + Mines Game Information + + + + Winning Probabilities + + The probability of winning increases with each safe tile you reveal. Here's how it works: + + + + For each tile you reveal: + + More mines = Higher risk = Higher potential payout + Fewer mines = Lower risk = Lower potential payout + Each safe tile increases your multiplier + + + + Example: + With 3 mines on the board: + + First tile: 88% chance of being safe + Second tile: 87% chance of being safe + Third tile: 86% chance of being safe + + + + + + + Game Rules & Information + + + If you leave the page while playing: + + + If you haven't revealed any tiles, your game session will be ended after 5 minutes of inactivity + If you have revealed tiles, the auto cashout will process your gains within 15 seconds + + + + + + + + + + + + + + + Balance + {formatValue(balance)} + + + + = 7}> + {#each Array(TOTAL_TILES) as _, index} + + handleTileClick(index)} + disabled={!isPlaying} + > + {#if revealedTiles.includes(index)} + {#if minePositions.includes(index)} + + {:else} + + {/if} + {/if} + + {/each} + + + + + + + + Number of Mines + + (mineCount = parseInt(value))} + disabled={isPlaying} + > + + {mineCount} Mines + + + {#each Array(22) as _, i} + {i + MIN_MINES} Mines + {/each} + + + + + + + + Bet Amount + + + Max bet: {MAX_BET_AMOUNT.toLocaleString()} + + + + + + + setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT) * 0.25))} + disabled={isPlaying}>25% + setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT) * 0.5))} + disabled={isPlaying}>50% + setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT) * 0.75))} + disabled={isPlaying}>75% + setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT)))} + disabled={isPlaying}>Max + + + + + + {#if !isPlaying} + + Start Game + + {:else} + + {#if hasRevealedTile} + + + + Auto Cashout in {Math.ceil(AUTO_CASHOUT_TIME - autoCashoutTimer)}s + + + = 7} + style="width: {autoCashoutProgress}%" + > + + + + {/if} + + {hasRevealedTile ? 'Cash Out' : 'Abort Bet'} + + + {#if hasRevealedTile} + + + Current Profit: + + +{formatValue(betAmount * (currentMultiplier - 1))} + + + + Next Tile: + + +{formatValue(betAmount * (calculateRawMultiplier(revealedTiles.length + 1, mineCount) - 1))} + + + + Current Multiplier: + {currentMultiplier.toFixed(2)}x + + + {/if} + {/if} + + + + + + + diff --git a/website/src/lib/server/games/mines.ts b/website/src/lib/server/games/mines.ts new file mode 100644 index 0000000..2c95f24 --- /dev/null +++ b/website/src/lib/server/games/mines.ts @@ -0,0 +1,98 @@ +import { db } from '$lib/server/db'; +import { user } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + + +interface MinesSession { + sessionToken: string; + betAmount: number; + mineCount: number; + minePositions: number[]; + revealedTiles: number[]; + startTime: number; + currentMultiplier: number; + status: 'active' | 'won' | 'lost'; + lastActivity: number; + userId: number; +} + + +export const activeGames = new Map(); + +// Clean up old games every minute. +setInterval(async () => { + const now = Date.now(); + for (const [token, game] of activeGames.entries()) { + // Delete games older than 5 minutes that are still there for some reason. + if (now - game.lastActivity > 5 * 60 * 1000) { + // If no tiles were revealed, refund the bet + if (game.revealedTiles.length === 0) { + try { + console.log(`Processing refund for inactive Mines game ${token} (User: ${game.userId}, Bet: ${game.betAmount})`); + + 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)); + + console.log(`Successfully refunded ${game.betAmount} to user ${game.userId}. New balance: ${newBalance}`); + } catch (error) { + console.error(`Failed to refund inactive game ${token}:`, error); + } + } else { + console.log(`Cleaning up inactive game ${token} (User: ${game.userId}) - No refund needed as tiles were revealed`); + } + activeGames.delete(token); + } + } +}, 60000); + +// Rig the game... +const getMaxPayout = (bet: number, picks: number): number => { + const absoluteCap = 5_000_000; // never pay above this. Yeah, its rigged. Live with that :) + const baseCap = 1.4; // 1.4x min multiplier, increase to, well, increase payouts + const growthRate = 0.45; // cap curve sensitivity + + // Cap increases with number of successful reveals + const effectiveMultiplierCap = baseCap + Math.pow(picks, growthRate); + const payoutCap = bet * effectiveMultiplierCap; + + return Math.min(payoutCap, absoluteCap); +}; + + +export function calculateMultiplier(picks: number, mines: number, betAmount: number): number { + const TOTAL_TILES = 25; + const HOUSE_EDGE = 0.05; + + let probability = 1; + for (let i = 0; i < picks; i++) { + probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i); + } + + if (probability <= 0) return 1.0; + + const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE); + const rawPayout = fairMultiplier * betAmount; + + const maxPayout = getMaxPayout(betAmount, picks); + const cappedPayout = Math.min(rawPayout, maxPayout); + const effectiveMultiplier = cappedPayout / betAmount; + + return Math.max(1.0, effectiveMultiplier); +} + + diff --git a/website/src/routes/api/gambling/mines/cashout/+server.ts b/website/src/routes/api/gambling/mines/cashout/+server.ts new file mode 100644 index 0000000..a91bb68 --- /dev/null +++ b/website/src/routes/api/gambling/mines/cashout/+server.ts @@ -0,0 +1,74 @@ +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 { activeGames } 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 game = activeGames.get(sessionToken); + 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. This could be changed later to keep the initial bet on the Server + if (game.revealedTiles.length === 0) { + payout = game.betAmount; + newBalance = Math.round((currentBalance + payout) * 100000000) / 100000000; + } else { + // Calculate payout + 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)); + + activeGames.delete(sessionToken); + + return { + newBalance, + payout, + amountWagered: game.betAmount, + isAbort: game.revealedTiles.length === 0 + }; + }); + + 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 }); + } +}; \ No newline at end of file diff --git a/website/src/routes/api/gambling/mines/reveal/+server.ts b/website/src/routes/api/gambling/mines/reveal/+server.ts new file mode 100644 index 0000000..75f95c7 --- /dev/null +++ b/website/src/routes/api/gambling/mines/reveal/+server.ts @@ -0,0 +1,81 @@ +import { auth } from '$lib/auth'; +import { error, json } from '@sveltejs/kit'; +import { activeGames, 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'; + +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(); + const game = activeGames.get(sessionToken); + + if (!game) { + return json({ error: 'Invalid session' }, { status: 400 }); + } + + if (game.revealedTiles.includes(tileIndex)) { + return json({ error: 'Tile already revealed' }, { status: 400 }); + } + + // Update last activity time + game.lastActivity = Date.now(); + + // Check if hit mine + + if (game.minePositions.includes(tileIndex)) { + game.status = 'lost'; + const minePositions = game.minePositions; + + // Fetch user balance to return after loss + const userId = Number(session.user.id); + const [userData] = await db + .select({ baseCurrencyBalance: user.baseCurrencyBalance }) + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + activeGames.delete(sessionToken); + + return json({ + hitMine: true, + minePositions, + newBalance: Number(userData.baseCurrencyBalance), + status: 'lost' + }); + } + + + // Safe tile + game.revealedTiles.push(tileIndex); + game.currentMultiplier = calculateMultiplier( + game.revealedTiles.length, + game.mineCount, + game.betAmount + ); + + // Check if all safe tiles are revealed. Crazy when you get this :) + if (game.revealedTiles.length === 25 - game.mineCount) { + game.status = 'won'; + } + + 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 }); + } +}; \ No newline at end of file diff --git a/website/src/routes/api/gambling/mines/start/+server.ts b/website/src/routes/api/gambling/mines/start/+server.ts new file mode 100644 index 0000000..34e25d9 --- /dev/null +++ b/website/src/routes/api/gambling/mines/start/+server.ts @@ -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 { activeGames } 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 || !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(); + while (positions.size < mineCount) { + positions.add(Math.floor(Math.random() * 25)); + } + const safePositions = []; + for (let i = 0; i < 25; i++) { + if (!positions.has(i)) safePositions.push(i); + } + console.log(positions) + console.log('Safe positions:', safePositions); + + + // transaction token for authentication + 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(); + + // Create session + activeGames.set(sessionToken, { + sessionToken, + betAmount: roundedAmount, + mineCount, + minePositions: Array.from(positions), + revealedTiles: [], + startTime: now, + lastActivity: now, + currentMultiplier: 1, + status: 'active', + userId + }); + + // Hold bet amount on server to prevent the user from like sending it to another account and farming money without a risk + await tx + .update(user) + .set({ + baseCurrencyBalance: (roundedBalance - roundedAmount).toFixed(8), + updatedAt: new Date() + }) + .where(eq(user.id, userId)); + + return { sessionToken }; + }); + + 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 }); + } +}; \ No newline at end of file diff --git a/website/src/routes/gambling/+page.svelte b/website/src/routes/gambling/+page.svelte index 4df7bde..27b7d0c 100644 --- a/website/src/routes/gambling/+page.svelte +++ b/website/src/routes/gambling/+page.svelte @@ -1,6 +1,7 @@
+ The probability of winning increases with each safe tile you reveal. Here's how it works: +
For each tile you reveal:
Example:
With 3 mines on the board:
+ If you leave the page while playing: +
Balance
{formatValue(balance)}
+ Max bet: {MAX_BET_AMOUNT.toLocaleString()} +