feat: gambling (coinflip & slots)

This commit is contained in:
Face 2025-05-29 14:12:27 +03:00
parent 543a5a951c
commit 1cae171748
38 changed files with 5631 additions and 1 deletions

View file

@ -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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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>

View 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>

View file

@ -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;

View 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 });
}
};

View 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 });
}
};

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.