Merge branch 'main' of https://github.com/MD1125/rugplay into pr/77
This commit is contained in:
commit
789fc7cc69
43 changed files with 6943 additions and 153 deletions
|
|
@ -42,8 +42,7 @@ export const auth = betterAuth({
|
|||
s3ImageKey = await uploadProfilePicture(
|
||||
profile.sub,
|
||||
new Uint8Array(arrayBuffer),
|
||||
blob.type,
|
||||
blob.size
|
||||
blob.type || 'image/jpeg'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@
|
|||
Hammer,
|
||||
BookOpen,
|
||||
Info,
|
||||
Bell
|
||||
Bell,
|
||||
Crown
|
||||
} from 'lucide-svelte';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
|
@ -85,7 +86,7 @@
|
|||
|
||||
function handleModeToggle() {
|
||||
setMode(mode.current === 'light' ? 'dark' : 'light');
|
||||
setOpenMobile(false);
|
||||
// Remove setOpenMobile(false) to keep menu open
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
|
|
@ -152,6 +153,11 @@
|
|||
showUserManual = true;
|
||||
setOpenMobile(false);
|
||||
}
|
||||
|
||||
function handlePrestigeClick() {
|
||||
goto('/prestige');
|
||||
setOpenMobile(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||
|
|
@ -195,22 +201,6 @@
|
|||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton>
|
||||
{#snippet child({ props }: { props: MenuButtonProps })}
|
||||
<button onclick={handleModeToggle} {...props}>
|
||||
{#if mode.current === 'light'}
|
||||
<Moon class="h-5 w-5" />
|
||||
<span>Dark Mode</span>
|
||||
{:else}
|
||||
<Sun class="h-5 w-5" />
|
||||
<span>Light Mode</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
|
|
@ -421,6 +411,8 @@
|
|||
</div>
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<!-- Profile & Settings Group -->
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item onclick={handleAccountClick}>
|
||||
<User />
|
||||
|
|
@ -430,10 +422,16 @@
|
|||
<Settings />
|
||||
Settings
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleUserManualClick}>
|
||||
<BookOpen />
|
||||
User Manual
|
||||
<DropdownMenu.Item onclick={handlePrestigeClick}>
|
||||
<Crown />
|
||||
Prestige
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<!-- Features Group -->
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
showPromoCode = true;
|
||||
|
|
@ -443,10 +441,24 @@
|
|||
<Gift />
|
||||
Promo code
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleUserManualClick}>
|
||||
<BookOpen />
|
||||
User Manual
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleModeToggle}>
|
||||
{#if mode.current === 'light'}
|
||||
<Moon />
|
||||
Dark Mode
|
||||
{:else}
|
||||
<Sun />
|
||||
Light Mode
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
{#if $USER_DATA?.isAdmin}
|
||||
<DropdownMenu.Separator />
|
||||
<!-- Admin Group -->
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item
|
||||
onclick={handleAdminClick}
|
||||
|
|
@ -471,8 +483,11 @@
|
|||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<!-- Legal Group -->
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleTermsClick}>
|
||||
<Scale />
|
||||
Terms of Service
|
||||
|
|
@ -482,7 +497,10 @@
|
|||
Privacy Policy
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<!-- Sign Out -->
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
signOut().then(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { UserProfile } from '$lib/types/user-profile';
|
||||
import SilentBadge from './SilentBadge.svelte';
|
||||
import { Hash, Hammer, Flame } from 'lucide-svelte';
|
||||
import { Hash, Hammer, Flame, Star } from 'lucide-svelte';
|
||||
import { getPrestigeName, getPrestigeColor } from '$lib/utils';
|
||||
|
||||
let {
|
||||
user,
|
||||
|
|
@ -14,14 +15,23 @@
|
|||
} = $props();
|
||||
|
||||
let badgeClass = $derived(size === 'sm' ? 'text-xs' : '');
|
||||
let prestigeName = $derived(user.prestigeLevel ? getPrestigeName(user.prestigeLevel) : null);
|
||||
let prestigeColor = $derived(user.prestigeLevel ? getPrestigeColor(user.prestigeLevel) : 'text-gray-500');
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#if showId}
|
||||
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
|
||||
{/if}
|
||||
{#if prestigeName}
|
||||
<SilentBadge icon={Star} text={prestigeName} class="{prestigeColor} {badgeClass}" />
|
||||
{/if}
|
||||
{#if user.loginStreak && user.loginStreak > 1}
|
||||
<SilentBadge icon={Flame} text="{user.loginStreak} day streak" class="text-orange-500 {badgeClass}" />
|
||||
<SilentBadge
|
||||
icon={Flame}
|
||||
text="{user.loginStreak} day streak"
|
||||
class="text-orange-500 {badgeClass}"
|
||||
/>
|
||||
{/if}
|
||||
{#if user.isAdmin}
|
||||
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />
|
||||
|
|
|
|||
|
|
@ -314,8 +314,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portfolio/summary');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch portfolio summary');
|
||||
}
|
||||
const data = await response.json();
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
480
website/src/lib/components/self/games/Dice.svelte
Normal file
480
website/src/lib/components/self/games/Dice.svelte
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
<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';
|
||||
|
||||
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 getRandInt(from: number, to: number): number {
|
||||
return Math.round(Math.random() * (to - from)) + from;
|
||||
}
|
||||
|
||||
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 diceRotation = $state({ x: 0, y: 0 });
|
||||
let lastResult = $state<DiceResult | null>(null);
|
||||
let activeSoundTimeouts = $state<NodeJS.Timeout[]>([]);
|
||||
|
||||
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; // Increase / Decrease to make the Spin faster or slower
|
||||
const animationDuration = 1500; // Duration of the Animation, keep it like thatif you haven't added your own sound in website\static\sound\dice.mp3
|
||||
|
||||
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');
|
||||
const diceElement = document.querySelector('.dice') as HTMLElement;
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
|
||||
function getDotsForFace(face: number): number { // Could be made bigger, has no Point though Ig.
|
||||
switch (face) {
|
||||
case 1: return 1;
|
||||
case 2: return 2;
|
||||
case 3: return 3;
|
||||
case 4: return 4;
|
||||
case 5: return 5;
|
||||
case 6: return 6;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Dynmaically fetch the correct balance.
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portfolio/summary');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch portfolio summary');
|
||||
}
|
||||
const data = await response.json();
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
</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">
|
||||
{#each Array(6) as _, i}
|
||||
<div class="face" style="transform: {getFaceTransform(i + 1)}">
|
||||
<div class="dot-container">
|
||||
{#each Array(getDotsForFace(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;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.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%;
|
||||
box-sizing: border-box;
|
||||
gap: 10%;
|
||||
}
|
||||
|
||||
.dot {
|
||||
background: #363131;
|
||||
border-radius: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.face:nth-child(1) .dot-container {
|
||||
grid-template-areas:
|
||||
". . ."
|
||||
". dot ."
|
||||
". . .";
|
||||
}
|
||||
.face:nth-child(1) .dot {
|
||||
grid-area: dot;
|
||||
}
|
||||
|
||||
.face:nth-child(2) .dot-container {
|
||||
grid-template-areas:
|
||||
"dot1 . ."
|
||||
". . ."
|
||||
". . dot2";
|
||||
}
|
||||
.face:nth-child(2) .dot:nth-child(1) { grid-area: dot1; }
|
||||
.face:nth-child(2) .dot:nth-child(2) { grid-area: dot2; }
|
||||
|
||||
.face:nth-child(3) .dot-container {
|
||||
grid-template-areas:
|
||||
"dot1 . ."
|
||||
". dot2 ."
|
||||
". . dot3";
|
||||
}
|
||||
.face:nth-child(3) .dot:nth-child(1) { grid-area: dot1; }
|
||||
.face:nth-child(3) .dot:nth-child(2) { grid-area: dot2; }
|
||||
.face:nth-child(3) .dot:nth-child(3) { grid-area: dot3; }
|
||||
|
||||
.face:nth-child(4) .dot-container {
|
||||
grid-template-areas:
|
||||
"dot1 . dot2"
|
||||
". . ."
|
||||
"dot3 . dot4";
|
||||
}
|
||||
.face:nth-child(4) .dot:nth-child(1) { grid-area: dot1; }
|
||||
.face:nth-child(4) .dot:nth-child(2) { grid-area: dot2; }
|
||||
.face:nth-child(4) .dot:nth-child(3) { grid-area: dot3; }
|
||||
.face:nth-child(4) .dot:nth-child(4) { grid-area: dot4; }
|
||||
|
||||
.face:nth-child(5) .dot-container {
|
||||
grid-template-areas:
|
||||
"dot1 . dot2"
|
||||
". dot3 ."
|
||||
"dot4 . dot5";
|
||||
}
|
||||
.face:nth-child(5) .dot:nth-child(1) { grid-area: dot1; }
|
||||
.face:nth-child(5) .dot:nth-child(2) { grid-area: dot2; }
|
||||
.face:nth-child(5) .dot:nth-child(3) { grid-area: dot3; }
|
||||
.face:nth-child(5) .dot:nth-child(4) { grid-area: dot4; }
|
||||
.face:nth-child(5) .dot:nth-child(5) { grid-area: dot5; }
|
||||
|
||||
.face:nth-child(6) .dot-container {
|
||||
grid-template-areas:
|
||||
"dot1 . dot2"
|
||||
"dot3 . dot4"
|
||||
"dot5 . dot6";
|
||||
}
|
||||
.face:nth-child(6) .dot:nth-child(1) { grid-area: dot1; }
|
||||
.face:nth-child(6) .dot:nth-child(2) { grid-area: dot2; }
|
||||
.face:nth-child(6) .dot:nth-child(3) { grid-area: dot3; }
|
||||
.face:nth-child(6) .dot:nth-child(4) { grid-area: dot4; }
|
||||
.face:nth-child(6) .dot:nth-child(5) { grid-area: dot5; }
|
||||
.face:nth-child(6) .dot:nth-child(6) { grid-area: dot6; }
|
||||
</style>
|
||||
|
|
@ -132,6 +132,7 @@
|
|||
}
|
||||
if (autoCashoutTimer >= AUTO_CASHOUT_TIME) {
|
||||
isAutoCashout = true;
|
||||
clearInterval(autoCashoutInterval);
|
||||
cashOut();
|
||||
}
|
||||
}, 100);
|
||||
|
|
@ -279,8 +280,21 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Dynmaically fetch the correct balance.
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portfolio/summary');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch portfolio summary');
|
||||
}
|
||||
const data = await response.json();
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -313,7 +327,7 @@
|
|||
<button
|
||||
class="mine-tile"
|
||||
class:revealed={revealedTiles.includes(index)}
|
||||
class:mine={revealedTiles.includes(index) && minePositions.includes(index) && index === lastClickedTile}
|
||||
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')}
|
||||
onclick={() => handleTileClick(index)}
|
||||
|
|
@ -596,4 +610,4 @@
|
|||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
@ -211,8 +211,21 @@
|
|||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
// Dynmaically fetch the correct balance.
|
||||
onMount(async () => {
|
||||
volumeSettings.load();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/portfolio/summary');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch portfolio summary');
|
||||
}
|
||||
const data = await response.json();
|
||||
balance = data.baseCurrencyBalance;
|
||||
onBalanceUpdate?.(data.baseCurrencyBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import * as Avatar from '$lib/components/ui/avatar';
|
||||
import { Star } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<!-- Main Content Column -->
|
||||
<div class="flex flex-col lg:col-span-2">
|
||||
<!-- How Card Skeleton -->
|
||||
<Card.Root class="mb-6 gap-1">
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="text-base">How</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="bg-primary/10 text-primary flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-sm font-medium"
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-full" />
|
||||
<Skeleton class="h-3 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Progress Card Skeleton -->
|
||||
<Card.Root class="flex flex-1 flex-col gap-1">
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Star class="h-5 w-5" />
|
||||
Progress
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-1 flex-col space-y-6">
|
||||
<!-- Progress Section -->
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-4 w-12" />
|
||||
</div>
|
||||
<Skeleton class="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
|
||||
<!-- Financial Details Table Skeleton -->
|
||||
<div class="overflow-hidden rounded-xl border">
|
||||
<table class="w-full text-sm">
|
||||
<tbody class="divide-y">
|
||||
{#each Array(3) as _}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<Skeleton class="ml-auto h-4 w-24" />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton class="h-4 w-40" />
|
||||
|
||||
<!-- Prestige Button Skeleton -->
|
||||
<Skeleton class="h-12 w-full rounded-lg" />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Info -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<!-- Profile Preview Card Skeleton -->
|
||||
<Card.Root class="flex-1 gap-1">
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="text-base">Preview</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Current Profile -->
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-3 w-12" />
|
||||
<div class="flex items-center gap-3 rounded-lg border p-3">
|
||||
<Avatar.Root class="h-10 w-10 shrink-0">
|
||||
<Avatar.Fallback>
|
||||
<Skeleton class="h-full w-full rounded-full" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</div>
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prestige Preview -->
|
||||
<div class="space-y-2">
|
||||
<Skeleton class="h-3 w-10" />
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border-2 border-yellow-500/30 bg-yellow-50/50 p-3 dark:bg-yellow-950/20"
|
||||
>
|
||||
<Avatar.Root class="h-10 w-10 shrink-0">
|
||||
<Avatar.Fallback>
|
||||
<Skeleton class="h-full w-full rounded-full" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- All Prestige Levels Skeleton -->
|
||||
<Card.Root class="flex-1 gap-1">
|
||||
<Card.Header class="pb-2">
|
||||
<Card.Title class="text-base">Levels</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-1">
|
||||
{#each Array(5) as _}
|
||||
<div class="flex items-center justify-between py-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
{/each}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
7
website/src/lib/components/ui/progress/index.ts
Normal file
7
website/src/lib/components/ui/progress/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
27
website/src/lib/components/ui/progress/progress.svelte
Normal file
27
website/src/lib/components/ui/progress/progress.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { Progress as ProgressPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
max = 100,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<ProgressPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="progress"
|
||||
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
|
||||
{value}
|
||||
{max}
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
data-slot="progress-indicator"
|
||||
class="bg-primary h-full w-full flex-1 transition-all"
|
||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
||||
></div>
|
||||
</ProgressPrimitive.Root>
|
||||
|
|
@ -31,7 +31,8 @@ export const user = pgTable("user", {
|
|||
precision: 20,
|
||||
scale: 8,
|
||||
}).notNull().default("0.00000000"),
|
||||
loginStreak: integer("login_streak").notNull().default(0)
|
||||
loginStreak: integer("login_streak").notNull().default(0),
|
||||
prestigeLevel: integer("prestige_level").default(0),
|
||||
});
|
||||
|
||||
export const session = pgTable("session", {
|
||||
|
|
@ -190,6 +191,7 @@ export const predictionQuestion = pgTable("prediction_question", {
|
|||
creatorIdIdx: index("prediction_question_creator_id_idx").on(table.creatorId),
|
||||
statusIdx: index("prediction_question_status_idx").on(table.status),
|
||||
resolutionDateIdx: index("prediction_question_resolution_date_idx").on(table.resolutionDate),
|
||||
statusResolutionIdx: index("prediction_question_status_resolution_idx").on(table.status, table.resolutionDate),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,17 +19,13 @@ interface MinesSession {
|
|||
|
||||
export const activeGames = new Map<string, MinesSession>();
|
||||
|
||||
// Clean up old games every minute.
|
||||
// Clean up old games every minute. (5 Minute system)
|
||||
setInterval(async () => {
|
||||
const now = Date.now();
|
||||
for (const [token, game] of activeGames.entries()) {
|
||||
// Delete games older than 5 minutes that are still there for some reason.
|
||||
if (now - game.lastActivity > 5 * 60 * 1000) {
|
||||
// If no tiles were revealed, refund the bet
|
||||
if (game.revealedTiles.length === 0) {
|
||||
try {
|
||||
console.log(`Processing refund for inactive Mines game ${token} (User: ${game.userId}, Bet: ${game.betAmount})`);
|
||||
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
|
|
@ -48,29 +44,74 @@ setInterval(async () => {
|
|||
})
|
||||
.where(eq(user.id, game.userId));
|
||||
|
||||
console.log(`Successfully refunded ${game.betAmount} to user ${game.userId}. New balance: ${newBalance}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to refund inactive game ${token}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.log(`Cleaning up inactive game ${token} (User: ${game.userId}) - No refund needed as tiles were revealed`);
|
||||
}
|
||||
activeGames.delete(token);
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
setInterval(async () => {
|
||||
const now = Date.now();
|
||||
for (const [token, game] of activeGames.entries()) {
|
||||
if (game.status === 'active' && game.revealedTiles.length > 0 && now - game.lastActivity > 20000) {
|
||||
try {
|
||||
const [userData] = await db
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, game.userId))
|
||||
.for('update')
|
||||
.limit(1);
|
||||
|
||||
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||
const payout = game.betAmount * game.currentMultiplier;
|
||||
const roundedPayout = Math.round(payout * 100000000) / 100000000;
|
||||
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(user.id, game.userId));
|
||||
|
||||
activeGames.delete(token);
|
||||
} catch (error) {
|
||||
console.error(`Failed to auto cashout game ${token}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Rig the game...
|
||||
const getMaxPayout = (bet: number, picks: number): number => {
|
||||
const absoluteCap = 5_000_000; // never pay above this. Yeah, its rigged. Live with that :)
|
||||
const baseCap = 1.4; // 1.4x min multiplier, increase to, well, increase payouts
|
||||
const growthRate = 0.45; // cap curve sensitivity
|
||||
|
||||
// Cap increases with number of successful reveals
|
||||
const effectiveMultiplierCap = baseCap + Math.pow(picks, growthRate);
|
||||
const payoutCap = bet * effectiveMultiplierCap;
|
||||
|
||||
return Math.min(payoutCap, absoluteCap);
|
||||
const getMaxPayout = (bet: number, picks: number, mines: number): number => {
|
||||
const MAX_PAYOUT = 2_000_000; // Maximum payout cap of 2 million to not make linker too rich
|
||||
const HIGH_BET_THRESHOLD = 50_000;
|
||||
|
||||
const mineFactor = 1 + (mines / 25);
|
||||
const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor;
|
||||
|
||||
// For high bets, we stop linker from getting richer ¯\_(ツ)_/¯
|
||||
if (bet > HIGH_BET_THRESHOLD) {
|
||||
const betRatio = Math.pow(Math.min(1, (bet - HIGH_BET_THRESHOLD) / (MAX_PAYOUT - HIGH_BET_THRESHOLD)), 1);
|
||||
|
||||
// Direct cap on multiplier for high bets
|
||||
const maxAllowedMultiplier = 1.05 + (picks * 0.1);
|
||||
const highBetMultiplier = Math.min(baseMultiplier, maxAllowedMultiplier) * (1 - (bet / MAX_PAYOUT) * 0.9);
|
||||
const betSizeFactor = Math.max(0.1, 1 - (bet / MAX_PAYOUT) * 0.9);
|
||||
const minMultiplier = (1.1 + (picks * 0.15 * betSizeFactor)) * mineFactor;
|
||||
|
||||
const reducedMultiplier = highBetMultiplier - ((highBetMultiplier - minMultiplier) * betRatio);
|
||||
const payout = Math.min(bet * reducedMultiplier, MAX_PAYOUT);
|
||||
|
||||
return payout;
|
||||
}
|
||||
|
||||
const payout = Math.min(bet * baseMultiplier, MAX_PAYOUT);
|
||||
return payout;
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -78,6 +119,7 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num
|
|||
const TOTAL_TILES = 25;
|
||||
const HOUSE_EDGE = 0.05;
|
||||
|
||||
// Calculate probability of winning based on picks and mines
|
||||
let probability = 1;
|
||||
for (let i = 0; i < picks; i++) {
|
||||
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - i);
|
||||
|
|
@ -85,14 +127,15 @@ export function calculateMultiplier(picks: number, mines: number, betAmount: num
|
|||
|
||||
if (probability <= 0) return 1.0;
|
||||
|
||||
// Calculate fair multiplier based on probability and house edge
|
||||
const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE);
|
||||
|
||||
const rawPayout = fairMultiplier * betAmount;
|
||||
|
||||
const maxPayout = getMaxPayout(betAmount, picks);
|
||||
const maxPayout = getMaxPayout(betAmount, picks, mines);
|
||||
const cappedPayout = Math.min(rawPayout, maxPayout);
|
||||
const effectiveMultiplier = cappedPayout / betAmount;
|
||||
|
||||
return Math.max(1.0, effectiveMultiplier);
|
||||
return Math.max(1.0, Number(effectiveMultiplier.toFixed(2)));
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
39
website/src/lib/server/image.ts
Normal file
39
website/src/lib/server/image.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import sharp from 'sharp';
|
||||
|
||||
const MAX_SIZE = 128;
|
||||
const WEBP_QUALITY = 50;
|
||||
|
||||
export interface ProcessedImage {
|
||||
buffer: Buffer;
|
||||
contentType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function processImage(
|
||||
inputBuffer: Buffer,
|
||||
): Promise<ProcessedImage> {
|
||||
try {
|
||||
const image = sharp(inputBuffer, { animated: true });
|
||||
|
||||
const processedBuffer = await image
|
||||
.resize(MAX_SIZE, MAX_SIZE, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.webp({
|
||||
quality: WEBP_QUALITY,
|
||||
effort: 6
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: processedBuffer,
|
||||
contentType: 'image/webp',
|
||||
size: processedBuffer.length
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Image processing failed:', error);
|
||||
throw new Error('Failed to process image');
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,91 @@ export async function resolveExpiredQuestions() {
|
|||
);
|
||||
|
||||
if (resolution.confidence < 50) {
|
||||
console.log(`Skipping question ${question.id} due to low confidence: ${resolution.confidence}`);
|
||||
console.log(`Cancelling question ${question.id} due to low confidence: ${resolution.confidence}`);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Mark question as cancelled
|
||||
await tx
|
||||
.update(predictionQuestion)
|
||||
.set({
|
||||
status: 'CANCELLED',
|
||||
resolvedAt: now,
|
||||
})
|
||||
.where(eq(predictionQuestion.id, question.id));
|
||||
|
||||
// Get all bets for this question
|
||||
const bets = await tx
|
||||
.select({
|
||||
id: predictionBet.id,
|
||||
userId: predictionBet.userId,
|
||||
side: predictionBet.side,
|
||||
amount: predictionBet.amount,
|
||||
})
|
||||
.from(predictionBet)
|
||||
.where(and(
|
||||
eq(predictionBet.questionId, question.id),
|
||||
isNull(predictionBet.settledAt)
|
||||
));
|
||||
|
||||
const notificationsToCreate: Array<{
|
||||
userId: number;
|
||||
amount: number;
|
||||
}> = [];
|
||||
|
||||
// Refund all bets
|
||||
for (const bet of bets) {
|
||||
const refundAmount = Number(bet.amount);
|
||||
|
||||
// Mark bet as settled with full refund
|
||||
await tx
|
||||
.update(predictionBet)
|
||||
.set({
|
||||
actualWinnings: refundAmount.toFixed(8),
|
||||
settledAt: now,
|
||||
})
|
||||
.where(eq(predictionBet.id, bet.id));
|
||||
|
||||
// Refund the user
|
||||
if (bet.userId !== null) {
|
||||
const [userData] = await tx
|
||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||
.from(user)
|
||||
.where(eq(user.id, bet.userId))
|
||||
.limit(1);
|
||||
|
||||
if (userData) {
|
||||
const newBalance = Number(userData.baseCurrencyBalance) + refundAmount;
|
||||
await tx
|
||||
.update(user)
|
||||
.set({
|
||||
baseCurrencyBalance: newBalance.toFixed(8),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(user.id, bet.userId));
|
||||
}
|
||||
|
||||
notificationsToCreate.push({
|
||||
userId: bet.userId,
|
||||
amount: refundAmount
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create refund notifications for all users who had bets
|
||||
for (const notifData of notificationsToCreate) {
|
||||
const { userId, amount } = notifData;
|
||||
|
||||
const title = 'Prediction skipped 🥀';
|
||||
const message = `You received a full refund of ${formatValue(amount)} for "${question.question}". We recommend betting on more reliable predictions!`;
|
||||
|
||||
await createNotification(
|
||||
userId.toString(),
|
||||
'HOPIUM',
|
||||
title,
|
||||
message,
|
||||
);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -139,7 +223,6 @@ export async function resolveExpiredQuestions() {
|
|||
}
|
||||
});
|
||||
|
||||
console.log(`Successfully resolved question ${question.id}: ${resolution.resolution ? 'YES' : 'NO'} (confidence: ${resolution.confidence}%)`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to resolve question ${question.id}:`, error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { S3Client, GetObjectCommand, PutObjectCommand, DeleteObjectCommand } fro
|
|||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { PRIVATE_B2_KEY_ID, PRIVATE_B2_APP_KEY } from '$env/static/private';
|
||||
import { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT, PUBLIC_B2_REGION } from '$env/static/public';
|
||||
import { processImage } from './image.js';
|
||||
|
||||
const s3Client = new S3Client({
|
||||
endpoint: PUBLIC_B2_ENDPOINT,
|
||||
|
|
@ -47,7 +48,6 @@ export async function uploadProfilePicture(
|
|||
identifier: string, // Can be user ID or a unique ID from social provider
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
contentLength?: number
|
||||
): Promise<string> {
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
throw new Error('Invalid file type. Only images are allowed.');
|
||||
|
|
@ -58,17 +58,16 @@ export async function uploadProfilePicture(
|
|||
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
|
||||
}
|
||||
|
||||
let fileExtension = contentType.split('/')[1];
|
||||
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
||||
|
||||
const key = `avatars/${identifier}.${fileExtension}`;
|
||||
const processedImage = await processImage(Buffer.from(body));
|
||||
|
||||
const key = `avatars/${identifier}.webp`;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: PUBLIC_B2_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
...(contentLength && { ContentLength: contentLength }),
|
||||
Body: processedImage.buffer,
|
||||
ContentType: processedImage.contentType,
|
||||
ContentLength: processedImage.size,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
|
|
@ -79,7 +78,6 @@ export async function uploadCoinIcon(
|
|||
coinSymbol: string,
|
||||
body: Uint8Array,
|
||||
contentType: string,
|
||||
contentLength?: number
|
||||
): Promise<string> {
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
throw new Error('Invalid file type. Only images are allowed.');
|
||||
|
|
@ -90,17 +88,16 @@ export async function uploadCoinIcon(
|
|||
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
|
||||
}
|
||||
|
||||
let fileExtension = contentType.split('/')[1];
|
||||
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
||||
const processedImage = await processImage(Buffer.from(body));
|
||||
|
||||
const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`;
|
||||
const key = `coins/${coinSymbol.toLowerCase()}.webp`;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: PUBLIC_B2_BUCKET,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType,
|
||||
...(contentLength && { ContentLength: contentLength }),
|
||||
Body: processedImage.buffer,
|
||||
ContentType: processedImage.contentType,
|
||||
ContentLength: processedImage.size,
|
||||
});
|
||||
|
||||
await s3Client.send(command);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export type User = {
|
|||
|
||||
volumeMaster: number;
|
||||
volumeMuted: boolean;
|
||||
|
||||
prestigeLevel: number;
|
||||
} | null;
|
||||
|
||||
export const USER_DATA = writable<User>(undefined);
|
||||
|
|
@ -9,6 +9,8 @@ export interface UserProfile {
|
|||
isAdmin: boolean;
|
||||
totalPortfolioValue: number;
|
||||
loginStreak: number;
|
||||
|
||||
prestigeLevel: number | null;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
|
|
|
|||
|
|
@ -335,4 +335,50 @@ export const formatMarketCap = formatValue;
|
|||
export function timeToLocal(originalTime: number): number {
|
||||
const d = new Date(originalTime * 1000);
|
||||
return Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
export const PRESTIGE_COSTS = {
|
||||
1: 100_000,
|
||||
2: 250_000,
|
||||
3: 1_000_000,
|
||||
4: 5_000_000,
|
||||
5: 25_000_000
|
||||
} as const;
|
||||
|
||||
export const PRESTIGE_NAMES = {
|
||||
1: 'Prestige I',
|
||||
2: 'Prestige II',
|
||||
3: 'Prestige III',
|
||||
4: 'Prestige IV',
|
||||
5: 'Prestige V'
|
||||
} as const;
|
||||
|
||||
export const PRESTIGE_COLORS = {
|
||||
1: 'text-blue-500',
|
||||
2: 'text-purple-500',
|
||||
3: 'text-yellow-500',
|
||||
4: 'text-orange-500',
|
||||
5: 'text-red-500'
|
||||
} as const;
|
||||
|
||||
export function getPrestigeName(level: number): string | null {
|
||||
if (level <= 0) return null;
|
||||
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_NAMES;
|
||||
return PRESTIGE_NAMES[clampedLevel];
|
||||
}
|
||||
|
||||
export function getPrestigeCost(level: number): number | null {
|
||||
if (level <= 0) return null;
|
||||
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_COSTS;
|
||||
return PRESTIGE_COSTS[clampedLevel];
|
||||
}
|
||||
|
||||
export function getPrestigeColor(level: number): string {
|
||||
if (level <= 0) return 'text-gray-500';
|
||||
const clampedLevel = Math.min(level, 5) as keyof typeof PRESTIGE_COLORS;
|
||||
return PRESTIGE_COLORS[clampedLevel];
|
||||
}
|
||||
|
||||
export function getMaxPrestigeLevel(): number {
|
||||
return 5;
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue