feat: gambling (coinflip & slots)
|
|
@ -11,6 +11,7 @@
|
|||
"@visx/scale": "^3.12.0",
|
||||
"apexcharts": "^4.7.0",
|
||||
"better-auth": "^1.2.8",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"lightweight-charts": "^5.0.7",
|
||||
"lucide-svelte": "^0.511.0",
|
||||
|
|
@ -19,14 +20,15 @@
|
|||
"postgres": "^3.4.4",
|
||||
"redis": "^5.1.0",
|
||||
"svelte-apexcharts": "^1.0.2",
|
||||
"svelte-confetti": "^2.3.1",
|
||||
"svelte-lightweight-charts": "^2.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.5.6",
|
||||
"@lucide/svelte": "^0.482.0",
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^2.1.0",
|
||||
|
|
@ -436,6 +438,8 @@
|
|||
|
||||
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
|
||||
|
||||
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="],
|
||||
|
|
@ -504,6 +508,8 @@
|
|||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001718", "", {}, "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw=="],
|
||||
|
||||
"canvas-confetti": ["canvas-confetti@1.9.3", "", {}, "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||
|
|
@ -762,6 +768,8 @@
|
|||
|
||||
"svelte-check": ["svelte-check@4.2.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e49SU1RStvQhoipkQ/aonDhHnG3qxHSBtNfBRb9pxVXoa+N7qybAo32KgA9wEb2PCYFNaDg7bZCdhLD1vHpdYA=="],
|
||||
|
||||
"svelte-confetti": ["svelte-confetti@2.3.1", "", { "peerDependencies": { "svelte": ">=5.0.0" } }, "sha512-bKd8etTOeBQyeS9LDPuSd7Oqy5msf0xvxItzsHPajKaarr/LWFzqPq7rp6QQO5rGTzLgM0fmjovOvLkRbrd2gg=="],
|
||||
|
||||
"svelte-lightweight-charts": ["svelte-lightweight-charts@2.2.0", "", { "peerDependencies": { "lightweight-charts": ">=4.0.0", "svelte": ">=3.44.0" } }, "sha512-LXdha4vfLMuOPc0Yyetu+DLSDJkPryGkufUQgpCkfguCscSQUcrLiI9MdqKwQk6Fkm6AZbg8SQ5qDIYxcyC+Dg=="],
|
||||
|
||||
"svelte-sonner": ["svelte-sonner@1.0.2", "", { "dependencies": { "runed": "^0.26.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-hoidgv7hrk3XNZzjXj6/frsvZOiUOtf7Tn2du/hTu1G9PchJGEoy4EpIKyfhVKBiBrxaNFaYPFxN4pHLHCoe/w=="],
|
||||
|
|
|
|||
4326
website/package-lock.json
generated
Normal file
|
|
@ -20,6 +20,7 @@
|
|||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bits-ui": "^2.1.0",
|
||||
|
|
@ -46,6 +47,7 @@
|
|||
"@visx/scale": "^3.12.0",
|
||||
"apexcharts": "^4.7.0",
|
||||
"better-auth": "^1.2.8",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"lightweight-charts": "^5.0.7",
|
||||
"lucide-svelte": "^0.511.0",
|
||||
|
|
@ -54,6 +56,7 @@
|
|||
"postgres": "^3.4.4",
|
||||
"redis": "^5.1.0",
|
||||
"svelte-apexcharts": "^1.0.2",
|
||||
"svelte-confetti": "^2.3.1",
|
||||
"svelte-lightweight-charts": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
491
website/src/lib/components/self/games/Coinflip.svelte
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
<script lang="ts">
|
||||
// https://github.com/gre/bezier-easing
|
||||
// BezierEasing - use bezier curve for transition easing function
|
||||
// by Gaëtan Renaudeau 2014 - 2015 – MIT License
|
||||
|
||||
// These values are established by empiricism with tests (tradeoff: performance VS precision)
|
||||
const NEWTON_ITERATIONS = 4;
|
||||
const NEWTON_MIN_SLOPE = 0.001;
|
||||
const SUBDIVISION_PRECISION = 0.0000001;
|
||||
const SUBDIVISION_MAX_ITERATIONS = 10;
|
||||
|
||||
const kSplineTableSize = 11;
|
||||
const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);
|
||||
|
||||
const float32ArraySupported = typeof Float32Array === 'function';
|
||||
|
||||
function A(aA1: number, aA2: number) {
|
||||
return 1.0 - 3.0 * aA2 + 3.0 * aA1;
|
||||
}
|
||||
function B(aA1: number, aA2: number) {
|
||||
return 3.0 * aA2 - 6.0 * aA1;
|
||||
}
|
||||
function C(aA1: number) {
|
||||
return 3.0 * aA1;
|
||||
}
|
||||
|
||||
// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
|
||||
function calcBezier(aT: number, aA1: number, aA2: number) {
|
||||
return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT;
|
||||
}
|
||||
|
||||
// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
|
||||
function getSlope(aT: number, aA1: number, aA2: number) {
|
||||
return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
|
||||
}
|
||||
|
||||
function binarySubdivide(aX: number, aA: number, aB: number, mX1: number, mX2: number) {
|
||||
let currentX,
|
||||
currentT,
|
||||
i = 0;
|
||||
do {
|
||||
currentT = aA + (aB - aA) / 2.0;
|
||||
currentX = calcBezier(currentT, mX1, mX2) - aX;
|
||||
if (currentX > 0.0) {
|
||||
aB = currentT;
|
||||
} else {
|
||||
aA = currentT;
|
||||
}
|
||||
} while (Math.abs(currentX) > SUBDIVISION_PRECISION && ++i < SUBDIVISION_MAX_ITERATIONS);
|
||||
return currentT;
|
||||
}
|
||||
|
||||
function newtonRaphsonIterate(aX: number, aGuessT: number, mX1: number, mX2: number) {
|
||||
for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
|
||||
const currentSlope = getSlope(aGuessT, mX1, mX2);
|
||||
if (currentSlope === 0.0) {
|
||||
return aGuessT;
|
||||
}
|
||||
const currentX = calcBezier(aGuessT, mX1, mX2) - aX;
|
||||
aGuessT -= currentX / currentSlope;
|
||||
}
|
||||
return aGuessT;
|
||||
}
|
||||
|
||||
function LinearEasing(x: number) {
|
||||
return x;
|
||||
}
|
||||
|
||||
function bezier(mX1: number, mY1: number, mX2: number, mY2: number): (x: number) => number {
|
||||
if (!(0 <= mX1 && mX1 <= 1 && 0 <= mX2 && mX2 <= 1)) {
|
||||
throw new Error('bezier x values must be in [0, 1] range');
|
||||
}
|
||||
|
||||
if (mX1 === mY1 && mX2 === mY2) {
|
||||
return LinearEasing;
|
||||
}
|
||||
|
||||
// Precompute samples table
|
||||
const sampleValues: Float32Array | number[] = float32ArraySupported
|
||||
? new Float32Array(kSplineTableSize)
|
||||
: new Array(kSplineTableSize);
|
||||
for (let i = 0; i < kSplineTableSize; ++i) {
|
||||
sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2);
|
||||
}
|
||||
|
||||
function getTForX(aX: number) {
|
||||
let intervalStart = 0.0;
|
||||
let currentSample = 1;
|
||||
const lastSample = kSplineTableSize - 1;
|
||||
|
||||
for (; currentSample !== lastSample && sampleValues[currentSample] <= aX; ++currentSample) {
|
||||
intervalStart += kSampleStepSize;
|
||||
}
|
||||
--currentSample;
|
||||
|
||||
// Interpolate to provide an initial guess for t
|
||||
const dist =
|
||||
(aX - sampleValues[currentSample]) /
|
||||
(sampleValues[currentSample + 1] - sampleValues[currentSample]);
|
||||
const guessForT = intervalStart + dist * kSampleStepSize;
|
||||
|
||||
const initialSlope = getSlope(guessForT, mX1, mX2);
|
||||
if (initialSlope >= NEWTON_MIN_SLOPE) {
|
||||
return newtonRaphsonIterate(aX, guessForT, mX1, mX2);
|
||||
} else if (initialSlope === 0.0) {
|
||||
return guessForT;
|
||||
} else {
|
||||
return binarySubdivide(aX, intervalStart, intervalStart + kSampleStepSize, mX1, mX2);
|
||||
}
|
||||
}
|
||||
|
||||
return function BezierEasing(x: number) {
|
||||
// Because JavaScript number are imprecise, we should guarantee the extremes are right.
|
||||
if (x === 0 || x === 1) {
|
||||
return x;
|
||||
}
|
||||
return calcBezier(getTForX(x), mY1, mY2);
|
||||
};
|
||||
}
|
||||
|
||||
function getNormalizedTimeForProgress(
|
||||
targetProgress: number,
|
||||
easingFunction: (t: number) => number,
|
||||
tolerance = 0.0001,
|
||||
maxIterations = 100
|
||||
): number {
|
||||
if (targetProgress <= 0) return 0;
|
||||
if (targetProgress >= 1) return 1;
|
||||
|
||||
let minT = 0;
|
||||
let maxT = 1;
|
||||
let t = 0.5;
|
||||
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const currentProgress = easingFunction(t);
|
||||
const error = currentProgress - targetProgress;
|
||||
|
||||
if (Math.abs(error) < tolerance) {
|
||||
return t;
|
||||
}
|
||||
|
||||
if (error < 0) {
|
||||
minT = t;
|
||||
} else {
|
||||
maxT = t;
|
||||
}
|
||||
t = (minT + maxT) / 2;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
// --- End of bezier-easing code ---
|
||||
|
||||
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 } from '$lib/utils';
|
||||
|
||||
interface CoinflipResult {
|
||||
won: boolean;
|
||||
result: 'heads' | 'tails';
|
||||
newBalance: number;
|
||||
payout: number;
|
||||
amountWagered: number;
|
||||
}
|
||||
|
||||
const cssEaseInOut = bezier(0.42, 0, 0.58, 1.0);
|
||||
|
||||
let {
|
||||
balance = $bindable(),
|
||||
onBalanceUpdate
|
||||
}: {
|
||||
balance: number;
|
||||
onBalanceUpdate?: (newBalance: number) => void;
|
||||
} = $props();
|
||||
|
||||
let betAmount = $state(10);
|
||||
let selectedSide = $state('heads');
|
||||
let isFlipping = $state(false);
|
||||
let coinRotation = $state(0);
|
||||
let lastResult = $state<CoinflipResult | null>(null);
|
||||
let activeSoundTimeouts = $state<NodeJS.Timeout[]>([]);
|
||||
|
||||
let canBet = $derived(betAmount > 0 && betAmount <= balance && !isFlipping);
|
||||
|
||||
function selectSide(side: string) {
|
||||
if (!isFlipping) {
|
||||
selectedSide = side;
|
||||
}
|
||||
}
|
||||
|
||||
function setBetAmount(amount: number) {
|
||||
if (amount >= 0 && amount <= balance) {
|
||||
betAmount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
async function flipCoin() {
|
||||
if (!canBet) return;
|
||||
|
||||
isFlipping = true;
|
||||
lastResult = null;
|
||||
|
||||
activeSoundTimeouts.forEach(clearTimeout);
|
||||
activeSoundTimeouts = [];
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/gambling/coinflip', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
side: selectedSide,
|
||||
amount: betAmount
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to place bet');
|
||||
}
|
||||
|
||||
const resultData: CoinflipResult = await response.json();
|
||||
|
||||
const baseSpinsDegrees = 1800;
|
||||
const currentRotationValue = coinRotation;
|
||||
|
||||
let rotationDeltaForThisFlip = baseSpinsDegrees;
|
||||
|
||||
const faceAfterBaseSpins =
|
||||
(currentRotationValue + baseSpinsDegrees) % 360 < 180 ? 'heads' : 'tails';
|
||||
|
||||
if (faceAfterBaseSpins !== resultData.result) {
|
||||
rotationDeltaForThisFlip += 180;
|
||||
}
|
||||
|
||||
if (rotationDeltaForThisFlip === 0) {
|
||||
rotationDeltaForThisFlip = 360;
|
||||
}
|
||||
|
||||
coinRotation = currentRotationValue + rotationDeltaForThisFlip;
|
||||
|
||||
const animationDuration = 2000;
|
||||
|
||||
if (rotationDeltaForThisFlip >= 180) {
|
||||
const numHalfSpins = Math.floor(rotationDeltaForThisFlip / 180);
|
||||
|
||||
for (let i = 1; i <= numHalfSpins; i++) {
|
||||
const targetEasedProgress = (i * 180) / rotationDeltaForThisFlip;
|
||||
const normalizedTime = getNormalizedTimeForProgress(targetEasedProgress, cssEaseInOut);
|
||||
const timeToPlaySound = normalizedTime * animationDuration;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
playSound('flip');
|
||||
}, timeToPlaySound);
|
||||
activeSoundTimeouts.push(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
balance = resultData.newBalance;
|
||||
lastResult = resultData;
|
||||
onBalanceUpdate?.(resultData.newBalance);
|
||||
|
||||
if (resultData.won) {
|
||||
showConfetti(confetti);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isFlipping = false;
|
||||
if (!resultData.won) {
|
||||
playSound('lose');
|
||||
}
|
||||
}, 500);
|
||||
}, animationDuration);
|
||||
} catch (error) {
|
||||
console.error('Coinflip error:', error);
|
||||
toast.error('Bet failed', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
isFlipping = false;
|
||||
activeSoundTimeouts.forEach(clearTimeout);
|
||||
activeSoundTimeouts = [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coinflip</CardTitle>
|
||||
<CardDescription>Choose heads or tails and double your money!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<!-- Main Layout: Coin/Balance Left, Controls Right -->
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<!-- Left Side: Coin, Balance, and Result -->
|
||||
<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>
|
||||
|
||||
<!-- Coin Animation -->
|
||||
<div class="flex justify-center">
|
||||
<div class="coin-container">
|
||||
<div class="coin" style="transform: rotateY({coinRotation}deg)">
|
||||
<div class="coin-face coin-heads">
|
||||
<img
|
||||
src="/facedev/avif/bliptext.avif"
|
||||
alt="Heads"
|
||||
class="h-32 w-32 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="coin-face coin-tails">
|
||||
<img
|
||||
src="/facedev/avif/wattesigma.avif"
|
||||
alt="Tails"
|
||||
class="h-32 w-32 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result Display (Reserve Space) -->
|
||||
<div class="flex items-center justify-center text-center">
|
||||
{#if lastResult && !isFlipping}
|
||||
<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>
|
||||
|
||||
<!-- Right Side: Betting Controls -->
|
||||
<div class="space-y-4">
|
||||
<!-- Side Selection (Inline) -->
|
||||
<div>
|
||||
<div class="mb-2 block text-sm font-medium">Choose Side</div>
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
variant={selectedSide === 'heads' ? 'default' : 'outline'}
|
||||
onclick={() => selectSide('heads')}
|
||||
disabled={isFlipping}
|
||||
class="side-button h-16 flex-1"
|
||||
>
|
||||
<div class="text-center">
|
||||
<img
|
||||
src="/facedev/avif/bliptext.avif"
|
||||
alt="Heads"
|
||||
class="mx-auto mb-1 h-8 w-8 object-contain"
|
||||
/>
|
||||
<div>Heads</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedSide === 'tails' ? 'default' : 'outline'}
|
||||
onclick={() => selectSide('tails')}
|
||||
disabled={isFlipping}
|
||||
class="side-button h-16 flex-1"
|
||||
>
|
||||
<div class="text-center">
|
||||
<img
|
||||
src="/facedev/avif/wattesigma.avif"
|
||||
alt="Tails"
|
||||
class="mx-auto mb-1 h-8 w-8 object-contain"
|
||||
/>
|
||||
<div>Tails</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bet Amount -->
|
||||
<div>
|
||||
<label for="bet-amount" class="mb-2 block text-sm font-medium">Bet Amount</label>
|
||||
<Input
|
||||
id="bet-amount"
|
||||
type="number"
|
||||
bind:value={betAmount}
|
||||
min="1"
|
||||
max={balance}
|
||||
disabled={isFlipping}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Percentage Quick Actions -->
|
||||
<div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor((balance || 0) * 0.25))}
|
||||
disabled={isFlipping}>25%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor((balance || 0) * 0.5))}
|
||||
disabled={isFlipping}>50%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor((balance || 0) * 0.75))}
|
||||
disabled={isFlipping}>75%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(balance || 0))}
|
||||
disabled={isFlipping}>Max</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flip Button -->
|
||||
<Button class="h-12 w-full text-lg" onclick={flipCoin} disabled={!canBet}>
|
||||
{isFlipping ? 'Flipping...' : 'Flip'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.coin-container {
|
||||
position: relative;
|
||||
width: 8rem; /* 128px */
|
||||
height: 8rem; /* 128px */
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.coin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 2s ease-in-out;
|
||||
}
|
||||
|
||||
.coin-face {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backface-visibility: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.coin-heads {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
|
||||
.coin-tails {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
:global(.side-button) {
|
||||
box-sizing: border-box !important;
|
||||
border: 2px solid transparent !important;
|
||||
}
|
||||
|
||||
:global(.side-button[data-variant='outline']) {
|
||||
border-color: hsl(var(--border)) !important;
|
||||
}
|
||||
|
||||
:global(.side-button[data-variant='default']) {
|
||||
border-color: hsl(var(--primary)) !important;
|
||||
}
|
||||
</style>
|
||||
431
website/src/lib/components/self/games/Slots.svelte
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
<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';
|
||||
|
||||
interface SlotsResult {
|
||||
won: boolean;
|
||||
symbols: string[];
|
||||
newBalance: number;
|
||||
payout: number;
|
||||
amountWagered: number;
|
||||
winType?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
balance = $bindable(),
|
||||
onBalanceUpdate
|
||||
}: {
|
||||
balance: number;
|
||||
onBalanceUpdate?: (newBalance: number) => void;
|
||||
} = $props();
|
||||
|
||||
const symbols = [
|
||||
'bliptext',
|
||||
'bussin',
|
||||
'griddycode',
|
||||
'lyntr',
|
||||
'subterfuge',
|
||||
'twoblade',
|
||||
'wattesigma',
|
||||
'webx'
|
||||
];
|
||||
|
||||
const BASE_SPINS_PER_REEL = [8, 10, 12];
|
||||
const NUM_RENDERED_CYCLES = Math.max(...BASE_SPINS_PER_REEL) + 3;
|
||||
|
||||
let betAmount = $state(10);
|
||||
let isSpinning = $state(false);
|
||||
|
||||
const createReelStrip = () => {
|
||||
const strip = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const shuffled = [...symbols].sort(() => Math.random() - 0.5);
|
||||
strip.push(...shuffled);
|
||||
}
|
||||
return strip;
|
||||
};
|
||||
|
||||
let reelSymbols = $state([createReelStrip(), createReelStrip(), createReelStrip()]);
|
||||
|
||||
let reelPositions = $state([0, 0, 0]);
|
||||
let lastResult = $state<SlotsResult | null>(null);
|
||||
|
||||
let displayedSymbols = $state(
|
||||
reelSymbols.map((reel_cycle_data) => {
|
||||
return reel_cycle_data[1];
|
||||
})
|
||||
);
|
||||
|
||||
let canBet = $derived(betAmount > 0 && betAmount <= balance && !isSpinning);
|
||||
|
||||
function setBetAmount(amount: number) {
|
||||
if (amount >= 0 && amount <= balance) {
|
||||
betAmount = amount;
|
||||
}
|
||||
}
|
||||
|
||||
function getVisibleSymbolIndex(position: number, logicalReelCycleLength: number): number {
|
||||
const symbolHeight = 60;
|
||||
let index = Math.round(1 - position / symbolHeight);
|
||||
index = ((index % logicalReelCycleLength) + logicalReelCycleLength) % logicalReelCycleLength;
|
||||
return index;
|
||||
}
|
||||
|
||||
async function spin() {
|
||||
if (!canBet) return;
|
||||
|
||||
isSpinning = true;
|
||||
lastResult = null;
|
||||
|
||||
playSound('background');
|
||||
|
||||
const spinStartOffsets = [
|
||||
Math.random() * 60 - 30,
|
||||
Math.random() * 60 - 30,
|
||||
Math.random() * 60 - 30
|
||||
];
|
||||
|
||||
reelPositions = reelPositions.map((pos, i) => pos + spinStartOffsets[i]);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/gambling/slots', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: betAmount
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to place bet');
|
||||
}
|
||||
|
||||
const result: SlotsResult = await response.json();
|
||||
|
||||
const targetIndices = result.symbols.map((symbol, reelIndex) => {
|
||||
const indices = reelSymbols[reelIndex]
|
||||
.map((s, i) => (s === symbol ? i : -1))
|
||||
.filter((i) => i !== -1);
|
||||
return indices[Math.floor(Math.random() * indices.length)];
|
||||
});
|
||||
|
||||
const spinDurations = [2000, 2500, 3000];
|
||||
|
||||
targetIndices.forEach((targetIndex, i) => {
|
||||
const symbolHeight = 60;
|
||||
|
||||
const targetPosition = 60 - targetIndex * symbolHeight;
|
||||
const logicalCyclePixelHeight = reelSymbols[i].length * symbolHeight;
|
||||
|
||||
const fullRotations = BASE_SPINS_PER_REEL[i] * logicalCyclePixelHeight;
|
||||
|
||||
reelPositions[i] = targetPosition - fullRotations;
|
||||
|
||||
setTimeout(() => {
|
||||
playSound('click');
|
||||
}, spinDurations[i]);
|
||||
});
|
||||
|
||||
const maxDuration = Math.max(...spinDurations);
|
||||
|
||||
setTimeout(() => {
|
||||
balance = result.newBalance;
|
||||
lastResult = result;
|
||||
onBalanceUpdate?.(result.newBalance);
|
||||
|
||||
if (result.won) {
|
||||
if (result.winType === '3 OF A KIND') {
|
||||
showSchoolPrideCannons(confetti);
|
||||
showConfetti(confetti);
|
||||
} else {
|
||||
showConfetti(confetti);
|
||||
}
|
||||
} else {
|
||||
playSound('lose');
|
||||
}
|
||||
|
||||
isSpinning = false;
|
||||
|
||||
reelPositions = reelPositions.map((pos, i) => {
|
||||
const symbolHeight = 60;
|
||||
const logicalReelCycleLength = reelSymbols[i].length;
|
||||
const logicalCyclePixelHeight = logicalReelCycleLength * symbolHeight;
|
||||
const normalized = pos % logicalCyclePixelHeight;
|
||||
return normalized > 0 ? normalized - logicalCyclePixelHeight : normalized;
|
||||
});
|
||||
}, maxDuration + 200);
|
||||
} catch (error) {
|
||||
console.error('Slots error:', error);
|
||||
toast.error('Bet failed', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
});
|
||||
isSpinning = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isSpinning) {
|
||||
const newDisplayedSymbols = reelSymbols.map((logicalCycle, i) => {
|
||||
const index = getVisibleSymbolIndex(reelPositions[i], logicalCycle.length);
|
||||
return logicalCycle[index];
|
||||
});
|
||||
|
||||
if (!lastResult) {
|
||||
displayedSymbols = newDisplayedSymbols;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Slots</CardTitle>
|
||||
<CardDescription>Match 3 symbols to win big!</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<!-- Left Side: Slots Machine -->
|
||||
<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>
|
||||
|
||||
<!-- Slots Machine -->
|
||||
<div class="slots-machine">
|
||||
<div class="slots-container">
|
||||
{#each reelSymbols as logicalCycleData, reelIndex}
|
||||
<div class="reel">
|
||||
<div
|
||||
class="reel-strip"
|
||||
style="transform: translateY({reelPositions[
|
||||
reelIndex
|
||||
]}px); transition: {isSpinning
|
||||
? `transform ${2 + reelIndex * 0.5}s cubic-bezier(0.17, 0.67, 0.16, 0.99)`
|
||||
: 'none'};"
|
||||
>
|
||||
{#each Array(NUM_RENDERED_CYCLES) as _, cycleInstanceIndex}
|
||||
{#each logicalCycleData as symbol, symbolIndexInCycle}
|
||||
<div class="symbol">
|
||||
<img src="/facedev/avif/{symbol}.avif" alt={symbol} class="symbol-image" />
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="payline"></div>
|
||||
</div>
|
||||
|
||||
<!-- Result Display -->
|
||||
<div class="flex items-center justify-center text-center">
|
||||
{#if lastResult && !isSpinning}
|
||||
<div class="bg-muted/50 w-full rounded-lg p-3">
|
||||
{#if lastResult.won}
|
||||
<p class="text-success font-semibold">
|
||||
WIN - {lastResult.winType}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
Won {formatValue(lastResult.payout)}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-destructive font-semibold">NO MATCH</p>
|
||||
<p class="text-sm">
|
||||
Lost {formatValue(lastResult.amountWagered)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Side: Betting Controls -->
|
||||
<div class="space-y-4">
|
||||
<!-- Paytable -->
|
||||
<div>
|
||||
<div class="mb-2 block text-sm font-medium">Paytable</div>
|
||||
<div class="bg-muted/50 space-y-1 rounded-lg p-3 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span>3 Same Symbols:</span>
|
||||
<span class="text-success">5x</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>2 Same Symbols:</span>
|
||||
<span class="text-success">2x</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bet Amount -->
|
||||
<div>
|
||||
<label for="bet-amount" class="mb-2 block text-sm font-medium">Bet Amount</label>
|
||||
<Input
|
||||
id="bet-amount"
|
||||
type="number"
|
||||
bind:value={betAmount}
|
||||
min="1"
|
||||
max={balance}
|
||||
disabled={isSpinning}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Percentage Quick Actions -->
|
||||
<div>
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(balance * 0.25))}
|
||||
disabled={isSpinning}>25%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(balance * 0.5))}
|
||||
disabled={isSpinning}>50%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(balance * 0.75))}
|
||||
disabled={isSpinning}>75%</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => setBetAmount(Math.floor(balance))}
|
||||
disabled={isSpinning}>Max</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spin Button -->
|
||||
<Button class="h-12 w-full text-lg" onclick={spin} disabled={!canBet}>
|
||||
{isSpinning ? 'Spinning...' : 'Spin'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.slots-machine {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.slots-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 198px;
|
||||
}
|
||||
|
||||
.reel {
|
||||
flex: 1;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.reel::before,
|
||||
.reel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reel::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
|
||||
.reel::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.2), transparent);
|
||||
}
|
||||
|
||||
.reel-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.symbol:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.symbol-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.payline {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--primary), transparent);
|
||||
transform: translateY(-1px);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.payline::before,
|
||||
.payline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
background: var(--primary);
|
||||
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
||||
}
|
||||
|
||||
.payline::before {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.payline::after {
|
||||
right: -4px;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -231,4 +231,101 @@ export function getTimeframeInSeconds(timeframe: string): number {
|
|||
}
|
||||
}
|
||||
|
||||
//
|
||||
let availableSounds = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
export function playRandomFireworkSound() {
|
||||
// If no sounds available, reset the array
|
||||
if (availableSounds.length === 0) {
|
||||
availableSounds = [1, 2, 3, 4, 5, 6, 7];
|
||||
}
|
||||
|
||||
// Pick a random sound from available ones
|
||||
const randomIndex = Math.floor(Math.random() * availableSounds.length);
|
||||
const soundNumber = availableSounds[randomIndex];
|
||||
|
||||
// Remove the sound from available array to prevent repetition
|
||||
availableSounds = availableSounds.filter((_, index) => index !== randomIndex);
|
||||
|
||||
playSound(`firework${soundNumber}`);
|
||||
}
|
||||
|
||||
export function playSound(sound: string) {
|
||||
try {
|
||||
const audio = new Audio(`sound/${sound}.mp3`);
|
||||
audio.volume = 0.3; // TODO: volume control
|
||||
audio.play().catch(console.error);
|
||||
} catch (error) {
|
||||
console.error('Error playing sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function showConfetti(confetti: any) {
|
||||
const duration = 2 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
||||
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min;
|
||||
}
|
||||
|
||||
playRandomFireworkSound();
|
||||
|
||||
const interval = setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval);
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
|
||||
});
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
|
||||
});
|
||||
|
||||
if (Math.floor(timeLeft / 500) !== Math.floor((timeLeft - 250) / 500)) {
|
||||
playRandomFireworkSound();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
export function showSchoolPrideCannons(confetti: any) {
|
||||
const end = Date.now() + (3 * 1000);
|
||||
const colors = ['#bb0000', '#ffffff'];
|
||||
playSound('cannon');
|
||||
playSound('win');
|
||||
|
||||
setTimeout(() => {
|
||||
playSound('cannon');
|
||||
}, 100);
|
||||
|
||||
(function frame() {
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0 },
|
||||
colors: colors
|
||||
});
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1 },
|
||||
colors: colors
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
export const formatMarketCap = formatValue;
|
||||
|
|
|
|||
84
website/src/routes/api/gambling/coinflip/+server.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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 CoinflipRequest {
|
||||
side: 'heads' | 'tails';
|
||||
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 { side, amount }: CoinflipRequest = await request.json();
|
||||
|
||||
if (!['heads', 'tails'].includes(side)) {
|
||||
return json({ error: 'Invalid side' }, { 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);
|
||||
|
||||
if (amount > currentBalance) {
|
||||
throw new Error(`Insufficient funds. You need *${amount.toFixed(2)} but only have *${currentBalance.toFixed(2)}`);
|
||||
}
|
||||
|
||||
const gameResult: 'heads' | 'tails' = randomBytes(1)[0] < 128 ? 'heads' : 'tails';
|
||||
const won = gameResult === side;
|
||||
|
||||
const multiplier = 2;
|
||||
const payout = won ? amount * multiplier : 0;
|
||||
const newBalance = currentBalance - amount + 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: amount
|
||||
};
|
||||
});
|
||||
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
console.error('Coinflip API error:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
|
||||
return json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
};
|
||||
105
website/src/routes/api/gambling/slots/+server.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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 SlotsRequest {
|
||||
amount: number;
|
||||
}
|
||||
|
||||
function getRandomSymbol(symbols: string[]): string {
|
||||
const randomValue = randomBytes(1)[0];
|
||||
const index = Math.floor((randomValue / 256) * symbols.length);
|
||||
return symbols[index];
|
||||
}
|
||||
|
||||
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 { amount }: SlotsRequest = await request.json();
|
||||
|
||||
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 symbols = ['bliptext', 'bussin', 'griddycode', 'lyntr', 'subterfuge', 'twoblade', 'wattesigma', 'webx'];
|
||||
|
||||
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);
|
||||
|
||||
if (amount > currentBalance) {
|
||||
throw new Error(`Insufficient funds. You need *${amount.toFixed(2)} but only have *${currentBalance.toFixed(2)}`);
|
||||
}
|
||||
|
||||
|
||||
// Generate random symbols
|
||||
const gameResult = [
|
||||
getRandomSymbol(symbols),
|
||||
getRandomSymbol(symbols),
|
||||
getRandomSymbol(symbols)
|
||||
];
|
||||
|
||||
let multiplier = 0;
|
||||
let winType = '';
|
||||
|
||||
if (gameResult[0] === gameResult[1] && gameResult[1] === gameResult[2]) {
|
||||
multiplier = 5;
|
||||
winType = '3 OF A KIND';
|
||||
}
|
||||
|
||||
else if (gameResult[0] === gameResult[1] || gameResult[1] === gameResult[2] || gameResult[0] === gameResult[2]) {
|
||||
multiplier = 2;
|
||||
winType = '2 OF A KIND';
|
||||
}
|
||||
|
||||
const won = multiplier > 0;
|
||||
const payout = won ? amount * multiplier : 0;
|
||||
const newBalance = currentBalance - amount + payout;
|
||||
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, userId));
|
||||
|
||||
return {
|
||||
won,
|
||||
symbols: gameResult,
|
||||
newBalance,
|
||||
payout,
|
||||
amountWagered: amount,
|
||||
winType: won ? winType : undefined
|
||||
};
|
||||
});
|
||||
|
||||
return json(result);
|
||||
} catch (e) {
|
||||
console.error('Slots API error:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
|
||||
return json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
};
|
||||
85
website/src/routes/gambling/+page.svelte
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts">
|
||||
import Coinflip from '$lib/components/self/games/Coinflip.svelte';
|
||||
import Slots from '$lib/components/self/games/Slots.svelte';
|
||||
import { USER_DATA } from '$lib/stores/user-data';
|
||||
import { PORTFOLIO_DATA, fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
let shouldSignIn = $state(false);
|
||||
let balance = $state(0);
|
||||
let loading = $state(true);
|
||||
let activeGame = $state('coinflip');
|
||||
|
||||
function handleBalanceUpdate(newBalance: number) {
|
||||
balance = newBalance;
|
||||
|
||||
if ($PORTFOLIO_DATA) {
|
||||
PORTFOLIO_DATA.update((data) =>
|
||||
data
|
||||
? {
|
||||
...data,
|
||||
baseCurrencyBalance: newBalance,
|
||||
totalValue: newBalance + data.totalCoinValue
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($USER_DATA && $PORTFOLIO_DATA) {
|
||||
balance = $PORTFOLIO_DATA.baseCurrencyBalance;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Gambling - Rugplay</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto max-w-4xl p-6">
|
||||
<h1 class="mb-6 text-center text-3xl font-bold">Gambling</h1>
|
||||
|
||||
{#if !$USER_DATA}
|
||||
<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>
|
||||
<button
|
||||
class="text-primary underline hover:cursor-pointer"
|
||||
onclick={() => (shouldSignIn = true)}
|
||||
>
|
||||
Sign in to continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Game Selection -->
|
||||
<div class="mb-6 flex justify-center gap-4">
|
||||
<Button
|
||||
variant={activeGame === 'coinflip' ? 'default' : 'outline'}
|
||||
onclick={() => (activeGame = 'coinflip')}
|
||||
>
|
||||
Coinflip
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeGame === 'slots' ? 'default' : 'outline'}
|
||||
onclick={() => (activeGame = 'slots')}
|
||||
>
|
||||
Slots
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Game Content -->
|
||||
{#if activeGame === 'coinflip'}
|
||||
<Coinflip bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||
{:else if activeGame === 'slots'}
|
||||
<Slots bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
BIN
website/static/facedev/avif/bliptext.avif
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
website/static/facedev/avif/bussin.avif
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
website/static/facedev/avif/griddycode.avif
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
website/static/facedev/avif/lyntr.avif
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
website/static/facedev/avif/subterfuge.avif
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
website/static/facedev/avif/twoblade.avif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
website/static/facedev/avif/wattesigma.avif
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
website/static/facedev/avif/webx.avif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
website/static/facedev/bliptext.png
Normal file
|
After Width: | Height: | Size: 588 KiB |
BIN
website/static/facedev/bussin.png
Normal file
|
After Width: | Height: | Size: 997 KiB |
BIN
website/static/facedev/griddycode.png
Normal file
|
After Width: | Height: | Size: 843 KiB |
BIN
website/static/facedev/lyntr.png
Normal file
|
After Width: | Height: | Size: 812 KiB |
BIN
website/static/facedev/subterfuge.png
Normal file
|
After Width: | Height: | Size: 764 KiB |
BIN
website/static/facedev/twoblade.png
Normal file
|
After Width: | Height: | Size: 770 KiB |
BIN
website/static/facedev/wattesigma.png
Normal file
|
After Width: | Height: | Size: 516 KiB |
BIN
website/static/facedev/webx.png
Normal file
|
After Width: | Height: | Size: 957 KiB |