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
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"name": "rugplay",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
|
|
@ -27,14 +27,14 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@internationalized/date": "^3.8.1",
|
"@internationalized/date": "^3.8.1",
|
||||||
"@lucide/svelte": "^0.482.0",
|
"@lucide/svelte": "^0.515.0",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bits-ui": "^2.5.0",
|
"bits-ui": "^2.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-kit": "^0.22.0",
|
"drizzle-kit": "^0.22.0",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.3.2",
|
||||||
|
|
@ -218,7 +218,7 @@
|
||||||
|
|
||||||
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
|
||||||
|
|
||||||
"@lucide/svelte": ["@lucide/svelte@0.482.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-n2ycHU9cNcleRDwwpEHBJ6pYzVhHIaL3a+9dQa8kns9hB2g05bY+v2p2KP8v0pZwtNhYTHk/F2o2uZ1bVtQGhw=="],
|
"@lucide/svelte": ["@lucide/svelte@0.515.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-CEAyqcZmNBfYzVgaRmK2RFJP5tnbXxekRyDk0XX/eZQRfsJmkDvmQwXNX8C869BgNeryzmrRyjHhUL6g9ZOHNA=="],
|
||||||
|
|
||||||
"@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="],
|
"@noble/ciphers": ["@noble/ciphers@0.6.0", "", {}, "sha512-mIbq/R9QXk5/cTfESb1OKtyFnk7oc1Om/8onA1158K9/OZUQFDEVy55jVTato+xmp3XX6F6Qh0zz0Nc1AxAlRQ=="],
|
||||||
|
|
||||||
|
|
@ -516,7 +516,7 @@
|
||||||
|
|
||||||
"better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="],
|
"better-call": ["better-call@1.0.9", "", { "dependencies": { "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-Qfm0gjk0XQz0oI7qvTK1hbqTsBY4xV2hsHAxF8LZfUYl3RaECCIifXuVqtPpZJWvlCCMlQSvkvhhyuApGUba6g=="],
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.5.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-PbjylA1UWd4A/c5AYqie/EVxQ1/8uugmJKLg9whLoBBHbfPEBGhK09dCPrahK9kA6DRHhMmij0XXIUGIfrmNow=="],
|
"bits-ui": ["bits-ui@2.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/dom": "^1.7.0", "css.escape": "^1.5.1", "esm-env": "^1.1.2", "runed": "^0.28.0", "svelte-toolbelt": "^0.9.1", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-2kjVSVBDt9SFnjFiA9vD74T5+UtAKWYpfOil9d4+7v0fSawvTNcMgOP05cD3FTJT/rarj78DjhaXdyTWIW6I/g=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||||
|
|
||||||
|
|
|
||||||
1
website/drizzle/0001_cuddly_dormammu.sql
Normal file
1
website/drizzle/0001_cuddly_dormammu.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
CREATE INDEX IF NOT EXISTS "prediction_question_status_resolution_idx" ON "prediction_question" USING btree ("status","resolution_date");
|
||||||
1
website/drizzle/0002_small_micromacro.sql
Normal file
1
website/drizzle/0002_small_micromacro.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "user" ADD COLUMN "prestige_level" integer DEFAULT 0;
|
||||||
1720
website/drizzle/meta/0001_snapshot.json
Normal file
1720
website/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1727
website/drizzle/meta/0002_snapshot.json
Normal file
1727
website/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,20 @@
|
||||||
"when": 1749654046953,
|
"when": 1749654046953,
|
||||||
"tag": "0000_crazy_bloodstrike",
|
"tag": "0000_crazy_bloodstrike",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749907594739,
|
||||||
|
"tag": "0001_cuddly_dormammu",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1749916220202,
|
||||||
|
"tag": "0002_small_micromacro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
1600
website/package-lock.json
generated
1600
website/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -58,6 +58,7 @@
|
||||||
"openai": "^4.103.0",
|
"openai": "^4.103.0",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"redis": "^5.1.0",
|
"redis": "^5.1.0",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
"svelte-apexcharts": "^1.0.2",
|
"svelte-apexcharts": "^1.0.2",
|
||||||
"svelte-confetti": "^2.3.1",
|
"svelte-confetti": "^2.3.1",
|
||||||
"svelte-lightweight-charts": "^2.2.0"
|
"svelte-lightweight-charts": "^2.2.0"
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,7 @@ export const auth = betterAuth({
|
||||||
s3ImageKey = await uploadProfilePicture(
|
s3ImageKey = await uploadProfilePicture(
|
||||||
profile.sub,
|
profile.sub,
|
||||||
new Uint8Array(arrayBuffer),
|
new Uint8Array(arrayBuffer),
|
||||||
blob.type,
|
blob.type || 'image/jpeg'
|
||||||
blob.size
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@
|
||||||
Hammer,
|
Hammer,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Info,
|
Info,
|
||||||
Bell
|
Bell,
|
||||||
|
Crown
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
@ -85,7 +86,7 @@
|
||||||
|
|
||||||
function handleModeToggle() {
|
function handleModeToggle() {
|
||||||
setMode(mode.current === 'light' ? 'dark' : 'light');
|
setMode(mode.current === 'light' ? 'dark' : 'light');
|
||||||
setOpenMobile(false);
|
// Remove setOpenMobile(false) to keep menu open
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCurrency(value: number): string {
|
function formatCurrency(value: number): string {
|
||||||
|
|
@ -152,6 +153,11 @@
|
||||||
showUserManual = true;
|
showUserManual = true;
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePrestigeClick() {
|
||||||
|
goto('/prestige');
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SignInConfirmDialog bind:open={shouldSignIn} />
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
|
@ -195,22 +201,6 @@
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
{/each}
|
{/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.Menu>
|
||||||
</Sidebar.GroupContent>
|
</Sidebar.GroupContent>
|
||||||
</Sidebar.Group>
|
</Sidebar.Group>
|
||||||
|
|
@ -421,6 +411,8 @@
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Profile & Settings Group -->
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item onclick={handleAccountClick}>
|
<DropdownMenu.Item onclick={handleAccountClick}>
|
||||||
<User />
|
<User />
|
||||||
|
|
@ -430,10 +422,16 @@
|
||||||
<Settings />
|
<Settings />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onclick={handleUserManualClick}>
|
<DropdownMenu.Item onclick={handlePrestigeClick}>
|
||||||
<BookOpen />
|
<Crown />
|
||||||
User Manual
|
Prestige
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Features Group -->
|
||||||
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
showPromoCode = true;
|
showPromoCode = true;
|
||||||
|
|
@ -443,10 +441,24 @@
|
||||||
<Gift />
|
<Gift />
|
||||||
Promo code
|
Promo code
|
||||||
</DropdownMenu.Item>
|
</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>
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
{#if $USER_DATA?.isAdmin}
|
{#if $USER_DATA?.isAdmin}
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
|
<!-- Admin Group -->
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={handleAdminClick}
|
onclick={handleAdminClick}
|
||||||
|
|
@ -471,8 +483,11 @@
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
{/if}
|
{/if}
|
||||||
<DropdownMenu.Group>
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Legal Group -->
|
||||||
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item onclick={handleTermsClick}>
|
<DropdownMenu.Item onclick={handleTermsClick}>
|
||||||
<Scale />
|
<Scale />
|
||||||
Terms of Service
|
Terms of Service
|
||||||
|
|
@ -482,7 +497,10 @@
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
|
<!-- Sign Out -->
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
signOut().then(() => {
|
signOut().then(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { UserProfile } from '$lib/types/user-profile';
|
import type { UserProfile } from '$lib/types/user-profile';
|
||||||
import SilentBadge from './SilentBadge.svelte';
|
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 {
|
let {
|
||||||
user,
|
user,
|
||||||
|
|
@ -14,14 +15,23 @@
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let badgeClass = $derived(size === 'sm' ? 'text-xs' : '');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{#if showId}
|
{#if showId}
|
||||||
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
|
<SilentBadge icon={Hash} class="text-muted-foreground {badgeClass}" text="#{user.id} to join" />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if prestigeName}
|
||||||
|
<SilentBadge icon={Star} text={prestigeName} class="{prestigeColor} {badgeClass}" />
|
||||||
|
{/if}
|
||||||
{#if user.loginStreak && user.loginStreak > 1}
|
{#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}
|
||||||
{#if user.isAdmin}
|
{#if user.isAdmin}
|
||||||
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />
|
<SilentBadge icon={Hammer} text="Admin" class="text-primary {badgeClass}" />
|
||||||
|
|
|
||||||
|
|
@ -314,8 +314,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
volumeSettings.load();
|
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>
|
</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) {
|
if (autoCashoutTimer >= AUTO_CASHOUT_TIME) {
|
||||||
isAutoCashout = true;
|
isAutoCashout = true;
|
||||||
|
clearInterval(autoCashoutInterval);
|
||||||
cashOut();
|
cashOut();
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
@ -279,8 +280,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
// Dynmaically fetch the correct balance.
|
||||||
|
onMount(async () => {
|
||||||
volumeSettings.load();
|
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(() => {
|
onDestroy(() => {
|
||||||
|
|
@ -313,7 +327,7 @@
|
||||||
<button
|
<button
|
||||||
class="mine-tile"
|
class="mine-tile"
|
||||||
class:revealed={revealedTiles.includes(index)}
|
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:safe={revealedTiles.includes(index) && !minePositions.includes(index) && clickedSafeTiles.includes(index)}
|
||||||
class:light={document.documentElement.classList.contains('light')}
|
class:light={document.documentElement.classList.contains('light')}
|
||||||
onclick={() => handleTileClick(index)}
|
onclick={() => handleTileClick(index)}
|
||||||
|
|
|
||||||
|
|
@ -211,8 +211,21 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
// Dynmaically fetch the correct balance.
|
||||||
|
onMount(async () => {
|
||||||
volumeSettings.load();
|
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>
|
</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,
|
precision: 20,
|
||||||
scale: 8,
|
scale: 8,
|
||||||
}).notNull().default("0.00000000"),
|
}).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", {
|
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),
|
creatorIdIdx: index("prediction_question_creator_id_idx").on(table.creatorId),
|
||||||
statusIdx: index("prediction_question_status_idx").on(table.status),
|
statusIdx: index("prediction_question_status_idx").on(table.status),
|
||||||
resolutionDateIdx: index("prediction_question_resolution_date_idx").on(table.resolutionDate),
|
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>();
|
export const activeGames = new Map<string, MinesSession>();
|
||||||
|
|
||||||
// Clean up old games every minute.
|
// Clean up old games every minute. (5 Minute system)
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [token, game] of activeGames.entries()) {
|
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 (now - game.lastActivity > 5 * 60 * 1000) {
|
||||||
// If no tiles were revealed, refund the bet
|
|
||||||
if (game.revealedTiles.length === 0) {
|
if (game.revealedTiles.length === 0) {
|
||||||
try {
|
try {
|
||||||
console.log(`Processing refund for inactive Mines game ${token} (User: ${game.userId}, Bet: ${game.betAmount})`);
|
|
||||||
|
|
||||||
const [userData] = await db
|
const [userData] = await db
|
||||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||||
.from(user)
|
.from(user)
|
||||||
|
|
@ -48,29 +44,74 @@ setInterval(async () => {
|
||||||
})
|
})
|
||||||
.where(eq(user.id, game.userId));
|
.where(eq(user.id, game.userId));
|
||||||
|
|
||||||
console.log(`Successfully refunded ${game.betAmount} to user ${game.userId}. New balance: ${newBalance}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to refund inactive game ${token}:`, 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);
|
activeGames.delete(token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 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...
|
// Rig the game...
|
||||||
const getMaxPayout = (bet: number, picks: number): number => {
|
const getMaxPayout = (bet: number, picks: number, mines: number): number => {
|
||||||
const absoluteCap = 5_000_000; // never pay above this. Yeah, its rigged. Live with that :)
|
const MAX_PAYOUT = 2_000_000; // Maximum payout cap of 2 million to not make linker too rich
|
||||||
const baseCap = 1.4; // 1.4x min multiplier, increase to, well, increase payouts
|
const HIGH_BET_THRESHOLD = 50_000;
|
||||||
const growthRate = 0.45; // cap curve sensitivity
|
|
||||||
|
|
||||||
// Cap increases with number of successful reveals
|
const mineFactor = 1 + (mines / 25);
|
||||||
const effectiveMultiplierCap = baseCap + Math.pow(picks, growthRate);
|
const baseMultiplier = (1.4 + Math.pow(picks, 0.45)) * mineFactor;
|
||||||
const payoutCap = bet * effectiveMultiplierCap;
|
|
||||||
|
|
||||||
return Math.min(payoutCap, absoluteCap);
|
// 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 TOTAL_TILES = 25;
|
||||||
const HOUSE_EDGE = 0.05;
|
const HOUSE_EDGE = 0.05;
|
||||||
|
|
||||||
|
// Calculate probability of winning based on picks and mines
|
||||||
let probability = 1;
|
let probability = 1;
|
||||||
for (let i = 0; i < picks; i++) {
|
for (let i = 0; i < picks; i++) {
|
||||||
probability *= (TOTAL_TILES - mines - i) / (TOTAL_TILES - 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;
|
if (probability <= 0) return 1.0;
|
||||||
|
|
||||||
|
// Calculate fair multiplier based on probability and house edge
|
||||||
const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE);
|
const fairMultiplier = (1 / probability) * (1 - HOUSE_EDGE);
|
||||||
const rawPayout = fairMultiplier * betAmount;
|
|
||||||
|
|
||||||
const maxPayout = getMaxPayout(betAmount, picks);
|
const rawPayout = fairMultiplier * betAmount;
|
||||||
|
const maxPayout = getMaxPayout(betAmount, picks, mines);
|
||||||
const cappedPayout = Math.min(rawPayout, maxPayout);
|
const cappedPayout = Math.min(rawPayout, maxPayout);
|
||||||
const effectiveMultiplier = cappedPayout / betAmount;
|
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) {
|
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;
|
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) {
|
} catch (error) {
|
||||||
console.error(`Failed to resolve question ${question.id}:`, 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 { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
import { PRIVATE_B2_KEY_ID, PRIVATE_B2_APP_KEY } from '$env/static/private';
|
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 { PUBLIC_B2_BUCKET, PUBLIC_B2_ENDPOINT, PUBLIC_B2_REGION } from '$env/static/public';
|
||||||
|
import { processImage } from './image.js';
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
endpoint: PUBLIC_B2_ENDPOINT,
|
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
|
identifier: string, // Can be user ID or a unique ID from social provider
|
||||||
body: Uint8Array,
|
body: Uint8Array,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
contentLength?: number
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!contentType || !contentType.startsWith('image/')) {
|
if (!contentType || !contentType.startsWith('image/')) {
|
||||||
throw new Error('Invalid file type. Only images are allowed.');
|
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.');
|
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileExtension = contentType.split('/')[1];
|
const processedImage = await processImage(Buffer.from(body));
|
||||||
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
|
||||||
|
|
||||||
const key = `avatars/${identifier}.${fileExtension}`;
|
const key = `avatars/${identifier}.webp`;
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: PUBLIC_B2_BUCKET,
|
Bucket: PUBLIC_B2_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: body,
|
Body: processedImage.buffer,
|
||||||
ContentType: contentType,
|
ContentType: processedImage.contentType,
|
||||||
...(contentLength && { ContentLength: contentLength }),
|
ContentLength: processedImage.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
await s3Client.send(command);
|
await s3Client.send(command);
|
||||||
|
|
@ -79,7 +78,6 @@ export async function uploadCoinIcon(
|
||||||
coinSymbol: string,
|
coinSymbol: string,
|
||||||
body: Uint8Array,
|
body: Uint8Array,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
contentLength?: number
|
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!contentType || !contentType.startsWith('image/')) {
|
if (!contentType || !contentType.startsWith('image/')) {
|
||||||
throw new Error('Invalid file type. Only images are allowed.');
|
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.');
|
throw new Error('Unsupported image format. Only JPEG, PNG, GIF, and WebP are allowed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileExtension = contentType.split('/')[1];
|
const processedImage = await processImage(Buffer.from(body));
|
||||||
if (fileExtension === 'jpeg') fileExtension = 'jpg';
|
|
||||||
|
|
||||||
const key = `coins/${coinSymbol.toLowerCase()}.${fileExtension}`;
|
const key = `coins/${coinSymbol.toLowerCase()}.webp`;
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
Bucket: PUBLIC_B2_BUCKET,
|
Bucket: PUBLIC_B2_BUCKET,
|
||||||
Key: key,
|
Key: key,
|
||||||
Body: body,
|
Body: processedImage.buffer,
|
||||||
ContentType: contentType,
|
ContentType: processedImage.contentType,
|
||||||
...(contentLength && { ContentLength: contentLength }),
|
ContentLength: processedImage.size,
|
||||||
});
|
});
|
||||||
|
|
||||||
await s3Client.send(command);
|
await s3Client.send(command);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ export type User = {
|
||||||
|
|
||||||
volumeMaster: number;
|
volumeMaster: number;
|
||||||
volumeMuted: boolean;
|
volumeMuted: boolean;
|
||||||
|
|
||||||
|
prestigeLevel: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
export const USER_DATA = writable<User>(undefined);
|
export const USER_DATA = writable<User>(undefined);
|
||||||
|
|
@ -9,6 +9,8 @@ export interface UserProfile {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
totalPortfolioValue: number;
|
totalPortfolioValue: number;
|
||||||
loginStreak: number;
|
loginStreak: number;
|
||||||
|
|
||||||
|
prestigeLevel: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserStats {
|
export interface UserStats {
|
||||||
|
|
|
||||||
|
|
@ -336,3 +336,49 @@ export function timeToLocal(originalTime: number): number {
|
||||||
const d = new Date(originalTime * 1000);
|
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);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,11 +95,7 @@
|
||||||
class="text-primary underline hover:cursor-pointer"
|
class="text-primary underline hover:cursor-pointer"
|
||||||
onclick={() => (shouldSignIn = !shouldSignIn)}>sign in</button
|
onclick={() => (shouldSignIn = !shouldSignIn)}>sign in</button
|
||||||
>
|
>
|
||||||
or{' '}
|
to play.
|
||||||
<button
|
|
||||||
class="text-primary underline hover:cursor-pointer"
|
|
||||||
onclick={() => (shouldSignIn = !shouldSignIn)}>create an account</button
|
|
||||||
> to play.
|
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,7 @@ async function handleIconUpload(iconFile: File | null, symbol: string): Promise<
|
||||||
return await uploadCoinIcon(
|
return await uploadCoinIcon(
|
||||||
symbol,
|
symbol,
|
||||||
new Uint8Array(arrayBuffer),
|
new Uint8Array(arrayBuffer),
|
||||||
iconFile.type,
|
iconFile.type
|
||||||
iconFile.size
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
87
website/src/routes/api/gambling/dice/+server.ts
Normal file
87
website/src/routes/api/gambling/dice/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -47,6 +47,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
await tx
|
await tx
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
|
|
@ -55,13 +56,15 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
})
|
})
|
||||||
.where(eq(user.id, userId));
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
|
||||||
activeGames.delete(sessionToken);
|
activeGames.delete(sessionToken);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
newBalance,
|
newBalance,
|
||||||
payout,
|
payout,
|
||||||
amountWagered: game.betAmount,
|
amountWagered: game.betAmount,
|
||||||
isAbort: game.revealedTiles.length === 0
|
isAbort: game.revealedTiles.length === 0,
|
||||||
|
minePositions: game.minePositions
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,35 +27,46 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
return json({ error: 'Tile already revealed' }, { status: 400 });
|
return json({ error: 'Tile already revealed' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last activity time
|
|
||||||
game.lastActivity = Date.now();
|
game.lastActivity = Date.now();
|
||||||
|
|
||||||
// Check if hit mine
|
|
||||||
|
|
||||||
if (game.minePositions.includes(tileIndex)) {
|
if (game.minePositions.includes(tileIndex)) {
|
||||||
game.status = 'lost';
|
game.status = 'lost';
|
||||||
const minePositions = game.minePositions;
|
const minePositions = game.minePositions;
|
||||||
|
|
||||||
// Fetch user balance to return after loss
|
|
||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
const [userData] = await db
|
const [userData] = await db
|
||||||
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||||
.from(user)
|
.from(user)
|
||||||
.where(eq(user.id, userId))
|
.where(eq(user.id, userId))
|
||||||
|
.for('update')
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
|
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||||
|
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: currentBalance.toFixed(8),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
activeGames.delete(sessionToken);
|
activeGames.delete(sessionToken);
|
||||||
|
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
hitMine: true,
|
hitMine: true,
|
||||||
minePositions,
|
minePositions,
|
||||||
newBalance: Number(userData.baseCurrencyBalance),
|
newBalance: currentBalance,
|
||||||
status: 'lost'
|
status: 'lost',
|
||||||
|
amountWagered: game.betAmount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Safe tile
|
// Safe tile (Yipeee)
|
||||||
game.revealedTiles.push(tileIndex);
|
game.revealedTiles.push(tileIndex);
|
||||||
game.currentMultiplier = calculateMultiplier(
|
game.currentMultiplier = calculateMultiplier(
|
||||||
game.revealedTiles.length,
|
game.revealedTiles.length,
|
||||||
|
|
@ -63,9 +74,38 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
game.betAmount
|
game.betAmount
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if all safe tiles are revealed. Crazy when you get this :)
|
|
||||||
if (game.revealedTiles.length === 25 - game.mineCount) {
|
if (game.revealedTiles.length === 25 - game.mineCount) {
|
||||||
game.status = 'won';
|
game.status = 'won';
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
const [userData] = await db
|
||||||
|
.select({ baseCurrencyBalance: user.baseCurrencyBalance })
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.for('update')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const currentBalance = Number(userData.baseCurrencyBalance);
|
||||||
|
const payout = game.betAmount * game.currentMultiplier;
|
||||||
|
const roundedPayout = Math.round(payout * 100000000) / 100000000;
|
||||||
|
const newBalance = Math.round((currentBalance + roundedPayout) * 100000000) / 100000000;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: newBalance.toFixed(8),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
activeGames.delete(sessionToken);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
hitMine: false,
|
||||||
|
currentMultiplier: game.currentMultiplier,
|
||||||
|
status: 'won',
|
||||||
|
newBalance,
|
||||||
|
payout
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
|
throw new Error(`Insufficient funds. You need *${roundedAmount.toFixed(2)} but only have *${roundedBalance.toFixed(2)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Generate mine positions
|
// Generate mine positions
|
||||||
const positions = new Set<number>();
|
const positions = new Set<number>();
|
||||||
while (positions.size < mineCount) {
|
while (positions.size < mineCount) {
|
||||||
|
|
@ -53,7 +54,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
if (!positions.has(i)) safePositions.push(i);
|
if (!positions.has(i)) safePositions.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// transaction token for authentication
|
// transaction token for authentication stuff
|
||||||
const randomBytes = new Uint8Array(8);
|
const randomBytes = new Uint8Array(8);
|
||||||
crypto.getRandomValues(randomBytes);
|
crypto.getRandomValues(randomBytes);
|
||||||
const sessionToken = Array.from(randomBytes)
|
const sessionToken = Array.from(randomBytes)
|
||||||
|
|
@ -61,6 +62,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const newBalance = roundedBalance - roundedAmount;
|
||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
activeGames.set(sessionToken, {
|
activeGames.set(sessionToken, {
|
||||||
|
|
@ -76,16 +78,20 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
userId
|
userId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hold bet amount on server to prevent the user from like sending it to another account and farming money without a risk
|
// Update user balance
|
||||||
await tx
|
await tx
|
||||||
.update(user)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
baseCurrencyBalance: (roundedBalance - roundedAmount).toFixed(8),
|
baseCurrencyBalance: newBalance.toFixed(8),
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
})
|
})
|
||||||
.where(eq(user.id, userId));
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
return { sessionToken };
|
|
||||||
|
return {
|
||||||
|
sessionToken,
|
||||||
|
newBalance
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(result);
|
return json(result);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from '$lib/auth';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { predictionQuestion, user, predictionBet } from '$lib/server/db/schema';
|
import { predictionQuestion, user, predictionBet } from '$lib/server/db/schema';
|
||||||
import { eq, desc, and, sum, count } from 'drizzle-orm';
|
import { eq, desc, and, sum, count, or } from 'drizzle-orm';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, request }) => {
|
export const GET: RequestHandler = async ({ url, request }) => {
|
||||||
|
|
@ -24,9 +24,22 @@ export const GET: RequestHandler = async ({ url, request }) => {
|
||||||
const userId = session?.user ? Number(session.user.id) : null;
|
const userId = session?.user ? Number(session.user.id) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let statusFilter;
|
||||||
|
|
||||||
|
if (status === 'ACTIVE') {
|
||||||
|
statusFilter = eq(predictionQuestion.status, 'ACTIVE');
|
||||||
|
} else if (status === 'RESOLVED') {
|
||||||
|
statusFilter = or(
|
||||||
|
eq(predictionQuestion.status, 'RESOLVED'),
|
||||||
|
eq(predictionQuestion.status, 'CANCELLED')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
statusFilter = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (status !== 'ALL') {
|
if (statusFilter) {
|
||||||
conditions.push(eq(predictionQuestion.status, status as any));
|
conditions.push(statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined;
|
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
|
||||||
|
|
@ -44,28 +44,52 @@ export async function GET({ request }) {
|
||||||
const value = quantity * price;
|
const value = quantity * price;
|
||||||
totalCoinValue += value;
|
totalCoinValue += value;
|
||||||
|
|
||||||
// Calculate average purchase price from buy transactions
|
const allTransactions = await db.select({
|
||||||
const avgPriceResult = await db.select({
|
type: transaction.type,
|
||||||
avgPrice: sql<number>`
|
quantity: transaction.quantity,
|
||||||
CASE
|
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||||
WHEN SUM(${transaction.quantity}) > 0
|
timestamp: transaction.timestamp
|
||||||
THEN SUM(${transaction.totalBaseCurrencyAmount}) / SUM(${transaction.quantity})
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
`
|
|
||||||
})
|
})
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(transaction.userId, userId),
|
eq(transaction.userId, userId),
|
||||||
eq(transaction.coinId, holding.coinId),
|
eq(transaction.coinId, holding.coinId)
|
||||||
eq(transaction.type, 'BUY')
|
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
|
.orderBy(transaction.timestamp);
|
||||||
|
|
||||||
const avgPurchasePrice = Number(avgPriceResult[0]?.avgPrice || 0);
|
// calculate cost basis
|
||||||
const percentageChange = avgPurchasePrice > 0
|
let remainingQuantity = quantity;
|
||||||
? ((price - avgPurchasePrice) / avgPurchasePrice) * 100
|
let totalCostBasis = 0;
|
||||||
|
let runningQuantity = 0;
|
||||||
|
|
||||||
|
for (const tx of allTransactions) {
|
||||||
|
const txQuantity = Number(tx.quantity);
|
||||||
|
const txAmount = Number(tx.totalBaseCurrencyAmount);
|
||||||
|
|
||||||
|
if (tx.type === 'BUY') {
|
||||||
|
runningQuantity += txQuantity;
|
||||||
|
|
||||||
|
// if we still need to account for held coins
|
||||||
|
if (remainingQuantity > 0) {
|
||||||
|
const quantityToAttribute = Math.min(txQuantity, remainingQuantity);
|
||||||
|
const avgPrice = txAmount / txQuantity;
|
||||||
|
totalCostBasis += quantityToAttribute * avgPrice;
|
||||||
|
remainingQuantity -= quantityToAttribute;
|
||||||
|
}
|
||||||
|
} else if (tx.type === 'SELL') {
|
||||||
|
runningQuantity -= txQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if we accounted for all held coins, break
|
||||||
|
if (remainingQuantity <= 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgPurchasePrice = quantity > 0 ? totalCostBasis / quantity : 0;
|
||||||
|
|
||||||
|
const percentageChange = totalCostBasis > 0
|
||||||
|
? ((value - totalCostBasis) / totalCostBasis) * 100
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -76,7 +100,8 @@ export async function GET({ request }) {
|
||||||
value,
|
value,
|
||||||
change24h: Number(holding.change24h),
|
change24h: Number(holding.change24h),
|
||||||
avgPurchasePrice,
|
avgPurchasePrice,
|
||||||
percentageChange
|
percentageChange,
|
||||||
|
costBasis: totalCostBasis
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
171
website/src/routes/api/prestige/+server.ts
Normal file
171
website/src/routes/api/prestige/+server.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user, userPortfolio, transaction, notifications, coin } from '$lib/server/db/schema';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { formatValue, getPrestigeCost, getPrestigeName } from '$lib/utils';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session?.user) throw error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const [userData] = await tx
|
||||||
|
.select({
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance,
|
||||||
|
prestigeLevel: user.prestigeLevel
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.for('update')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userData) throw error(404, 'User not found');
|
||||||
|
|
||||||
|
const currentPrestige = userData.prestigeLevel || 0;
|
||||||
|
const nextPrestige = currentPrestige + 1;
|
||||||
|
const prestigeCost = getPrestigeCost(nextPrestige);
|
||||||
|
const prestigeName = getPrestigeName(nextPrestige);
|
||||||
|
|
||||||
|
if (!prestigeCost || !prestigeName) {
|
||||||
|
throw error(400, 'Maximum prestige level reached');
|
||||||
|
}
|
||||||
|
|
||||||
|
const holdings = await tx
|
||||||
|
.select({
|
||||||
|
coinId: userPortfolio.coinId,
|
||||||
|
quantity: userPortfolio.quantity,
|
||||||
|
currentPrice: coin.currentPrice,
|
||||||
|
symbol: coin.symbol
|
||||||
|
})
|
||||||
|
.from(userPortfolio)
|
||||||
|
.leftJoin(coin, eq(userPortfolio.coinId, coin.id))
|
||||||
|
.where(eq(userPortfolio.userId, userId));
|
||||||
|
|
||||||
|
let warningMessage = '';
|
||||||
|
let totalSaleValue = 0;
|
||||||
|
|
||||||
|
if (holdings.length > 0) {
|
||||||
|
warningMessage = `All ${holdings.length} coin holdings have been sold at current market prices. `;
|
||||||
|
|
||||||
|
for (const holding of holdings) {
|
||||||
|
const quantity = Number(holding.quantity);
|
||||||
|
const price = Number(holding.currentPrice);
|
||||||
|
const saleValue = quantity * price;
|
||||||
|
totalSaleValue += saleValue;
|
||||||
|
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
coinId: holding.coinId!,
|
||||||
|
type: 'SELL',
|
||||||
|
quantity: holding.quantity,
|
||||||
|
pricePerCoin: holding.currentPrice || '0',
|
||||||
|
totalBaseCurrencyAmount: saleValue.toString(),
|
||||||
|
timestamp: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.delete(userPortfolio)
|
||||||
|
.where(eq(userPortfolio.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBalance = Number(userData.baseCurrencyBalance) + totalSaleValue;
|
||||||
|
if (currentBalance < prestigeCost) {
|
||||||
|
throw error(400, `Insufficient funds. Need ${formatValue(prestigeCost)}, have ${formatValue(currentBalance)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: '100.00000000',
|
||||||
|
prestigeLevel: nextPrestige,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, userId));
|
||||||
|
|
||||||
|
await tx.delete(userPortfolio).where(eq(userPortfolio.userId, userId));
|
||||||
|
|
||||||
|
await tx.insert(notifications).values({
|
||||||
|
userId: userId,
|
||||||
|
type: 'SYSTEM',
|
||||||
|
title: `${prestigeName} Achieved!`,
|
||||||
|
message: `Congratulations! You have successfully reached ${prestigeName}. Your portfolio has been reset and you can now start fresh with your new prestige badge.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
newPrestigeLevel: nextPrestige,
|
||||||
|
costPaid: prestigeCost,
|
||||||
|
coinsSold: holdings.length,
|
||||||
|
totalSaleValue,
|
||||||
|
message: `${warningMessage}Congratulations! You've reached Prestige ${nextPrestige}!`
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ request }) => {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (!session?.user) throw error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
const [userProfile] = await db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
username: user.username,
|
||||||
|
bio: user.bio,
|
||||||
|
image: user.image,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance,
|
||||||
|
isAdmin: user.isAdmin,
|
||||||
|
loginStreak: user.loginStreak,
|
||||||
|
prestigeLevel: user.prestigeLevel
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userProfile) {
|
||||||
|
throw error(404, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [portfolioStats] = await db
|
||||||
|
.select({
|
||||||
|
holdingsCount: sql<number>`COUNT(*)`,
|
||||||
|
holdingsValue: sql<number>`COALESCE(SUM(CAST(${userPortfolio.quantity} AS NUMERIC) * CAST(${coin.currentPrice} AS NUMERIC)), 0)`
|
||||||
|
})
|
||||||
|
.from(userPortfolio)
|
||||||
|
.leftJoin(coin, eq(userPortfolio.coinId, coin.id))
|
||||||
|
.where(eq(userPortfolio.userId, userId));
|
||||||
|
|
||||||
|
const baseCurrencyBalance = Number(userProfile.baseCurrencyBalance);
|
||||||
|
const holdingsValue = Number(portfolioStats?.holdingsValue || 0);
|
||||||
|
const holdingsCount = Number(portfolioStats?.holdingsCount || 0);
|
||||||
|
const totalPortfolioValue = baseCurrencyBalance + holdingsValue;
|
||||||
|
|
||||||
|
return json({
|
||||||
|
profile: {
|
||||||
|
...userProfile,
|
||||||
|
baseCurrencyBalance,
|
||||||
|
totalPortfolioValue,
|
||||||
|
prestigeLevel: userProfile.prestigeLevel || 0
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalPortfolioValue,
|
||||||
|
baseCurrencyBalance,
|
||||||
|
holdingsValue,
|
||||||
|
holdingsCount,
|
||||||
|
coinsCreated: 0,
|
||||||
|
totalTransactions: 0,
|
||||||
|
totalBuyVolume: 0,
|
||||||
|
totalSellVolume: 0,
|
||||||
|
transactions24h: 0,
|
||||||
|
buyVolume24h: 0,
|
||||||
|
sellVolume24h: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -91,8 +91,7 @@ export async function POST({ request }) {
|
||||||
const key = await uploadProfilePicture(
|
const key = await uploadProfilePicture(
|
||||||
session.user.id,
|
session.user.id,
|
||||||
new Uint8Array(arrayBuffer),
|
new Uint8Array(arrayBuffer),
|
||||||
avatarFile.type,
|
avatarFile.type
|
||||||
avatarFile.size
|
|
||||||
);
|
);
|
||||||
updates.image = key;
|
updates.image = key;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export async function GET({ params }) {
|
||||||
baseCurrencyBalance: true,
|
baseCurrencyBalance: true,
|
||||||
isAdmin: true,
|
isAdmin: true,
|
||||||
loginStreak: true,
|
loginStreak: true,
|
||||||
|
prestigeLevel: true,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import SEO from '$lib/components/self/SEO.svelte';
|
import SEO from '$lib/components/self/SEO.svelte';
|
||||||
|
import Dice from '$lib/components/self/games/Dice.svelte'
|
||||||
|
|
||||||
let shouldSignIn = $state(false);
|
let shouldSignIn = $state(false);
|
||||||
let balance = $state(0);
|
let balance = $state(0);
|
||||||
|
|
@ -78,6 +79,12 @@
|
||||||
>
|
>
|
||||||
Mines
|
Mines
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={activeGame === 'dice' ? 'default' : 'outline'}
|
||||||
|
onclick={() => (activeGame = 'dice')}
|
||||||
|
>
|
||||||
|
Dice
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Content -->
|
<!-- Game Content -->
|
||||||
|
|
@ -87,6 +94,8 @@
|
||||||
<Slots bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
<Slots bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||||
{:else if activeGame === 'mines'}
|
{:else if activeGame === 'mines'}
|
||||||
<Mines bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
<Mines bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||||
|
{:else if activeGame === 'dice'}
|
||||||
|
<Dice bind:balance onBalanceUpdate={handleBalanceUpdate} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h1 class="mb-2 flex items-center justify-center gap-2 text-3xl font-bold">
|
<h1 class="mb-2 flex items-center justify-center gap-2 text-3xl font-bold">
|
||||||
<Sparkles class="h-8 w-8 text-purple-500" />
|
<Sparkles class="h-8 w-8 text-purple-500" />
|
||||||
Hopium<span class="text-xs">[BETA]</span>
|
Hopium
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-muted-foreground mb-6">
|
<p class="text-muted-foreground mb-6">
|
||||||
AI-powered prediction markets. Create questions and bet on outcomes.
|
AI-powered prediction markets. Create questions and bet on outcomes.
|
||||||
|
|
@ -236,6 +236,11 @@
|
||||||
NO
|
NO
|
||||||
{/if}
|
{/if}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{:else if question.status === 'CANCELLED'}
|
||||||
|
<Badge variant="outline" class="flex flex-shrink-0 items-center gap-1 text-muted-foreground border-muted-foreground">
|
||||||
|
<XIcon class="h-3 w-3" />
|
||||||
|
SKIP
|
||||||
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Probability Meter -->
|
<!-- Probability Meter -->
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,11 @@
|
||||||
RESOLVED: NO
|
RESOLVED: NO
|
||||||
{/if}
|
{/if}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{:else if question.status === 'CANCELLED'}
|
||||||
|
<Badge variant="outline" class="text-muted-foreground border-muted-foreground">
|
||||||
|
<XIcon class="h-4 w-4" />
|
||||||
|
SKIP
|
||||||
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -415,7 +420,7 @@
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={!customBetAmount ||
|
disabled={!customBetAmount ||
|
||||||
Number(customBetAmount) <= 0 ||
|
Number(customBetAmount) <= 0 ||
|
||||||
Number(customBetAmount) >= userBalance ||
|
Number(customBetAmount) > userBalance ||
|
||||||
placingBet ||
|
placingBet ||
|
||||||
question.aiResolution !== null}
|
question.aiResolution !== null}
|
||||||
onclick={placeBet}
|
onclick={placeBet}
|
||||||
|
|
|
||||||
477
website/src/routes/prestige/+page.svelte
Normal file
477
website/src/routes/prestige/+page.svelte
Normal file
|
|
@ -0,0 +1,477 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '$lib/components/ui/alert';
|
||||||
|
import { Progress } from '$lib/components/ui/progress';
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import * as Avatar from '$lib/components/ui/avatar';
|
||||||
|
import { AlertTriangle, Crown, Loader2, Star } from 'lucide-svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
|
import { formatValue, getPublicUrl, PRESTIGE_COSTS, PRESTIGE_NAMES } from '$lib/utils';
|
||||||
|
import SEO from '$lib/components/self/SEO.svelte';
|
||||||
|
import SignInConfirmDialog from '$lib/components/self/SignInConfirmDialog.svelte';
|
||||||
|
import ProfileBadges from '$lib/components/self/ProfileBadges.svelte';
|
||||||
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
|
import PrestigeSkeleton from '$lib/components/self/skeletons/PrestigeSkeleton.svelte';
|
||||||
|
|
||||||
|
let isPrestiging = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let shouldSignIn = $state(false);
|
||||||
|
let loading = $state(true);
|
||||||
|
let showConfirmDialog = $state(false);
|
||||||
|
let confirmationText = $state('');
|
||||||
|
let prestigeData = $state<any>(null);
|
||||||
|
|
||||||
|
let userData = $derived($USER_DATA);
|
||||||
|
|
||||||
|
const currentPrestige = $derived(prestigeData?.profile?.prestigeLevel || 0);
|
||||||
|
const nextPrestige = $derived(currentPrestige + 1);
|
||||||
|
const prestigeCost = $derived.by(() => {
|
||||||
|
if (!prestigeData) return null;
|
||||||
|
const nextLevel = currentPrestige + 1;
|
||||||
|
return PRESTIGE_COSTS[nextLevel as keyof typeof PRESTIGE_COSTS] || null;
|
||||||
|
});
|
||||||
|
const prestigeName = $derived.by(() => {
|
||||||
|
if (!prestigeData) return null;
|
||||||
|
const nextLevel = currentPrestige + 1;
|
||||||
|
return PRESTIGE_NAMES[nextLevel as keyof typeof PRESTIGE_NAMES] || null;
|
||||||
|
});
|
||||||
|
const currentBalance = $derived(prestigeData?.profile?.baseCurrencyBalance || 0);
|
||||||
|
const holdingsValue = $derived(prestigeData?.stats?.holdingsValue || 0);
|
||||||
|
const totalValue = $derived(prestigeData?.profile?.totalPortfolioValue || 0);
|
||||||
|
const canAfford = $derived(prestigeCost ? currentBalance >= prestigeCost : false);
|
||||||
|
const hasMaxPrestige = $derived(!prestigeCost);
|
||||||
|
const progressPercentage = $derived(
|
||||||
|
prestigeCost ? Math.min((currentBalance / prestigeCost) * 100, 100) : 100
|
||||||
|
);
|
||||||
|
const amountNeeded = $derived(prestigeCost ? Math.max(prestigeCost - currentBalance, 0) : 0);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await fetchPrestigeData();
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchPrestigeData() {
|
||||||
|
if (!userData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/prestige');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch prestige data');
|
||||||
|
prestigeData = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch prestige data:', e);
|
||||||
|
toast.error('Failed to load prestige data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrestige() {
|
||||||
|
if (!canAfford || !userData) return;
|
||||||
|
|
||||||
|
isPrestiging = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/prestige', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.message || 'Failed to prestige');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Congratulations! You've reached ${prestigeName}!`);
|
||||||
|
await fetchPrestigeData();
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An error occurred';
|
||||||
|
error = errorMessage;
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
isPrestiging = false;
|
||||||
|
showConfirmDialog = false;
|
||||||
|
confirmationText = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConfirmDialog() {
|
||||||
|
if (!canAfford || !userData) return;
|
||||||
|
showConfirmDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfirmDialog() {
|
||||||
|
showConfirmDialog = false;
|
||||||
|
confirmationText = '';
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
console.log(currentPrestige);
|
||||||
|
});
|
||||||
|
const canConfirmPrestige = $derived(confirmationText.toUpperCase() === 'PRESTIGE');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SEO
|
||||||
|
title="Prestige - Rugplay"
|
||||||
|
description="Advance your trading status and reset your progress for prestige rewards in the Rugplay cryptocurrency simulation."
|
||||||
|
noindex={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SignInConfirmDialog bind:open={shouldSignIn} />
|
||||||
|
|
||||||
|
<!-- Prestige Confirmation Dialog -->
|
||||||
|
<Dialog.Root bind:open={showConfirmDialog}>
|
||||||
|
<Dialog.Content class="sm:max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2">
|
||||||
|
<AlertTriangle class="text-destructive h-5 w-5" />
|
||||||
|
Confirm
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
This action is permanent and cannot be undone. Please review the consequences carefully.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle class="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>You will lose:</strong>
|
||||||
|
<ul class="mt-2 list-disc space-y-1 pl-4">
|
||||||
|
<li>Cash balance: {formatValue(currentBalance)}</li>
|
||||||
|
{#if holdingsValue > 0}
|
||||||
|
<li>All coin holdings worth {formatValue(holdingsValue)}</li>
|
||||||
|
{/if}
|
||||||
|
<li>Total portfolio value: {formatValue(totalValue)}</li>
|
||||||
|
</ul>
|
||||||
|
We will automatically sell all your coin holdings.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="confirmation" class="text-sm font-medium">Type "PRESTIGE" to confirm:</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmation"
|
||||||
|
bind:value={confirmationText}
|
||||||
|
placeholder="Type PRESTIGE here"
|
||||||
|
class="uppercase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="ghost" onclick={closeConfirmDialog}>Cancel</Button>
|
||||||
|
<Button onclick={handlePrestige} disabled={!canConfirmPrestige || isPrestiging}>
|
||||||
|
{#if isPrestiging}
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
Advancing...
|
||||||
|
{:else}
|
||||||
|
Proceed
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
|
<header class="mb-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="mb-2 flex items-center justify-center gap-3">
|
||||||
|
<Star class="h-8 w-8 text-yellow-500" />
|
||||||
|
<h1 class="text-3xl font-bold">Prestige</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground mb-6">Reset your progress to advance your trading status</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<PrestigeSkeleton />
|
||||||
|
{:else if !userData}
|
||||||
|
<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 prestige</div>
|
||||||
|
<p class="text-muted-foreground mb-4 text-sm">You need an account to prestige</p>
|
||||||
|
<Button onclick={() => (shouldSignIn = true)}>Sign In</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<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.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">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Meet Requirements</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Accumulate enough cash to afford the prestige cost
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Reset Progress</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
All cash and holdings are erased, but history remains
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Gain Status</p>
|
||||||
|
<p class="text-muted-foreground text-sm">
|
||||||
|
Earn an exclusive prestige title and start fresh
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{#if !hasMaxPrestige}
|
||||||
|
<!-- Prestige Requirements -->
|
||||||
|
<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">
|
||||||
|
<span class="font-medium">Progress to {prestigeName}</span>
|
||||||
|
<span class="font-mono">{progressPercentage.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercentage} class="h-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Financial Details Table -->
|
||||||
|
<div class="overflow-hidden rounded-xl border">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<tbody class="divide-y">
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted-foreground px-3 py-2 font-medium">Required:</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-bold">
|
||||||
|
{formatValue(prestigeCost || 0)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted-foreground px-3 py-2 font-medium">Your Cash:</td>
|
||||||
|
<td
|
||||||
|
class="px-3 py-2 text-right font-mono font-bold"
|
||||||
|
class:text-green-600={canAfford}
|
||||||
|
class:text-red-600={!canAfford}
|
||||||
|
>
|
||||||
|
{formatValue(currentBalance)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if !canAfford}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted-foreground px-3 py-2 font-medium">Still needed:</td>
|
||||||
|
<td class="px-3 py-2 text-right font-mono font-bold text-red-600">
|
||||||
|
{formatValue(amountNeeded)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !canAfford}
|
||||||
|
<Label>Tip: sell coin holdings</Label>
|
||||||
|
{:else}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTriangle class="h-4 w-4" />
|
||||||
|
<AlertDescription>Prestiging is permanent and cannot be undone!</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Prestige Button -->
|
||||||
|
<Button
|
||||||
|
onclick={canAfford ? openConfirmDialog : undefined}
|
||||||
|
disabled={!canAfford || isPrestiging}
|
||||||
|
class="w-full"
|
||||||
|
size="lg"
|
||||||
|
variant={canAfford ? 'default' : 'secondary'}
|
||||||
|
>
|
||||||
|
{#if isPrestiging}
|
||||||
|
<Loader2 class="h-4 w-4 animate-spin" />
|
||||||
|
Advancing to {prestigeName}...
|
||||||
|
{:else if !canAfford}
|
||||||
|
Need {formatValue(amountNeeded)} more to prestige
|
||||||
|
{:else}
|
||||||
|
Let's go
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<!-- Max Prestige Card -->
|
||||||
|
<Card.Root class="flex flex-1 flex-col gap-1">
|
||||||
|
<Card.Content class="py-16 text-center">
|
||||||
|
<Star class="mx-auto mb-6 h-20 w-20 text-yellow-500" />
|
||||||
|
<h3 class="mb-3 text-2xl font-bold">You're a star!</h3>
|
||||||
|
<p class="text-muted-foreground">
|
||||||
|
You have reached the highest prestige level available.
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error Messages -->
|
||||||
|
{#if error}
|
||||||
|
<Alert class="mt-6">
|
||||||
|
<AlertTriangle class="h-4 w-4" />
|
||||||
|
<AlertDescription class="text-red-600">
|
||||||
|
❌ {error}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column - Info -->
|
||||||
|
<div class="flex flex-col space-y-4">
|
||||||
|
<!-- Profile Preview Card -->
|
||||||
|
{#if userData}
|
||||||
|
<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">
|
||||||
|
<Label class="text-muted-foreground text-xs">Current</Label>
|
||||||
|
<div class="flex items-center gap-3 rounded-lg border p-3">
|
||||||
|
<Avatar.Root class="h-10 w-10 shrink-0">
|
||||||
|
<Avatar.Image src={getPublicUrl(userData.image)} alt={userData.name} />
|
||||||
|
<Avatar.Fallback class="text-sm"
|
||||||
|
>{userData.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||||
|
>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<h4 class="truncate text-sm font-medium">{userData.name}</h4>
|
||||||
|
<ProfileBadges
|
||||||
|
user={{
|
||||||
|
...userData,
|
||||||
|
id: parseInt(userData.id),
|
||||||
|
prestigeLevel: currentPrestige,
|
||||||
|
createdAt: new Date(),
|
||||||
|
totalPortfolioValue: totalValue,
|
||||||
|
loginStreak: 0
|
||||||
|
}}
|
||||||
|
showId={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground truncate text-xs">@{userData.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prestige Preview -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-muted-foreground text-xs">After</Label>
|
||||||
|
<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.Image src={getPublicUrl(userData.image)} alt={userData.name} />
|
||||||
|
<Avatar.Fallback class="text-sm"
|
||||||
|
>{userData.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||||
|
>
|
||||||
|
</Avatar.Root>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<h4 class="truncate text-sm font-medium">{userData.name}</h4>
|
||||||
|
<ProfileBadges
|
||||||
|
user={{
|
||||||
|
...userData,
|
||||||
|
id: parseInt(userData.id),
|
||||||
|
prestigeLevel: nextPrestige,
|
||||||
|
createdAt: new Date(),
|
||||||
|
totalPortfolioValue: totalValue,
|
||||||
|
loginStreak: 0
|
||||||
|
}}
|
||||||
|
showId={false}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted-foreground truncate text-xs">@{userData.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- All Prestige Levels -->
|
||||||
|
<Card.Root class="flex-1 gap-1">
|
||||||
|
<Card.Header class="pb-2">
|
||||||
|
<Card.Title class="text-base">Levels</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#each Object.entries(PRESTIGE_COSTS) as [level, cost]}
|
||||||
|
{@const levelNum = parseInt(level)}
|
||||||
|
{@const isCurrentNext = levelNum === nextPrestige && !hasMaxPrestige}
|
||||||
|
{@const isAchieved = levelNum <= currentPrestige}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between py-1"
|
||||||
|
class:opacity-50={!isAchieved && !isCurrentNext}
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if isAchieved}
|
||||||
|
<Star class="h-4 w-4 text-yellow-500" />
|
||||||
|
{:else if isCurrentNext}
|
||||||
|
<ChevronRight class="h-4 w-4 text-blue-500" />
|
||||||
|
{:else}
|
||||||
|
<div class="h-4 w-4"></div>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm font-medium" class:text-yellow-600={isAchieved}>
|
||||||
|
{PRESTIGE_NAMES[levelNum as keyof typeof PRESTIGE_NAMES]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-mono text-xs">{formatValue(cost)}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
min-height: calc(100vh - 4rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
website/static/sound/dice.mp3
Normal file
BIN
website/static/sound/dice.mp3
Normal file
Binary file not shown.
Reference in a new issue