Merge branch 'main' into documentation

This commit is contained in:
Max 2025-06-25 15:46:19 +02:00 committed by GitHub
commit 34f0cd7ac4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3649 additions and 78 deletions

View file

@ -1,10 +1,8 @@
# syntax = docker/dockerfile:1
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-slim AS base-node
WORKDIR /app
ENV NODE_ENV="production"
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y \
build-essential \
@ -18,7 +16,6 @@ RUN apt-get update -qq && \
&& rm -rf /var/lib/apt/lists/*
FROM base-node AS build-main
# Copy package files
COPY website/package.json website/package-lock.json* ./
@ -32,58 +29,31 @@ COPY website/. .
RUN mkdir -p .svelte-kit
# Generate SvelteKit types and build
RUN npm run build
FROM base-node AS build-websocket
WORKDIR /websocket
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
COPY website/websocket/package.json website/websocket/bun.lock* ./
COPY website/websocket/tsconfig.json ./
RUN bun install
COPY website/websocket/src ./src/
RUN bun build src/main.ts --outdir dist --target bun
FROM base-node AS production-main
COPY --from=build-main --chown=node:node /app/build ./build
COPY --from=build-main --chown=node:node /app/node_modules ./node_modules
COPY --from=build-main --chown=node:node /app/package.json ./package.json
RUN npm install -g pm2
RUN echo 'module.exports = {\
apps: [{\
name: "rugplay-app",\
script: "./build/index.js",\
instances: "max",\
exec_mode: "cluster",\
env: {\
NODE_ENV: "production",\
PORT: 3000,\
BODY_SIZE_LIMIT: "1.1M"\
}\
}]\
};' > ecosystem.config.cjs
USER node
EXPOSE 3000
CMD ["node", "build"]
CMD ["pm2-runtime", "start", "ecosystem.config.cjs"]
FROM base-node AS production-websocket
FROM oven/bun:1 AS production-websocket
WORKDIR /websocket
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
COPY --from=build-websocket /websocket/dist ./dist
COPY --from=build-websocket /websocket/package.json ./package.json
COPY --from=build-websocket --chown=bun:bun /websocket/dist ./dist
COPY --from=build-websocket --chown=bun:bun /websocket/package.json ./package.json
USER bun
EXPOSE 8080
CMD ["bun", "run", "dist/main.js"]

View file

@ -5,14 +5,16 @@ services:
target: production-main
dockerfile: Dockerfile
ports:
- "3002:3000"
- "5900-5907:3000"
env_file:
- website/.env
depends_on:
- websocket
- redis
- postgres
restart: unless-stopped
networks:
- shared_backend
deploy:
replicas: 8
websocket:
build:
@ -20,33 +22,13 @@ services:
target: production-websocket
dockerfile: Dockerfile
ports:
- "8081:8080"
- "8082:8080"
env_file:
- website/.env
depends_on:
- redis
restart: unless-stopped
networks:
- shared_backend
redis:
image: redis:8-alpine
volumes:
- rugplay_redisdata:/data
command: "redis-server --save 60 1"
restart: unless-stopped
postgres:
image: pgvector/pgvector:pg16
container_name: rugplay-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB:-rugplay}
ports:
- "5432:5432"
volumes:
- rugplay_pgdata:/var/lib/postgresql/data
restart: unless-stopped
volumes:
rugplay_pgdata:
rugplay_redisdata:
networks:
shared_backend:
external: true

View file

@ -0,0 +1,19 @@
CREATE INDEX IF NOT EXISTS "coin_symbol_idx" ON "coin" USING btree ("symbol");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_creator_id_idx" ON "coin" USING btree ("creator_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_is_listed_idx" ON "coin" USING btree ("is_listed");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_market_cap_idx" ON "coin" USING btree ("market_cap");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_current_price_idx" ON "coin" USING btree ("current_price");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_change24h_idx" ON "coin" USING btree ("change_24h");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_volume24h_idx" ON "coin" USING btree ("volume_24h");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coin_created_at_idx" ON "coin" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transaction_user_id_idx" ON "transaction" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transaction_coin_id_idx" ON "transaction" USING btree ("coin_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transaction_type_idx" ON "transaction" USING btree ("type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transaction_timestamp_idx" ON "transaction" USING btree ("timestamp");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transaction_user_coin_idx" ON "transaction" USING btree ("user_id","coin_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "transaction_coin_type_idx" ON "transaction" USING btree ("coin_id","type");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_username_idx" ON "user" USING btree ("username");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_is_banned_idx" ON "user" USING btree ("is_banned");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_is_admin_idx" ON "user" USING btree ("is_admin");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_created_at_idx" ON "user" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "user_updated_at_idx" ON "user" USING btree ("updated_at");

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,13 @@
"when": 1749916220202,
"tag": "0002_small_micromacro",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1750707307426,
"tag": "0003_complete_runaways",
"breakpoints": true
}
]
}

View file

@ -7,6 +7,7 @@ import { redirect, type Handle } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { minesCleanupInactiveGames, minesAutoCashout } from '$lib/server/games/mines';
async function initializeScheduler() {
if (building) return;
@ -49,10 +50,16 @@ async function initializeScheduler() {
processAccountDeletions().catch(console.error);
}, 5 * 60 * 1000);
const minesCleanupInterval = setInterval(() => {
minesCleanupInactiveGames().catch(console.error);
minesAutoCashout().catch(console.error);
}, 60 * 1000);
// Cleanup on process exit
const cleanup = async () => {
clearInterval(renewInterval);
clearInterval(schedulerInterval);
clearInterval(minesCleanupInterval);
const currentValue = await redis.get(lockKey);
if (currentValue === lockValue) {
await redis.del(lockKey);
@ -172,6 +179,13 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.userSession = userData;
if (event.url.pathname.startsWith('/api/')) {
const response = await svelteKitHandler({ event, resolve, auth });
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
return response;
}
return svelteKitHandler({ event, resolve, auth });
};

View file

@ -164,6 +164,7 @@
import { formatValue, playSound, showConfetti } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount } from 'svelte';
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
interface CoinflipResult {
won: boolean;
@ -314,8 +315,18 @@
}
}
onMount(() => {
onMount(async () => {
volumeSettings.load();
try {
const data = await fetchPortfolioSummary();
if (data) {
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
}
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
</script>

View file

@ -0,0 +1,419 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import confetti from 'canvas-confetti';
import { toast } from 'svelte-sonner';
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount } from 'svelte';
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
interface DiceResult {
won: boolean;
result: number;
newBalance: number;
payout: number;
amountWagered: number;
}
const MAX_BET_AMOUNT = 1000000;
const baseRotation = "rotate3d(1, 1, 0, 340deg)";
const faceRotations = {
1: { x: 0, y: 0 },
2: { x: 0, y: 90 },
3: { x: 90, y: 0 },
4: { x: -90, y: 0 },
5: { x: 0, y: -90 },
6: { x: 0, y: 180 }
};
const diceRotations = {
1: { x: 0, y: 0, z: 0 },
2: { x: 0, y: -90, z: 0 },
3: { x: -90, y: 0, z: 0 },
4: { x: 90, y: 0, z: 0 },
5: { x: 0, y: 90, z: 0 },
6: { x: 0, y: 180, z: 0 }
};
function getExtraSpin(spinFactor = 4) {
const extraSpinsX = spinFactor * 360;
const extraSpinsY = spinFactor * 360;
const extraSpinsZ = spinFactor * 360;
return {
x: extraSpinsX,
y: extraSpinsY,
z: extraSpinsZ
};
}
function getFaceRotation(face: number) {
return faceRotations[face as keyof typeof faceRotations];
}
function getFaceTransform(face: number): string {
const rotation = getFaceRotation(face);
return `${getRotate(rotation.x, rotation.y)} translateZ(50px)`;
}
function getDiceRotation(face: number, addExtraSpin = false, spinFactor = 4) {
let extraSpin = { x: 0, y: 0, z: 0 };
if (addExtraSpin) {
extraSpin = getExtraSpin(spinFactor);
}
const rotation = diceRotations[face as keyof typeof diceRotations];
return {
x: rotation.x + extraSpin.x,
y: rotation.y + extraSpin.y,
z: rotation.z + extraSpin.z
};
}
function getDiceTransform(face: number, addExtraSpin = false, spinFactor = 4): string {
const rotation = getDiceRotation(face, addExtraSpin, spinFactor);
return `${baseRotation} ${getRotate(rotation.x, rotation.y, rotation.z)}`;
}
function getRotate(x?: number, y?: number, z?: number) {
const rotateX = x !== undefined ? `rotateX(${x}deg)` : "";
const rotateY = y !== undefined ? `rotateY(${y}deg)` : "";
const rotateZ = z !== undefined ? `rotateZ(${z}deg)` : "";
return `${rotateX} ${rotateY} ${rotateZ}`;
}
let {
balance = $bindable(),
onBalanceUpdate
}: {
balance: number;
onBalanceUpdate?: (newBalance: number) => void;
} = $props();
let betAmount = $state(10);
let betAmountDisplay = $state('10');
let selectedNumber = $state(1);
let isRolling = $state(false);
let lastResult = $state<DiceResult | null>(null);
let activeSoundTimeouts = $state<NodeJS.Timeout[]>([]);
let diceElement: HTMLElement | null = null;
let canBet = $derived(
betAmount > 0 && betAmount <= balance && betAmount <= MAX_BET_AMOUNT && !isRolling
);
function selectNumber(num: number) {
if (!isRolling) {
selectedNumber = num;
playSound('click');
}
}
function setBetAmount(amount: number) {
const clampedAmount = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
if (clampedAmount >= 0) {
betAmount = clampedAmount;
betAmountDisplay = clampedAmount.toLocaleString();
}
}
function handleBetAmountInput(event: Event) {
const target = event.target as HTMLInputElement;
const value = target.value.replace(/,/g, '');
const numValue = parseFloat(value) || 0;
const clampedValue = Math.min(numValue, Math.min(balance, MAX_BET_AMOUNT));
betAmount = clampedValue;
betAmountDisplay = target.value;
}
function handleBetAmountBlur() {
betAmountDisplay = betAmount.toLocaleString();
}
async function rollDice() {
if (!canBet) return;
isRolling = true;
lastResult = null;
activeSoundTimeouts.forEach(clearTimeout);
activeSoundTimeouts = [];
const spinFactor = 20;
const animationDuration = 1500;
try {
const response = await fetch('/api/gambling/dice', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
selectedNumber,
amount: betAmount
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to place bet');
}
const resultData: DiceResult = await response.json();
playSound('dice');
if (diceElement) {
diceElement.style.transition = 'none';
diceElement.style.transform = getDiceTransform(selectedNumber, false);
void diceElement.offsetHeight;
diceElement.style.transition = 'transform 1.5s cubic-bezier(0.1, 0.9, 0.1, 1)';
diceElement.style.transform = getDiceTransform(resultData.result, true, spinFactor);
}
await new Promise(resolve => setTimeout(resolve, animationDuration));
await new Promise(resolve => setTimeout(resolve, 200)); // Small delay to show the Result
balance = resultData.newBalance;
lastResult = resultData;
onBalanceUpdate?.(resultData.newBalance);
if (resultData.won) {
showConfetti(confetti);
showSchoolPrideCannons(confetti);
} else {
playSound('lose');
}
isRolling = false;
} catch (error) {
console.error('Dice roll error:', error);
toast.error('Roll failed', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
isRolling = false;
activeSoundTimeouts.forEach(clearTimeout);
activeSoundTimeouts = [];
}
}
onMount(async () => {
volumeSettings.load();
try {
const data = await fetchPortfolioSummary();
if (data) {
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
}
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
</script>
<Card>
<CardHeader>
<CardTitle>Dice</CardTitle>
<CardDescription>Choose a number and roll the dice to win 3x your bet!</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<div class="flex flex-col space-y-4">
<div class="text-center">
<p class="text-muted-foreground text-sm">Balance</p>
<p class="text-2xl font-bold">{formatValue(balance)}</p>
</div>
<div class="flex-1 flex items-center justify-center">
<div class="dice-container">
<div class="dice" bind:this={diceElement}>
{#each Array(6) as _, i}
<div class="face" style="transform: {getFaceTransform(i + 1)}">
<div class="dot-container">
{#each Array(i + 1) as _}
<div class="dot"></div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
<div class="flex items-center justify-center text-center">
{#if lastResult && !isRolling}
<div class="bg-muted/50 w-full rounded-lg p-3">
{#if lastResult.won}
<p class="text-success font-semibold">WIN</p>
<p class="text-sm">
Won {formatValue(lastResult.payout)} on {lastResult.result}
</p>
{:else}
<p class="text-destructive font-semibold">LOSS</p>
<p class="text-sm">
Lost {formatValue(lastResult.amountWagered)} on {lastResult.result}
</p>
{/if}
</div>
{/if}
</div>
</div>
<div class="space-y-4">
<div>
<div class="mb-2 block text-sm font-medium">Choose Number</div>
<div class="grid grid-cols-3 gap-2">
{#each Array(6) as _, i}
<Button
variant={selectedNumber === i + 1 ? 'default' : 'outline'}
onclick={() => selectNumber(i + 1)}
disabled={isRolling}
class="h-16"
>
{i + 1}
</Button>
{/each}
</div>
</div>
<div>
<label for="bet-amount" class="mb-2 block text-sm font-medium">Bet Amount</label>
<Input
id="bet-amount"
type="text"
value={betAmountDisplay}
oninput={handleBetAmountInput}
onblur={handleBetAmountBlur}
disabled={isRolling}
placeholder="Enter bet amount"
/>
<p class="text-muted-foreground mt-1 text-xs">
Max bet: {MAX_BET_AMOUNT.toLocaleString()}
</p>
</div>
<div>
<div class="grid grid-cols-4 gap-2">
<Button
size="sm"
variant="outline"
onclick={() =>
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.25))}
disabled={isRolling}>25%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.5))}
disabled={isRolling}>50%</Button
>
<Button
size="sm"
variant="outline"
onclick={() =>
setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT) * 0.75))}
disabled={isRolling}>75%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance || 0, MAX_BET_AMOUNT)))}
disabled={isRolling}>Max</Button
>
</div>
</div>
<Button class="h-12 w-full text-lg" onclick={rollDice} disabled={!canBet}>
{isRolling ? 'Rolling...' : 'Roll'}
</Button>
</div>
</div>
</CardContent>
</Card>
<style>
.dice-container {
perspective: 1000px;
}
.dice {
width: 100px;
height: 100px;
transform-style: preserve-3d;
transform: rotate3d(0.9, 1, 0, 340deg);
transition: transform 4s cubic-bezier(0.1, 0.9, 0.1, 1);
}
.face {
position: absolute;
width: 100px;
height: 100px;
background: #fff;
border: 2px solid #363131;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
backface-visibility: hidden;
box-sizing: border-box;
}
.face:nth-child(1) { transform: translateZ(50px); }
.face:nth-child(2) { transform: rotateY(90deg) translateZ(50px); }
.face:nth-child(3) { transform: rotateX(90deg) translateZ(50px); }
.face:nth-child(4) { transform: rotateX(-90deg) translateZ(50px); }
.face:nth-child(5) { transform: rotateY(-90deg) translateZ(50px); }
.face:nth-child(6) { transform: rotateY(180deg) translateZ(50px); }
.dot-container {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 15%;
gap: 10%;
box-sizing: border-box;
}
.dot {
background: #363131;
border-radius: 50%;
width: 100%;
height: 100%;
}
/* Dot positions for each face */
.face:nth-child(1) .dot { grid-area: 2 / 2; }
.face:nth-child(2) .dot:nth-child(1) { grid-area: 1 / 1; }
.face:nth-child(2) .dot:nth-child(2) { grid-area: 3 / 3; }
.face:nth-child(3) .dot:nth-child(1) { grid-area: 1 / 1; }
.face:nth-child(3) .dot:nth-child(2) { grid-area: 2 / 2; }
.face:nth-child(3) .dot:nth-child(3) { grid-area: 3 / 3; }
.face:nth-child(4) .dot:nth-child(1) { grid-area: 1 / 1; }
.face:nth-child(4) .dot:nth-child(2) { grid-area: 1 / 3; }
.face:nth-child(4) .dot:nth-child(3) { grid-area: 3 / 1; }
.face:nth-child(4) .dot:nth-child(4) { grid-area: 3 / 3; }
.face:nth-child(5) .dot:nth-child(1) { grid-area: 1 / 1; }
.face:nth-child(5) .dot:nth-child(2) { grid-area: 1 / 3; }
.face:nth-child(5) .dot:nth-child(3) { grid-area: 2 / 2; }
.face:nth-child(5) .dot:nth-child(4) { grid-area: 3 / 1; }
.face:nth-child(5) .dot:nth-child(5) { grid-area: 3 / 3; }
.face:nth-child(6) .dot:nth-child(1) { grid-area: 1 / 1; }
.face:nth-child(6) .dot:nth-child(2) { grid-area: 1 / 3; }
.face:nth-child(6) .dot:nth-child(3) { grid-area: 2 / 1; }
.face:nth-child(6) .dot:nth-child(4) { grid-area: 2 / 3; }
.face:nth-child(6) .dot:nth-child(5) { grid-area: 3 / 1; }
.face:nth-child(6) .dot:nth-child(6) { grid-area: 3 / 3; }
</style>

