diff --git a/website/src/lib/components/self/games/Coinflip.svelte b/website/src/lib/components/self/games/Coinflip.svelte index daa940d..1b77200 100644 --- a/website/src/lib/components/self/games/Coinflip.svelte +++ b/website/src/lib/components/self/games/Coinflip.svelte @@ -314,8 +314,20 @@ } } - onMount(() => { + onMount(async () => { volumeSettings.load(); + + try { + const response = await fetch('/api/portfolio/summary'); + if (!response.ok) { + throw new Error('Failed to fetch portfolio summary'); + } + const data = await response.json(); + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); + } catch (error) { + console.error('Failed to fetch balance:', error); + } }); diff --git a/website/src/lib/components/self/games/Dice.svelte b/website/src/lib/components/self/games/Dice.svelte new file mode 100644 index 0000000..bf9d5cf --- /dev/null +++ b/website/src/lib/components/self/games/Dice.svelte @@ -0,0 +1,480 @@ + + + + + Dice + Choose a number and roll the dice to win 3x your bet! + + +
+
+
+

Balance

+

{formatValue(balance)}

+
+ +
+
+
+ {#each Array(6) as _, i} +
+
+ {#each Array(getDotsForFace(i + 1)) as _} +
+ {/each} +
+
+ {/each} +
+
+
+ +
+ {#if lastResult && !isRolling} +
+ {#if lastResult.won} +

WIN

+

+ Won {formatValue(lastResult.payout)} on {lastResult.result} +

+ {:else} +

LOSS

+

+ Lost {formatValue(lastResult.amountWagered)} on {lastResult.result} +

+ {/if} +
+ {/if} +
+
+ +
+
+
Choose Number
+
+ {#each Array(6) as _, i} + + {/each} +
+
+ +
+ + +

+ Max bet: {MAX_BET_AMOUNT.toLocaleString()} +

+
+ +
+
+ + + + +
+
+ + +
+
+
+
+ + diff --git a/website/src/lib/components/self/games/Mines.svelte b/website/src/lib/components/self/games/Mines.svelte index affd016..ab685da 100644 --- a/website/src/lib/components/self/games/Mines.svelte +++ b/website/src/lib/components/self/games/Mines.svelte @@ -131,6 +131,7 @@ } if (autoCashoutTimer >= AUTO_CASHOUT_TIME) { isAutoCashout = true; + clearInterval(autoCashoutInterval); cashOut(); } }, 100); @@ -227,6 +228,12 @@ hasRevealedTile = false; isAutoCashout = false; resetAutoCashoutTimer(); + + // Prevents the Tiles getting revealed when you Abort your bet. + if (!result.isAbort) { + revealedTiles = [...Array(TOTAL_TILES).keys()]; + minePositions = result.minePositions; + } } catch (error) { console.error('Cashout error:', error); toast.error('Failed to cash out', { @@ -276,8 +283,21 @@ } } - onMount(() => { + // Dynmaically fetch the correct balance. + onMount(async () => { volumeSettings.load(); + + try { + const response = await fetch('/api/portfolio/summary'); + if (!response.ok) { + throw new Error('Failed to fetch portfolio summary'); + } + const data = await response.json(); + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); + } catch (error) { + console.error('Failed to fetch balance:', error); + } }); onDestroy(() => { @@ -359,7 +379,7 @@ +
{mineCount}
+ +

Mines

+ + +
@@ -611,4 +638,4 @@ height: 32px; object-fit: contain; } - + \ No newline at end of file diff --git a/website/src/lib/components/self/games/Slots.svelte b/website/src/lib/components/self/games/Slots.svelte index 6ed0c6c..d935979 100644 --- a/website/src/lib/components/self/games/Slots.svelte +++ b/website/src/lib/components/self/games/Slots.svelte @@ -211,8 +211,21 @@ } }); - onMount(() => { + // Dynmaically fetch the correct balance. + onMount(async () => { volumeSettings.load(); + + try { + const response = await fetch('/api/portfolio/summary'); + if (!response.ok) { + throw new Error('Failed to fetch portfolio summary'); + } + const data = await response.json(); + balance = data.baseCurrencyBalance; + onBalanceUpdate?.(data.baseCurrencyBalance); + } catch (error) { + console.error('Failed to fetch balance:', error); + } }); diff --git a/website/src/lib/server/games/mines.ts b/website/src/lib/server/games/mines.ts index 2c95f24..766e10d 100644 --- a/website/src/lib/server/games/mines.ts +++ b/website/src/lib/server/games/mines.ts @@ -19,17 +19,13 @@ interface MinesSession { export const activeGames = new Map(); -// Clean up old games every minute. +// Clean up old games every minute. (5 Minute system) 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})`); - + try { const [userData] = await db .select({ baseCurrencyBalance: user.baseCurrencyBalance }) .from(user) @@ -48,29 +44,74 @@ setInterval(async () => { }) .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); +setInterval(async () => { + const now = Date.now(); + for (const [token, game] of activeGames.entries()) { + if (game.status === 'active' && game.revealedTiles.length > 0 && now - game.lastActivity > 20000) { + 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)); + + activeGames.delete(token); + } catch (error) { + console.error(`Failed to auto cashout game ${token}:`, error); + } + } + } +}, 15000); + // 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); +const getMaxPayout = (bet: number, picks: number, mines: number): number => { + const MAX_PAYOUT = 2_000_000; // Maximum payout cap of 2 million to not make linker too rich + const HIGH_BET_THRESHOLD = 50_000; + + const mineFactor = 1 + (mines / 25); + const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor; + + // For high bets, we stop linker from getting richer ¯\_(ツ)_/¯ + 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; }; @@ -78,6 +119,7 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num 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); @@ -85,14 +127,15 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num 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); + const maxPayout = getMaxPayout(betAmount, picks, mines); const cappedPayout = Math.min(rawPayout, maxPayout); const effectiveMultiplier = cappedPayout / betAmount; - return Math.max(1.0, effectiveMultiplier); + return Math.max(1.0, Number(effectiveMultiplier.toFixed(2))); } diff --git a/website/src/routes/+page.svelte b/website/src/routes/+page.svelte index e51493d..0fee172 100644 --- a/website/src/routes/+page.svelte +++ b/website/src/routes/+page.svelte @@ -95,11 +95,7 @@ class="text-primary underline hover:cursor-pointer" onclick={() => (shouldSignIn = !shouldSignIn)}>sign in - or{' '} - to play. + to play. {/if}

diff --git a/website/src/routes/api/gambling/dice/+server.ts b/website/src/routes/api/gambling/dice/+server.ts new file mode 100644 index 0000000..d023cf6 --- /dev/null +++ b/website/src/routes/api/gambling/dice/+server.ts @@ -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 }); + } +}; diff --git a/website/src/routes/api/gambling/mines/cashout/+server.ts b/website/src/routes/api/gambling/mines/cashout/+server.ts index a91bb68..773e9ad 100644 --- a/website/src/routes/api/gambling/mines/cashout/+server.ts +++ b/website/src/routes/api/gambling/mines/cashout/+server.ts @@ -47,6 +47,7 @@ export const POST: RequestHandler = async ({ request }) => { newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000; } + await tx .update(user) .set({ @@ -55,13 +56,15 @@ export const POST: RequestHandler = async ({ request }) => { }) .where(eq(user.id, userId)); + activeGames.delete(sessionToken); return { newBalance, payout, amountWagered: game.betAmount, - isAbort: game.revealedTiles.length === 0 + isAbort: game.revealedTiles.length === 0, + minePositions: game.minePositions }; }); diff --git a/website/src/routes/api/gambling/mines/reveal/+server.ts b/website/src/routes/api/gambling/mines/reveal/+server.ts index 75f95c7..6a711ec 100644 --- a/website/src/routes/api/gambling/mines/reveal/+server.ts +++ b/website/src/routes/api/gambling/mines/reveal/+server.ts @@ -27,35 +27,46 @@ export const POST: RequestHandler = async ({ request }) => { 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)) + .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)); activeGames.delete(sessionToken); + return json({ hitMine: true, minePositions, - newBalance: Number(userData.baseCurrencyBalance), - status: 'lost' + newBalance: currentBalance, + status: 'lost', + amountWagered: game.betAmount }); } - // Safe tile + // Safe tile (Yipeee) game.revealedTiles.push(tileIndex); game.currentMultiplier = calculateMultiplier( game.revealedTiles.length, @@ -63,9 +74,38 @@ export const POST: RequestHandler = async ({ request }) => { game.betAmount ); - // Check if all safe tiles are revealed. Crazy when you get this :) 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)); + + activeGames.delete(sessionToken); + + return json({ + hitMine: false, + currentMultiplier: game.currentMultiplier, + status: 'won', + newBalance, + payout + }); } return json({ diff --git a/website/src/routes/api/gambling/mines/start/+server.ts b/website/src/routes/api/gambling/mines/start/+server.ts index 34e25d9..cff9ab4 100644 --- a/website/src/routes/api/gambling/mines/start/+server.ts +++ b/website/src/routes/api/gambling/mines/start/+server.ts @@ -43,6 +43,7 @@ export const POST: RequestHandler = async ({ request }) => { 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) { @@ -52,11 +53,8 @@ export const POST: RequestHandler = async ({ request }) => { 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 + // transaction token for authentication stuff const randomBytes = new Uint8Array(8); crypto.getRandomValues(randomBytes); const sessionToken = Array.from(randomBytes) @@ -64,6 +62,7 @@ export const POST: RequestHandler = async ({ request }) => { .join(''); const now = Date.now(); + const newBalance = roundedBalance - roundedAmount; // Create session activeGames.set(sessionToken, { @@ -79,16 +78,20 @@ export const POST: RequestHandler = async ({ request }) => { userId }); - // Hold bet amount on server to prevent the user from like sending it to another account and farming money without a risk + // Update user balance await tx .update(user) .set({ - baseCurrencyBalance: (roundedBalance - roundedAmount).toFixed(8), + baseCurrencyBalance: newBalance.toFixed(8), updatedAt: new Date() }) .where(eq(user.id, userId)); - return { sessionToken }; + + return { + sessionToken, + newBalance + }; }); return json(result); diff --git a/website/src/routes/gambling/+page.svelte b/website/src/routes/gambling/+page.svelte index 27b7d0c..996aeb4 100644 --- a/website/src/routes/gambling/+page.svelte +++ b/website/src/routes/gambling/+page.svelte @@ -9,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); @@ -78,6 +79,12 @@ > Mines +
@@ -87,6 +94,8 @@ {:else if activeGame === 'mines'} + {:else if activeGame === 'dice'} + {/if} {/if} diff --git a/website/static/sound/dice.mp3 b/website/static/sound/dice.mp3 new file mode 100644 index 0000000..e73d79d Binary files /dev/null and b/website/static/sound/dice.mp3 differ