View file

@ -0,0 +1,528 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import confetti from 'canvas-confetti';
import { toast } from 'svelte-sonner';
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount, onDestroy } from 'svelte';
import { ModeWatcher } from 'mode-watcher';
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
import { calculateMinesMultiplier } from '$lib/utils';
const GRID_SIZE = 5;
const TOTAL_TILES = GRID_SIZE * GRID_SIZE;
const MAX_BET_AMOUNT = 1000000;
const MIN_MINES = 3;
const AUTO_CASHOUT_TIME = 15;
let {
balance = $bindable(),
onBalanceUpdate
}: {
balance: number;
onBalanceUpdate?: (newBalance: number) => void;
} = $props();
let betAmount = $state(10);
let betAmountDisplay = $state('10');
let mineCount = $state(3);
let isPlaying = $state(false);
let revealedTiles = $state<number[]>([]);
let minePositions = $state<number[]>([]);
let currentMultiplier = $state(1);
let autoCashoutTimer = $state(0);
let autoCashoutProgress = $state(0);
let sessionToken = $state<string | null>(null);
let autoCashoutInterval: ReturnType<typeof setInterval>;
let hasRevealedTile = $state(false);
let isAutoCashout = $state(false);
let lastClickedTile = $state<number | null>(null);
let clickedSafeTiles = $state<number[]>([]);
let canBet = $derived(
betAmount > 0 && betAmount <= balance && betAmount <= MAX_BET_AMOUNT && !isPlaying
);
function calculateProbability(picks: number, mines: number): string {
let probability = 1;
for (let i = 0; i < picks; i++) {
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
}
return (probability * 100).toFixed(2);
}
function setBetAmount(amount: number) {
const clamped = Math.min(amount, Math.min(balance, MAX_BET_AMOUNT));
if (clamped >= 0) {
betAmount = clamped;
betAmountDisplay = clamped.toLocaleString();
}
}
function handleBetAmountInput(event: Event) {
const value = (event.target as HTMLInputElement).value.replace(/,/g, '');
const num = parseFloat(value) || 0;
const clamped = Math.min(num, Math.min(balance, MAX_BET_AMOUNT));
betAmount = clamped;
betAmountDisplay = value;
}
function handleBetAmountBlur() {
betAmountDisplay = betAmount.toLocaleString();
}
function resetAutoCashoutTimer() {
if (autoCashoutInterval) clearInterval(autoCashoutInterval);
autoCashoutTimer = 0;
autoCashoutProgress = 0;
}
function startAutoCashoutTimer() {
if (!hasRevealedTile) return;
resetAutoCashoutTimer();
autoCashoutInterval = setInterval(() => {
if (autoCashoutTimer < AUTO_CASHOUT_TIME) {
autoCashoutTimer += 0.1;
autoCashoutProgress = (autoCashoutTimer / AUTO_CASHOUT_TIME) * 100;
}
if (autoCashoutTimer >= AUTO_CASHOUT_TIME) {
isAutoCashout = true;
clearInterval(autoCashoutInterval);
cashOut();
}
}, 100);
}
async function handleTileClick(index: number) {
if (!isPlaying || revealedTiles.includes(index) || !sessionToken) return;
lastClickedTile = index;
try {
const response = await fetch('/api/gambling/mines/reveal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken, tileIndex: index })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to reveal tile');
}
const result = await response.json();
if (result.hitMine) {
playSound('lose');
revealedTiles = [...revealedTiles, index];
minePositions = result.minePositions;
isPlaying = false;
resetAutoCashoutTimer();
balance = result.newBalance;
onBalanceUpdate?.(result.newBalance);
} else {
playSound('flip');
revealedTiles = [...revealedTiles, index];
clickedSafeTiles = [...clickedSafeTiles, index];
currentMultiplier = result.currentMultiplier;
hasRevealedTile = true;
startAutoCashoutTimer();
if (result.status === 'won') {
showSchoolPrideCannons(confetti);
showConfetti(confetti);
await cashOut();
}
}
} catch (error) {
console.error('Mines error:', error);
toast.error('Failed to reveal tile', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
async function cashOut() {
if (!isPlaying || !sessionToken) return;
try {
const response = await fetch('/api/gambling/mines/cashout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken })
});
if (!response.ok) {
const errorData = await response.json();
if (!isAutoCashout || errorData.error !== 'Invalid session') {
throw new Error(errorData.error || 'Failed to cash out');
}
return;
}
const result = await response.json();
balance = result.newBalance;
onBalanceUpdate?.(balance);
if (result.payout > betAmount) showConfetti(confetti);
playSound(result.isAbort ? 'flip' : 'win');
isPlaying = false;
hasRevealedTile = false;
isAutoCashout = false;
resetAutoCashoutTimer();
minePositions = [];
} catch (error) {
console.error('Cashout error:', error);
toast.error('Failed to cash out', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
async function startGame() {
if (!canBet) return;
balance -= betAmount;
onBalanceUpdate?.(balance);
try {
const response = await fetch('/api/gambling/mines/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ betAmount, mineCount })
});
if (!response.ok) {
const errorData = await response.json();
balance += betAmount;
onBalanceUpdate?.(balance);
throw new Error(errorData.error || 'Failed to start game');
}
const result = await response.json();
isPlaying = true;
hasRevealedTile = false;
revealedTiles = [];
clickedSafeTiles = [];
currentMultiplier = 1;
sessionToken = result.sessionToken;
minePositions = [];
} catch (error) {
console.error('Start game error:', error);
toast.error('Failed to start game', {
description: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
}
onMount(async () => {
volumeSettings.load();
try {
const data = await fetchPortfolioSummary();
if (data) {
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
}
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
onDestroy(resetAutoCashoutTimer);
</script>
<Card>
<CardHeader>
<CardTitle>Mines</CardTitle>
<CardDescription>
Navigate through the minefield and cash out before hitting a mine!
</CardDescription>
</CardHeader>
<CardContent>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<!-- Left Side: Grid and Stats -->
<div class="flex flex-col space-y-4">
<!-- Balance Display -->
<div class="text-center">
<p class="text-muted-foreground text-sm">Balance</p>
<p class="text-2xl font-bold">{formatValue(balance)}</p>
</div>
<!-- Mines Grid -->
<div class="mines-grid" class:pulse-warning={isPlaying && autoCashoutTimer >= 7}>
{#each Array(TOTAL_TILES) as _, index}
<ModeWatcher />
<button
class="mine-tile"
onclick={() => handleTileClick(index)}
disabled={!isPlaying}
class:revealed={revealedTiles.includes(index)}
class:mine={revealedTiles.includes(index) &&
minePositions.includes(index) &&
!clickedSafeTiles.includes(index)}
class:safe={revealedTiles.includes(index) &&
!minePositions.includes(index) &&
clickedSafeTiles.includes(index)}
class:light={document.documentElement.classList.contains('light')}
aria-label="Tile"
>
{#if revealedTiles.includes(index)}
{#if minePositions.includes(index)}
<img src="/facedev/avif/bussin.avif" alt="Mine" class="h-8 w-8 object-contain" />
{:else}
<img
src="/facedev/avif/twoblade.avif"
alt="Safe"
class="h-8 w-8 object-contain"
/>
{/if}
{/if}
</button>
{/each}
</div>
</div>
<!-- Right Side: Controls -->
<div class="space-y-4">
<div>
<label for="mine-count" class="mb-2 block text-sm font-medium">Number of Mines</label>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onclick={() => (mineCount = Math.max(mineCount - 1, MIN_MINES))}
disabled={isPlaying || mineCount <= MIN_MINES}
aria-label="Decrease mines">-</Button
>
<Input
id="mine-count"
type="number"
min={MIN_MINES}
max={24}
value={mineCount}
oninput={(e) => {
const target = e.target as HTMLInputElement | null;
const val = Math.max(
MIN_MINES,
Math.min(24, parseInt(target?.value ?? '') || MIN_MINES)
);
mineCount = val;
}}
disabled={isPlaying}
class="w-12 text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<Button
variant="secondary"
size="sm"
onclick={() => (mineCount = Math.min(mineCount + 1, 24))}
disabled={isPlaying || mineCount >= 24}
aria-label="Increase mines">+</Button
>
</div>
<p class="text-muted-foreground mt-1 text-xs">
You will get
<span class="text-success font-semibold">
{calculateMinesMultiplier(isPlaying ? revealedTiles.length + 1 : 1, mineCount, betAmount).toFixed(2)}x
</span>
per tile, probability of winning:
<span class="text-success font-semibold">
{calculateProbability(isPlaying ? 1 : 1, mineCount)}%
</span>
</p>
<span class="text-muted-foreground text-xs">
Note: Maximum payout per game is capped at $2,000,000.
</span>
</div>
<div>
<label for="bet-amount" class="mb-2 block text-sm font-medium">Bet Amount</label>
<Input
id="bet-amount"
type="text"
value={betAmountDisplay}
oninput={handleBetAmountInput}
onblur={handleBetAmountBlur}
disabled={isPlaying}
placeholder="Enter bet amount"
/>
<p class="text-muted-foreground mt-1 text-xs">
Max bet: {MAX_BET_AMOUNT.toLocaleString()}
</p>
</div>
<div>
<div class="grid grid-cols-4 gap-2">
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT) * 0.25))}
disabled={isPlaying}>25%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT) * 0.5))}
disabled={isPlaying}>50%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT) * 0.75))}
disabled={isPlaying}>75%</Button
>
<Button
size="sm"
variant="outline"
onclick={() => setBetAmount(Math.floor(Math.min(balance, MAX_BET_AMOUNT)))}
disabled={isPlaying}>Max</Button
>
</div>
</div>
<div class="flex flex-col gap-2">
{#if !isPlaying}
<Button class="h-12 flex-1 text-lg" onclick={startGame} disabled={!canBet}>
Start Game
</Button>
{:else}
{#if hasRevealedTile}
<div class="space-y-1">
<div class="bg-border h-px w-full"></div>
<div class="text-muted-foreground text-center text-xs">
Auto Cashout in {Math.ceil(AUTO_CASHOUT_TIME - autoCashoutTimer)}s
</div>
<div class="bg-muted h-1 w-full overflow-hidden rounded-full">
<div
class="bg-primary h-full transition-all duration-100"
class:urgent={autoCashoutTimer >= 7}
style="width: {autoCashoutProgress}%"
></div>
</div>
<div class="bg-border h-px w-full"></div>
</div>
{/if}
<Button class="h-12 flex-1 text-lg" onclick={cashOut} disabled={!isPlaying}>
{hasRevealedTile ? 'Cash Out' : 'Abort Bet'}
</Button>
<!-- Current Stats -->
{#if hasRevealedTile}
<div class="bg-muted/50 space-y-2 rounded-lg p-3">
<div class="flex justify-between">
<span>Current Profit:</span>
<span class="text-success">
+{formatValue(betAmount * (currentMultiplier - 1))}
</span>
</div>
<div class="flex justify-between">
<span>Next Tile:</span>
<span>
+{formatValue(
betAmount * (calculateMinesMultiplier(revealedTiles.length + 1, mineCount, betAmount) - 1)
)}
</span>
</div>
<div class="flex justify-between">
<span>Current Multiplier:</span>
<span>{currentMultiplier.toFixed(2)}x</span>
</div>
</div>
{/if}
{/if}
</div>
</div>
</div>
</CardContent>
</Card>
<style>
.mines-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
background: var(--muted);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 8px;
transition: all 0.3s ease;
}
.mines-grid.pulse-warning {
position: relative;
}
.mines-grid.pulse-warning::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
border: 2px solid var(--ring);
border-radius: var(--radius);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
.urgent {
position: relative;
}
.urgent::before {
content: '';
position: absolute;
top: -2px;
right: -2px;
bottom: -2px;
left: -2px;
border: 2px solid var(--ring);
border-radius: 9999px;
animation: pulse 0.5s ease-in-out infinite;
}
.mine-tile {
aspect-ratio: 1;
background: var(--card);
border: 1px solid black;
border-radius: calc(var(--radius) - 2px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
transform-style: preserve-3d;
position: relative;
cursor: pointer;
}
.mine-tile:hover:not(:disabled) {
background: var(--accent);
}
.mine-tile.revealed {
background: var(--muted);
transform: rotateY(180deg);
}
.mine-tile.mine {
background-color: rgba(239, 68, 68, 0.3);
border: 2px solid rgb(239, 68, 68);
}
.mine-tile.mine img {
filter: brightness(0.9) contrast(1.4);
}
.mine-tile.safe {
background-color: rgba(34, 197, 94, 0.2);
border: 2px solid rgb(34, 197, 94);
}
.mine-tile img {
backface-visibility: hidden;
transform: rotateY(180deg);
width: 32px;
height: 32px;
object-fit: contain;
}
</style>

View file

@ -13,6 +13,7 @@
import { formatValue, playSound, showConfetti, showSchoolPrideCannons } from '$lib/utils';
import { volumeSettings } from '$lib/stores/volume-settings';
import { onMount } from 'svelte';
import { fetchPortfolioSummary } from '$lib/stores/portfolio-data';
interface SlotsResult {
won: boolean;
@ -211,8 +212,19 @@
}
});
onMount(() => {
// Dynmaically fetch the correct balance.
onMount(async () => {
volumeSettings.load();
try {
const data = await fetchPortfolioSummary();
if (data) {
balance = data.baseCurrencyBalance;
onBalanceUpdate?.(data.baseCurrencyBalance);
}
} catch (error) {
console.error('Failed to fetch balance:', error);
}
});
</script>

View file

@ -33,6 +33,14 @@ export const user = pgTable("user", {
}).notNull().default("0.00000000"),
loginStreak: integer("login_streak").notNull().default(0),
prestigeLevel: integer("prestige_level").default(0),
}, (table) => {
return {
usernameIdx: index("user_username_idx").on(table.username),
isBannedIdx: index("user_is_banned_idx").on(table.isBanned),
isAdminIdx: index("user_is_admin_idx").on(table.isAdmin),
createdAtIdx: index("user_created_at_idx").on(table.createdAt),
updatedAtIdx: index("user_updated_at_idx").on(table.updatedAt),
};
});
export const session = pgTable("session", {
@ -88,6 +96,17 @@ export const coin = pgTable("coin", {
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
isListed: boolean("is_listed").default(true).notNull(),
}, (table) => {
return {
symbolIdx: index("coin_symbol_idx").on(table.symbol),
creatorIdIdx: index("coin_creator_id_idx").on(table.creatorId),
isListedIdx: index("coin_is_listed_idx").on(table.isListed),
marketCapIdx: index("coin_market_cap_idx").on(table.marketCap),
currentPriceIdx: index("coin_current_price_idx").on(table.currentPrice),
change24hIdx: index("coin_change24h_idx").on(table.change24h),
volume24hIdx: index("coin_volume24h_idx").on(table.volume24h),
createdAtIdx: index("coin_created_at_idx").on(table.createdAt),
};
});
export const userPortfolio = pgTable("user_portfolio", {
@ -114,6 +133,15 @@ export const transaction = pgTable("transaction", {
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(),
recipientUserId: integer('recipient_user_id').references(() => user.id, { onDelete: 'set null' }),
senderUserId: integer('sender_user_id').references(() => user.id, { onDelete: 'set null' }),
}, (table) => {
return {
userIdIdx: index("transaction_user_id_idx").on(table.userId),
coinIdIdx: index("transaction_coin_id_idx").on(table.coinId),
typeIdx: index("transaction_type_idx").on(table.type),
timestampIdx: index("transaction_timestamp_idx").on(table.timestamp),
userCoinIdx: index("transaction_user_coin_idx").on(table.userId, table.coinId),
coinTypeIdx: index("transaction_coin_type_idx").on(table.coinId, table.type),
};
});
export const priceHistory = pgTable("price_history", {

View file

@ -0,0 +1,117 @@
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redis } from '$lib/server/redis';
import { calculateMinesMultiplier } from '$lib/utils';
interface MinesSession {
sessionToken: string;
betAmount: number;
mineCount: number;
minePositions: number[];
revealedTiles: number[];
startTime: number;
currentMultiplier: number;
status: 'active' | 'won' | 'lost';
lastActivity: number;
userId: number;
}
const MINES_SESSION_PREFIX = 'mines:session:';
export const getSessionKey = (token: string) => `${MINES_SESSION_PREFIX}${token}`;
// --- Mines cleanup logic for scheduler ---
export async function minesCleanupInactiveGames() {
const now = Date.now();
const keys: string[] = [];
let cursor = '0';
do {
const scanResult = await redis.scan(cursor, { MATCH: `${MINES_SESSION_PREFIX}*` });
cursor = scanResult.cursor;
keys.push(...scanResult.keys);
} while (cursor !== '0');
for (const key of keys) {
const sessionRaw = await redis.get(key);
if (!sessionRaw) continue;
const game = JSON.parse(sessionRaw) as MinesSession;
if (now - game.lastActivity > 5 * 60 * 1000) {
if (game.revealedTiles.length === 0) {
try {
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, game.userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const newBalance = Math.round((currentBalance + game.betAmount) * 100000000) / 100000000;
await db
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, game.userId));
} catch (error) {
console.error(`Failed to refund inactive game ${game.sessionToken}:`, error);
}
}
await redis.del(getSessionKey(game.sessionToken));
}
}
}
export async function minesAutoCashout() {
const now = Date.now();
const keys: string[] = [];
let cursor = '0';
do {
const scanResult = await redis.scan(cursor, { MATCH: `${MINES_SESSION_PREFIX}*` });
cursor = scanResult.cursor;
keys.push(...scanResult.keys);
} while (cursor !== '0');
for (const key of keys) {
const sessionRaw = await redis.get(key);
if (!sessionRaw) continue;
const game = JSON.parse(sessionRaw) as MinesSession;
if (
game.status === 'active' &&
game.revealedTiles.length > 0 &&
now - game.lastActivity > 20000 &&
!game.revealedTiles.some(idx => game.minePositions.includes(idx))
) {
try {
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, game.userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const payout = game.betAmount * game.currentMultiplier;
const roundedPayout = Math.round(payout * 100000000) / 100000000;
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
await db
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, game.userId));
await redis.del(getSessionKey(game.sessionToken));
} catch (error) {
console.error(`Failed to auto cashout game ${game.sessionToken}:`, error);
}
}
}
}
export function calculateMultiplier(picks: number, mines: number, betAmount: number): number {
return calculateMinesMultiplier(picks, mines, betAmount);
}

View file

@ -384,3 +384,38 @@ export function getPrestigeColor(level: number): string {
export function getMaxPrestigeLevel(): number {
return 5;
}
export function calculateMinesMultiplier(picks: number, mines: number, betAmount: number): number {
const TOTAL_TILES = 25;
const HOUSE_EDGE = 0.05;
let probability = 1;
for (let i = 0; i < picks; i++) {
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
}
if (probability <= 0) return 1.0;
const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE);
// Backend payout cap logic
const MAX_PAYOUT = 2_000_000;
const HIGH_BET_THRESHOLD = 50_000;
const mineFactor = 1 + (mines / 25);
const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor;
let maxPayout: number;
if (betAmount > HIGH_BET_THRESHOLD) {
const betRatio = Math.pow(Math.min(1, (betAmount - HIGH_BET_THRESHOLD) / (MAX_PAYOUT - HIGH_BET_THRESHOLD)), 1);
const maxAllowedMultiplier = 1.05 + (picks * 0.1);
const highBetMultiplier = Math.min(baseMultiplier, maxAllowedMultiplier) * (1 - (betAmount / MAX_PAYOUT) * 0.9);
const betSizeFactor = Math.max(0.1, 1 - (betAmount / MAX_PAYOUT) * 0.9);
const minMultiplier = (1.1 + (picks * 0.15 * betSizeFactor)) * mineFactor;
const reducedMultiplier = highBetMultiplier - ((highBetMultiplier - minMultiplier) * betRatio);
maxPayout = Math.min(betAmount * reducedMultiplier, MAX_PAYOUT);
} else {
maxPayout = Math.min(betAmount * baseMultiplier, MAX_PAYOUT);
}
const rawPayout = fairMultiplier * betAmount;
const cappedPayout = Math.min(rawPayout, maxPayout);
const effectiveMultiplier = cappedPayout / betAmount;
return Math.max(1.0, Number(effectiveMultiplier.toFixed(2)));
}

View file

@ -95,11 +95,7 @@
class="text-primary underline hover:cursor-pointer"
onclick={() => (shouldSignIn = !shouldSignIn)}>sign in</button
>
or{' '}
<button
class="text-primary underline hover:cursor-pointer"
onclick={() => (shouldSignIn = !shouldSignIn)}>create an account</button
> to play.
to play.
{/if}
</p>
</header>

View file

@ -0,0 +1,87 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import type { RequestHandler } from './$types';
interface DiceRequest {
selectedNumber: number;
amount: number;
}
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const { selectedNumber, amount }: DiceRequest = await request.json();
if (!selectedNumber || selectedNumber < 1 || selectedNumber > 6 || !Number.isInteger(selectedNumber)) {
return json({ error: 'Invalid number selection' }, { status: 400 });
}
if (!amount || amount <= 0 || !Number.isFinite(amount)) {
return json({ error: 'Invalid bet amount' }, { status: 400 });
}
if (amount > 1000000) {
return json({ error: 'Bet amount too large' }, { status: 400 });
}
const userId = Number(session.user.id);
const result = await db.transaction(async (tx) => {
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const roundedAmount = Math.round(amount * 100000000) / 100000000;
const roundedBalance = Math.round(currentBalance * 100000000) / 100000000;
if (roundedAmount > roundedBalance) {
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
}
const gameResult = Math.floor(randomBytes(1)[0] / 42.67) + 1; // This gives us a number between 1-6
const won = gameResult === selectedNumber;
const multiplier = 3;
const payout = won ? roundedAmount * multiplier : 0;
const newBalance = roundedBalance - roundedAmount + payout;
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
return {
won,
result: gameResult,
newBalance,
payout,
amountWagered: roundedAmount
};
});
return json(result);
} catch (e) {
console.error('Dice API error:', e);
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
return json({ error: errorMessage }, { status: 400 });
}
};

View file

@ -0,0 +1,76 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redis } from '$lib/server/redis';
import { getSessionKey } from '$lib/server/games/mines';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const { sessionToken } = await request.json();
const sessionRaw = await redis.get(getSessionKey(sessionToken));
const game = sessionRaw ? JSON.parse(sessionRaw) : null;
const userId = Number(session.user.id);
if (!game) {
return json({ error: 'Invalid session' }, { status: 400 });
}
const result = await db.transaction(async (tx) => {
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
let payout: number;
let newBalance: number;
// If no tiles revealed, treat as abort and return full bet.
if (game.revealedTiles.length === 0) {
payout = game.betAmount;
newBalance = Math.round((currentBalance + payout) * 100000000) / 100000000;
} else {
payout = game.betAmount * game.currentMultiplier;
const roundedPayout = Math.round(payout * 100000000) / 100000000;
newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
}
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
await redis.del(getSessionKey(sessionToken));
return {
newBalance,
payout,
amountWagered: game.betAmount,
isAbort: game.revealedTiles.length === 0,
minePositions: game.minePositions
};
});
return json(result);
} catch (e) {
console.error('Mines cashout error:', e);
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
return json({ error: errorMessage }, { status: 400 });
}
};

View file

@ -0,0 +1,127 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { calculateMultiplier } from '$lib/server/games/mines';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redis, } from '$lib/server/redis';
import { getSessionKey } from '$lib/server/games/mines';
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const { sessionToken, tileIndex } = await request.json();
if (!Number.isInteger(tileIndex) || tileIndex < 0 || tileIndex > 24) {
return json({ error: 'Invalid tileIndex' }, { status: 400 });
}
const sessionRaw = await redis.get(getSessionKey(sessionToken));
const game = sessionRaw ? JSON.parse(sessionRaw) : null;
if (!game) {
return json({ error: 'Invalid session' }, { status: 400 });
}
if (game.revealedTiles.includes(tileIndex)) {
return json({ error: 'Tile already revealed' }, { status: 400 });
}
game.lastActivity = Date.now();
if (game.minePositions.includes(tileIndex)) {
game.status = 'lost';
const minePositions = game.minePositions;
const userId = Number(session.user.id);
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
await db
.update(user)
.set({
baseCurrencyBalance: currentBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
await redis.del(getSessionKey(sessionToken));
return json({
hitMine: true,
minePositions,
newBalance: currentBalance,
status: 'lost',
amountWagered: game.betAmount
});
}
// Safe tile
game.revealedTiles.push(tileIndex);
game.currentMultiplier = calculateMultiplier(
game.revealedTiles.length,
game.mineCount,
game.betAmount
);
if (game.revealedTiles.length === 25 - game.mineCount) {
game.status = 'won';
const userId = Number(session.user.id);
const [userData] = await db
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const payout = game.betAmount * game.currentMultiplier;
const roundedPayout = Math.round(payout * 100000000) / 100000000;
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
await db
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
await redis.del(getSessionKey(sessionToken));
return json({
hitMine: false,
currentMultiplier: game.currentMultiplier,
status: 'won',
newBalance,
payout
});
}
await redis.set(getSessionKey(sessionToken), JSON.stringify(game));
return json({
hitMine: false,
currentMultiplier: game.currentMultiplier,
status: game.status
});
} catch (e) {
console.error('Mines reveal error:', e);
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
return json({ error: errorMessage }, { status: 400 });
}
};

View file

@ -0,0 +1,100 @@
import { auth } from '$lib/auth';
import { error, json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { user } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import { redis } from '$lib/server/redis';
import { getSessionKey } from '$lib/server/games/mines';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
const session = await auth.api.getSession({
headers: request.headers
});
if (!session?.user) {
throw error(401, 'Not authenticated');
}
try {
const { betAmount, mineCount } = await request.json();
const userId = Number(session.user.id);
if (!betAmount || betAmount <= 0 || !mineCount || mineCount < 3 || mineCount > 24) {
return json({ error: 'Invalid bet amount or mine count' }, { status: 400 });
}
if (betAmount > 1000000) {
return json({ error: 'Bet amount too large' }, { status: 400 });
}
const result = await db.transaction(async (tx) => {
const [userData] = await tx
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
.from(user)
.where(eq(user.id, userId))
.for('update')
.limit(1);
const currentBalance = Number(userData.baseCurrencyBalance);
const roundedAmount = Math.round(betAmount * 100000000) / 100000000;
const roundedBalance = Math.round(currentBalance * 100000000) / 100000000;
if (roundedAmount > roundedBalance) {
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
}
// Generate mine positions
const positions = new Set<number>();
while (positions.size < mineCount) {
positions.add(Math.floor(Math.random() * 25));
}
// transaction token for authentication stuff
const randomBytes = new Uint8Array(8);
crypto.getRandomValues(randomBytes);
const sessionToken = Array.from(randomBytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
const now = Date.now();
const newBalance = roundedBalance - roundedAmount;
await redis.set(
getSessionKey(sessionToken),
JSON.stringify({
sessionToken,
betAmount: roundedAmount,
mineCount,
minePositions: Array.from(positions),
revealedTiles: [],
startTime: now,
lastActivity: now,
currentMultiplier: 1,
status: 'active',
userId
})
);
// Update user balance
await tx
.update(user)
.set({
baseCurrencyBalance: newBalance.toFixed(8),
updatedAt: new Date()
})
.where(eq(user.id, userId));
return {
sessionToken,
newBalance
};
});
return json(result);
} catch (e) {
console.error('Mines start error:', e);
const errorMessage = e instanceof Error ? e.message : 'Internal server error';
return json({ error: errorMessage }, { status: 400 });
}
};

View file

@ -1,6 +1,7 @@
<script lang="ts">
import Coinflip from '$lib/components/self/games/Coinflip.svelte';
import Slots from '$lib/components/self/games/Slots.svelte';
import Mines from '$lib/components/self/games/Mines.svelte';
import { USER_DATA } from '$lib/stores/user-data';
import { PORTFOLIO_SUMMARY, fetchPortfolioSummary } from '$lib/stores/portfolio-data';
import { onMount } from 'svelte';
@ -8,6 +9,7 @@
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
import { Button } from '$lib/components/ui/button';
import SEO from '$lib/components/self/SEO.svelte';
import Dice from '$lib/components/self/games/Dice.svelte'
let shouldSignIn = $state(false);
let balance = $state(0);
@ -39,14 +41,12 @@
<SEO
title="Gambling - Rugplay"
description="Play virtual gambling games with simulated currency in Rugplay. Try coinflip and slots games using virtual money with no real-world value - purely for entertainment."
keywords="virtual gambling simulation, coinflip game, slots game, virtual casino, simulated gambling, entertainment games"
description="Play virtual gambling games with simulated currency in Rugplay. Try coinflip, slots, and mines games using virtual money with no real-world value - purely for entertainment."
keywords="virtual gambling simulation, coinflip game, slots game, mines game, virtual casino, simulated gambling, entertainment games"
/>
<SignInConfirmDialog bind:open={shouldSignIn} />
<div class="container mx-auto max-w-4xl p-6">
<h1 class="mb-6 text-center text-3xl font-bold">Gambling</h1>
@ -54,7 +54,7 @@
<div class="flex h-96 items-center justify-center">
<div class="text-center">
<div class="text-muted-foreground mb-4 text-xl">Sign in to start gambling</div>
<p class="text-muted-foreground mb-4 text-sm">You need an account to place bets</p>
<p class="text-muted-foreground mb-4 text-sm">You need an account to gamble away your life savings</p>
<Button onclick={() => (shouldSignIn = true)}>Sign In</Button>
</div>
</div>
@ -73,6 +73,18 @@
>
Slots
</Button>
<Button
variant={activeGame === 'mines' ? 'default' : 'outline'}
onclick={() => (activeGame = 'mines')}
>
Mines
</Button>
<Button
variant={activeGame === 'dice' ? 'default' : 'outline'}
onclick={() => (activeGame = 'dice')}
>
Dice
</Button>
</div>
<!-- Game Content -->
@ -80,6 +92,10 @@
<Coinflip bind:balance onBalanceUpdate={handleBalanceUpdate} />
{:else if activeGame === 'slots'}
<Slots bind:balance onBalanceUpdate={handleBalanceUpdate} />
{:else if activeGame === 'mines'}
<Mines bind:balance onBalanceUpdate={handleBalanceUpdate} />
{:else if activeGame === 'dice'}
<Dice bind:balance onBalanceUpdate={handleBalanceUpdate} />
{/if}
{/if}
</div>

Binary file not shown.