cool prestige update
This commit is contained in:
@@ -47,7 +47,7 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Login / register (username + password)
|
- Login / register (username + password)
|
||||||
- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles)
|
- **Inventory**: sort by recent or value, multi-select sell at **instance** value (float/wear), bulk **Sell below** threshold, **favorites** (★ — cannot be sold or auto-sold; can still be bet in battles), **Vault** staking (up to **3** slots by default; +1 per Vault Capacity skill) — staked items earn dividends every **10 min** (10% value online / 1% offline, scaled by float² wear and rarity)
|
||||||
- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately
|
- **Auto-sell**: optional threshold (shared with bulk sell); on **case opens**, credits are applied after the reel finishes; on **battle wins**, items taken from other players below the threshold are sold immediately
|
||||||
- **Wear (float)**: each case drop gets a random float `0–1` and paint seed; grades FN / MW / FT / WW / BS (CS ranges). Catalog `marketValue` is the **Field-Tested** reference; instance `valueCents` scales by float within the wear band (FN up to +50%, MW +15–30%, FT mid ≈ catalog, WW down to −40%, BS lower)
|
- **Wear (float)**: each case drop gets a random float `0–1` and paint seed; grades FN / MW / FT / WW / BS (CS ranges). Catalog `marketValue` is the **Field-Tested** reference; instance `valueCents` scales by float within the wear band (FN up to +50%, MW +15–30%, FT mid ≈ catalog, WW down to −40%, BS lower)
|
||||||
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners
|
- **Feed Drop**: toggle in nav — live left column of latest case unboxes + MultiPlayers Battle winners
|
||||||
@@ -61,7 +61,7 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
|||||||
- Profile (username in nav): avatar, bio, password, stats, logout
|
- Profile (username in nav): avatar, bio, password, stats, logout
|
||||||
- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles
|
- **Presence**: online status (Socket.IO), total play time, last connection — shown on own and public profiles
|
||||||
- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile
|
- **Achievements** (`/achievements`, also from profile): 10 starter badges unlocked by play (cases, battles, catalog, prestige, shop, playtime); earned badges appear on the profile
|
||||||
- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100 000 000 000.00** cr excess); spend Pr on a skill tree (chance / sell gain / key progress)
|
- **Prestige**: pay **1 000 000 000 000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100 000 000 000.00** cr excess); spend Pr on a skill tree (Fortune, Yield, Polish, Bulk, Discount, Quality, noTime, Refund, Respin, Vault Capacity, Offline/Online Yield)
|
||||||
- **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory
|
- **Leaderboard**: richest players (balance + inventory value); click a row to open a public **player profile** (`/player/:username`) with stats and inventory
|
||||||
- Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters)
|
- Admin: overview dashboard, players (credit adjust), cases / items / drops (split pages, search filters)
|
||||||
|
|
||||||
@@ -115,10 +115,10 @@ Typical **prod → local** flow: `docker-db-export.sh` on the server → `scp` t
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop
|
- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop
|
||||||
- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`
|
- Catalog item price = Field-Tested reference; each inventory row stores `floatValue`, `wear`, `paintSeed`, `valueCents`, `favorite`, optional vault `staked` / `stakedAt` / `lastYieldAt`
|
||||||
- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr)
|
- Per-user auto-sell settings: `autoSellEnabled`, `autoSellThresholdCents` (default threshold **10.00** cr)
|
||||||
- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server`
|
- Case key progress stored in `CaseKeyProgress` (per user + case); backfill: `npm run db:backfill-case-keys --prefix server`
|
||||||
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve
|
- Inventory items locked while deposited in a bet; unlocked or transferred on resolve; staked vault items cannot be sold, auto-sold, or bet
|
||||||
- Avatars / uploads under `server/uploads/` (volume in Docker)
|
- Avatars / uploads under `server/uploads/` (volume in Docker)
|
||||||
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling`
|
- Real-time via Socket.IO; Docker Hub image name is lowercase: `foufure/casegambling`
|
||||||
- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
|
- Max **10** simultaneous browser windows/tabs per account (Socket.IO); override with `MAX_SOCKETS_PER_USER`
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ export default function AdminDrops() {
|
|||||||
[cases, selectedCaseId]
|
[cases, selectedCaseId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const percents = useMemo(
|
const poolItems = useMemo(() => {
|
||||||
() => (selected ? dropPercents(selected.items) : []),
|
if (!selected) return [];
|
||||||
[selected]
|
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
|
||||||
);
|
}, [selected]);
|
||||||
|
|
||||||
|
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -116,12 +118,12 @@ export default function AdminDrops() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="admin-block">
|
<div className="admin-block">
|
||||||
<h3>Pool ({selected.items.length})</h3>
|
<h3>Pool ({poolItems.length})</h3>
|
||||||
{selected.items.length === 0 ? (
|
{poolItems.length === 0 ? (
|
||||||
<div className="muted">No drops yet.</div>
|
<div className="muted">No drops yet.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="drop-list">
|
<div className="drop-list">
|
||||||
{selected.items.map((ci, idx) => (
|
{poolItems.map((ci, idx) => (
|
||||||
<div key={ci.id} className="drop-row">
|
<div key={ci.id} className="drop-row">
|
||||||
<span className="drop-item-cell">
|
<span className="drop-item-cell">
|
||||||
{ci.item.imageUrl ? (
|
{ci.item.imageUrl ? (
|
||||||
|
|||||||
+65
-2
@@ -46,6 +46,17 @@ export const api = {
|
|||||||
body: JSON.stringify({ count }),
|
body: JSON.stringify({ count }),
|
||||||
}),
|
}),
|
||||||
inventory: () => request('/api/inventory'),
|
inventory: () => request('/api/inventory'),
|
||||||
|
vaultStake: (inventoryItemIds) =>
|
||||||
|
request('/api/vault/stake', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
|
}),
|
||||||
|
vaultUnstake: (inventoryItemIds) =>
|
||||||
|
request('/api/vault/unstake', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ inventoryItemIds }),
|
||||||
|
}),
|
||||||
|
vaultClaim: () => request('/api/vault/claim', { method: 'POST' }),
|
||||||
sellInventory: (inventoryItemIds) =>
|
sellInventory: (inventoryItemIds) =>
|
||||||
request('/api/inventory/sell', {
|
request('/api/inventory/sell', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -174,8 +185,13 @@ export const api = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatCredits(cents) {
|
/**
|
||||||
const fixed = (Number(cents) / 100).toFixed(2);
|
* Exact credit display (cents → "1 234.56"), for tooltips / confirmations.
|
||||||
|
*/
|
||||||
|
export function formatCreditsExact(cents) {
|
||||||
|
const n = Number(cents);
|
||||||
|
if (!Number.isFinite(n)) return '0.00';
|
||||||
|
const fixed = (n / 100).toFixed(2);
|
||||||
const neg = fixed.startsWith('-');
|
const neg = fixed.startsWith('-');
|
||||||
const body = neg ? fixed.slice(1) : fixed;
|
const body = neg ? fixed.slice(1) : fixed;
|
||||||
const [intPart, dec] = body.split('.');
|
const [intPart, dec] = body.split('.');
|
||||||
@@ -183,6 +199,53 @@ export function formatCredits(cents) {
|
|||||||
return `${neg ? '-' : ''}${grouped}.${dec}`;
|
return `${neg ? '-' : ''}${grouped}.${dec}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alphabet suffixes after T: aa, ab, … az, ba, … (each step ×1000). */
|
||||||
|
function alphabetSuffix(index) {
|
||||||
|
let num = Math.max(0, Math.floor(index));
|
||||||
|
let result = '';
|
||||||
|
do {
|
||||||
|
result = String.fromCharCode(97 + (num % 26)) + result;
|
||||||
|
num = Math.floor(num / 26);
|
||||||
|
} while (num > 0);
|
||||||
|
while (result.length < 2) result = `a${result}`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tierSuffix(tier) {
|
||||||
|
if (tier <= 0) return '';
|
||||||
|
if (tier === 1) return 'K';
|
||||||
|
if (tier === 2) return 'M';
|
||||||
|
if (tier === 3) return 'B';
|
||||||
|
if (tier === 4) return 'T';
|
||||||
|
return alphabetSuffix(tier - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact credit display for UI readability.
|
||||||
|
* K / M / B / T, then aa, ab, … (×1000 each). Input is cents.
|
||||||
|
*/
|
||||||
|
export function formatCredits(cents) {
|
||||||
|
const n = Number(cents);
|
||||||
|
if (!Number.isFinite(n)) return '0.00';
|
||||||
|
const neg = n < 0;
|
||||||
|
const credits = Math.abs(n) / 100;
|
||||||
|
|
||||||
|
if (credits < 1000) {
|
||||||
|
return `${neg ? '-' : ''}${credits.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tier = 0;
|
||||||
|
let scaled = credits;
|
||||||
|
while (scaled >= 1000 && tier < 1000) {
|
||||||
|
scaled /= 1000;
|
||||||
|
tier += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = tierSuffix(tier);
|
||||||
|
const shown = String(Number(scaled.toFixed(2)));
|
||||||
|
return `${neg ? '-' : ''}${shown}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatChance(pct) {
|
export function formatChance(pct) {
|
||||||
return `${Number(pct || 0).toFixed(1)}%`;
|
return `${Number(pct || 0).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { rarityColor } from '../rarities';
|
|||||||
|
|
||||||
const ITEM_WIDTH = 120;
|
const ITEM_WIDTH = 120;
|
||||||
const ITEM_WIDTH_COMPACT = 72;
|
const ITEM_WIDTH_COMPACT = 72;
|
||||||
const SPIN_DURATION_MS = 5500;
|
const BASE_SPIN_DURATION_MS = 5500;
|
||||||
const SPIN_SLOTS = 42;
|
const SPIN_SLOTS = 42;
|
||||||
const TAIL_SLOTS = 8;
|
const TAIL_SLOTS = 8;
|
||||||
|
|
||||||
|
export function spinDurationMs(animReduction = 0) {
|
||||||
|
const pct = Math.max(0, Math.min(90, Number(animReduction) || 0));
|
||||||
|
return Math.max(1000, Math.round(BASE_SPIN_DURATION_MS * (1 - pct / 100)));
|
||||||
|
}
|
||||||
|
|
||||||
function pickRandom(items, avoidId) {
|
function pickRandom(items, avoidId) {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
if (items.length === 1) return items[0];
|
if (items.length === 1) return items[0];
|
||||||
@@ -78,6 +83,7 @@ export default function CaseReel({
|
|||||||
onDone,
|
onDone,
|
||||||
showOwners = false,
|
showOwners = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
animReduction = 0,
|
||||||
}) {
|
}) {
|
||||||
const trackRef = useRef(null);
|
const trackRef = useRef(null);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
@@ -85,6 +91,7 @@ export default function CaseReel({
|
|||||||
const onDoneRef = useRef(onDone);
|
const onDoneRef = useRef(onDone);
|
||||||
onDoneRef.current = onDone;
|
onDoneRef.current = onDone;
|
||||||
const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH;
|
const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH;
|
||||||
|
const durationMs = spinDurationMs(animReduction);
|
||||||
|
|
||||||
const spinKey =
|
const spinKey =
|
||||||
winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : '';
|
winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : '';
|
||||||
@@ -122,7 +129,7 @@ export default function CaseReel({
|
|||||||
if (finished) return;
|
if (finished) return;
|
||||||
finished = true;
|
finished = true;
|
||||||
onDoneRef.current?.();
|
onDoneRef.current?.();
|
||||||
}, SPIN_DURATION_MS + 80);
|
}, durationMs + 80);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(id);
|
cancelAnimationFrame(id);
|
||||||
@@ -131,7 +138,7 @@ export default function CaseReel({
|
|||||||
};
|
};
|
||||||
// intentionally only re-run when a new spin starts
|
// intentionally only re-run when a new spin starts
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [spinning, winnerId, itemWidth]);
|
}, [spinning, winnerId, itemWidth, durationMs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -144,7 +151,7 @@ export default function CaseReel({
|
|||||||
style={{
|
style={{
|
||||||
transform: `translateX(-${offset}px)`,
|
transform: `translateX(-${offset}px)`,
|
||||||
transition: spinning
|
transition: spinning
|
||||||
? `transform ${SPIN_DURATION_MS}ms cubic-bezier(0.12, 0.75, 0.12, 1)`
|
? `transform ${durationMs}ms cubic-bezier(0.12, 0.75, 0.12, 1)`
|
||||||
: 'none',
|
: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatCredits } from '../api';
|
import { formatCredits, formatCreditsExact } from '../api';
|
||||||
import { rarityColor } from '../rarities';
|
import { rarityColor } from '../rarities';
|
||||||
|
|
||||||
/** Instance sell/bet price, else catalog FT reference */
|
/** Instance sell/bet price, else catalog FT reference */
|
||||||
@@ -22,6 +22,7 @@ export default function ItemTile({
|
|||||||
showWear = true,
|
showWear = true,
|
||||||
selected = false,
|
selected = false,
|
||||||
favorite = false,
|
favorite = false,
|
||||||
|
staked = false,
|
||||||
onClick,
|
onClick,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) {
|
}) {
|
||||||
@@ -37,16 +38,23 @@ export default function ItemTile({
|
|||||||
}`
|
}`
|
||||||
: wear || undefined;
|
: wear || undefined;
|
||||||
|
|
||||||
|
const titleParts = [];
|
||||||
|
if (staked) titleParts.push('Vault');
|
||||||
|
if (favorite) titleParts.push('Favorite');
|
||||||
|
if (floatTitle) titleParts.push(floatTitle);
|
||||||
|
else titleParts.push(item.name);
|
||||||
|
if (showValue) titleParts.push(`${formatCreditsExact(value)} cr`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`item-tile${selected ? ' selected' : ''}${favorite ? ' favorite' : ''}${
|
className={`item-tile${selected ? ' selected' : ''}${favorite ? ' favorite' : ''}${
|
||||||
clickable ? ' selectable' : ''
|
staked ? ' staked' : ''
|
||||||
}${disabled ? ' disabled' : ''}`}
|
}${clickable ? ' selectable' : ''}${disabled ? ' disabled' : ''}`}
|
||||||
style={{ borderTopColor: color }}
|
style={{ borderTopColor: color }}
|
||||||
onClick={disabled ? undefined : onClick}
|
onClick={disabled ? undefined : onClick}
|
||||||
role={clickable ? 'button' : undefined}
|
role={clickable ? 'button' : undefined}
|
||||||
tabIndex={clickable && !disabled ? 0 : undefined}
|
tabIndex={clickable && !disabled ? 0 : undefined}
|
||||||
title={favorite ? `Favorite · ${floatTitle || item.name}` : floatTitle}
|
title={titleParts.join(' · ')}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
clickable && !disabled
|
clickable && !disabled
|
||||||
? (e) => {
|
? (e) => {
|
||||||
@@ -60,6 +68,7 @@ export default function ItemTile({
|
|||||||
>
|
>
|
||||||
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
|
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
|
||||||
{favorite ? <span className="item-favorite-badge" aria-label="Favorite">★</span> : null}
|
{favorite ? <span className="item-favorite-badge" aria-label="Favorite">★</span> : null}
|
||||||
|
{staked ? <span className="item-vault-badge" aria-label="Vault">V</span> : null}
|
||||||
{hasImage ? (
|
{hasImage ? (
|
||||||
<img src={item.imageUrl} alt="" loading="lazy" />
|
<img src={item.imageUrl} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
@@ -76,7 +85,9 @@ export default function ItemTile({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showValue && (
|
{showValue && (
|
||||||
<div className="item-meta">{formatCredits(value)} cr</div>
|
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
|
||||||
|
{formatCredits(value)} cr
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../AuthContext';
|
import { useAuth } from '../AuthContext';
|
||||||
import { api, formatCredits } from '../api';
|
import { api, formatCredits, formatCreditsExact } from '../api';
|
||||||
import DropFeed from './DropFeed';
|
import DropFeed from './DropFeed';
|
||||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||||
import { onInventoryChanged } from '../inventoryEvents';
|
import { onInventoryChanged } from '../inventoryEvents';
|
||||||
@@ -213,11 +213,20 @@ export default function Layout() {
|
|||||||
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
||||||
{user && (
|
{user && (
|
||||||
isPlayer ? (
|
isPlayer ? (
|
||||||
<NavLink to="/shop" className="balance-pill balance-pill-link">
|
<NavLink
|
||||||
|
to="/shop"
|
||||||
|
className="balance-pill balance-pill-link"
|
||||||
|
title={`${formatCreditsExact(user.balance)} cr`}
|
||||||
|
>
|
||||||
{formatCredits(user.balance)} cr
|
{formatCredits(user.balance)} cr
|
||||||
</NavLink>
|
</NavLink>
|
||||||
) : (
|
) : (
|
||||||
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
|
<span
|
||||||
|
className="balance-pill"
|
||||||
|
title={`${formatCreditsExact(user.balance)} cr`}
|
||||||
|
>
|
||||||
|
{formatCredits(user.balance)} cr
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{user ? (
|
{user ? (
|
||||||
|
|||||||
@@ -1,8 +1,54 @@
|
|||||||
import { formatCredits } from '../api';
|
|
||||||
|
|
||||||
export default function ProfilePrestigeStats({ prestige }) {
|
export default function ProfilePrestigeStats({ prestige }) {
|
||||||
if (!prestige || !prestige.count) return null;
|
if (!prestige || !prestige.count) return null;
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
typeof prestige.prBalance === 'number' && prestige.prBalance > 0
|
||||||
|
? { label: 'Pr', value: <strong>{prestige.prBalance}</strong> }
|
||||||
|
: null,
|
||||||
|
{ label: 'Fortune', value: <strong>+{prestige.chanceBonus}%</strong> },
|
||||||
|
{ label: 'Yield', value: <strong>+{prestige.gainBonus}%</strong> },
|
||||||
|
(prestige.wearBonus || 0) > 0 && {
|
||||||
|
label: 'Polish',
|
||||||
|
value: <strong>+{prestige.wearBonus}</strong>,
|
||||||
|
},
|
||||||
|
(prestige.openBonus || 0) > 0 && {
|
||||||
|
label: 'Bulk',
|
||||||
|
value: <strong>+{prestige.openBonus} opens</strong>,
|
||||||
|
},
|
||||||
|
(prestige.caseDiscount || 0) > 0 && {
|
||||||
|
label: 'Discount',
|
||||||
|
value: <strong>−{prestige.caseDiscount}%</strong>,
|
||||||
|
},
|
||||||
|
(prestige.qualityBonus || 0) > 0 && {
|
||||||
|
label: 'Quality',
|
||||||
|
value: <strong>+{prestige.qualityBonus}</strong>,
|
||||||
|
},
|
||||||
|
(prestige.animReduction || 0) > 0 && {
|
||||||
|
label: 'noTime',
|
||||||
|
value: <strong>−{prestige.animReduction}%</strong>,
|
||||||
|
},
|
||||||
|
(prestige.refundChance || 0) > 0 && {
|
||||||
|
label: 'Refund',
|
||||||
|
value: <strong>{prestige.refundChance}%</strong>,
|
||||||
|
},
|
||||||
|
(prestige.respinChance || 0) > 0 && {
|
||||||
|
label: 'Respin',
|
||||||
|
value: <strong>{prestige.respinChance}%</strong>,
|
||||||
|
},
|
||||||
|
(prestige.vaultSlots || 0) > 0 && {
|
||||||
|
label: 'Vault Capacity',
|
||||||
|
value: <strong>+{prestige.vaultSlots}</strong>,
|
||||||
|
},
|
||||||
|
(prestige.offlineYield || 0) > 0 && {
|
||||||
|
label: 'Offline Yield',
|
||||||
|
value: <strong>+{prestige.offlineYield}%</strong>,
|
||||||
|
},
|
||||||
|
(prestige.onlineYield || 0) > 0 && {
|
||||||
|
label: 'Online Yield',
|
||||||
|
value: <strong>+{prestige.onlineYield}%</strong>,
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="prestige-profile-block">
|
<div className="prestige-profile-block">
|
||||||
<div className="catalog-badges-head">
|
<div className="catalog-badges-head">
|
||||||
@@ -10,65 +56,11 @@ export default function ProfilePrestigeStats({ prestige }) {
|
|||||||
<span className="catalog-badges-luck">×{prestige.count}</span>
|
<span className="catalog-badges-luck">×{prestige.count}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="stat-list prestige-profile-stats">
|
<ul className="stat-list prestige-profile-stats">
|
||||||
{typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && (
|
{rows.map((row) => (
|
||||||
<li>
|
<li key={row.label}>
|
||||||
Pr <strong>{prestige.prBalance}</strong>
|
{row.label} {row.value}
|
||||||
</li>
|
</li>
|
||||||
)}
|
))}
|
||||||
<li>
|
|
||||||
Chance <strong>+{prestige.chanceBonus}%</strong>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Sell gains <strong>+{prestige.gainBonus}%</strong>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Key opens <strong>−{prestige.keyOpenReduction}</strong>
|
|
||||||
</li>
|
|
||||||
{(prestige.startBonus || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Vault <strong>{formatCredits(prestige.startBonus)} cr</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.adBonus || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Ad reward <strong>+{prestige.adBonus}%</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.adCooldownReduction || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Ad cooldown <strong>−{prestige.adCooldownReduction}s</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.wearBonus || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Polish <strong>+{prestige.wearBonus}</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.prBonus || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Pr echo <strong>+{prestige.prBonus}%</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.shopDiscount || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Haggle <strong>−{prestige.shopDiscount} osu</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.startOsu || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Purse <strong>+{prestige.startOsu} osu</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.dropValueBonus || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Magnet <strong>+{prestige.dropValueBonus}%</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{(prestige.openBonus || 0) > 0 && (
|
|
||||||
<li>
|
|
||||||
Bulk <strong>+{prestige.openBonus} opens</strong>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1841,6 +1841,48 @@ a.balance-pill-link.active {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-vault-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.35rem;
|
||||||
|
left: 0.35rem;
|
||||||
|
z-index: 1;
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
background: rgba(80, 160, 220, 0.35);
|
||||||
|
color: #9fd4ff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-tile.staked {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(80, 160, 220, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-panel {
|
||||||
|
margin: 1.25rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-panel .section-head {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-grid {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.item-tile.disabled {
|
.item-tile.disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export default function CasePage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [keyMessage, setKeyMessage] = useState('');
|
const [keyMessage, setKeyMessage] = useState('');
|
||||||
const [catalogMessage, setCatalogMessage] = useState('');
|
const [catalogMessage, setCatalogMessage] = useState('');
|
||||||
|
const [openBonusMessage, setOpenBonusMessage] = useState('');
|
||||||
|
const [animReduction, setAnimReduction] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [opening, setOpening] = useState(false);
|
const [opening, setOpening] = useState(false);
|
||||||
const [spins, setSpins] = useState([]);
|
const [spins, setSpins] = useState([]);
|
||||||
@@ -243,6 +245,7 @@ export default function CasePage() {
|
|||||||
setError('');
|
setError('');
|
||||||
setKeyMessage('');
|
setKeyMessage('');
|
||||||
setCatalogMessage('');
|
setCatalogMessage('');
|
||||||
|
setOpenBonusMessage('');
|
||||||
setSpins([]);
|
setSpins([]);
|
||||||
setOpening(true);
|
setOpening(true);
|
||||||
announcedRef.current = false;
|
announcedRef.current = false;
|
||||||
@@ -253,6 +256,13 @@ export default function CasePage() {
|
|||||||
updateBalance(result.user.balance);
|
updateBalance(result.user.balance);
|
||||||
notifyInventoryChanged();
|
notifyInventoryChanged();
|
||||||
persistQty(crate.id, quantity);
|
persistQty(crate.id, quantity);
|
||||||
|
if (result.animReduction != null) {
|
||||||
|
setAnimReduction(result.animReduction);
|
||||||
|
}
|
||||||
|
const bonusParts = [];
|
||||||
|
if (result.refunded) bonusParts.push('Refund! Case cost returned');
|
||||||
|
if (result.respin) bonusParts.push('Respin! All drops re-rolled');
|
||||||
|
if (bonusParts.length) setOpenBonusMessage(bonusParts.join(' · '));
|
||||||
if (result.keyProgress) {
|
if (result.keyProgress) {
|
||||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||||
}
|
}
|
||||||
@@ -352,14 +362,18 @@ export default function CasePage() {
|
|||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
||||||
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
|
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
|
||||||
|
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
|
||||||
|
|
||||||
{crate && (
|
{crate && (
|
||||||
<>
|
<>
|
||||||
<div className="case-hero">
|
<div className="case-hero">
|
||||||
<h1>{crate.name}</h1>
|
<h1>{crate.name}</h1>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Cost {formatCredits(crate.price)} cr each · Balance{' '}
|
Cost {formatCredits(crate.price)} cr each
|
||||||
{formatCredits(user.balance)} cr
|
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
|
||||||
|
? ` (was ${formatCredits(crate.basePrice)})`
|
||||||
|
: ''}{' '}
|
||||||
|
· Balance {formatCredits(user.balance)} cr
|
||||||
{crate.catalogLuckPercent > 0
|
{crate.catalogLuckPercent > 0
|
||||||
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
||||||
: ''}
|
: ''}
|
||||||
@@ -435,6 +449,7 @@ export default function CasePage() {
|
|||||||
winnerId={spin.item.id}
|
winnerId={spin.item.id}
|
||||||
spinning={spin.spinning}
|
spinning={spin.spinning}
|
||||||
onDone={() => onReelDone(spin.key)}
|
onDone={() => onReelDone(spin.key)}
|
||||||
|
animReduction={animReduction}
|
||||||
/>
|
/>
|
||||||
{spin.done && (
|
{spin.done && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const PAGE_SIZE = 50;
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user, loading, patchUser } = useAuth();
|
const { user, loading, patchUser } = useAuth();
|
||||||
const [inventory, setInventory] = useState([]);
|
const [inventory, setInventory] = useState([]);
|
||||||
|
const [vault, setVault] = useState(null);
|
||||||
|
const [vaultMessage, setVaultMessage] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [sort, setSort] = useState('value');
|
const [sort, setSort] = useState('value');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -37,6 +39,7 @@ export default function Dashboard() {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
const inv = await api.inventory();
|
const inv = await api.inventory();
|
||||||
setInventory(inv.inventory);
|
setInventory(inv.inventory);
|
||||||
|
setVault(inv.vault || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -109,6 +112,7 @@ export default function Dashboard() {
|
|||||||
return inventory.filter(
|
return inventory.filter(
|
||||||
(inv) =>
|
(inv) =>
|
||||||
!inv.favorite &&
|
!inv.favorite &&
|
||||||
|
!inv.staked &&
|
||||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||||
);
|
);
|
||||||
}, [inventory, thresholdCents]);
|
}, [inventory, thresholdCents]);
|
||||||
@@ -118,10 +122,17 @@ export default function Dashboard() {
|
|||||||
[inventory, selected]
|
[inventory, selected]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const stakedItems = useMemo(() => inventory.filter((inv) => inv.staked), [inventory]);
|
||||||
|
|
||||||
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
|
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
|
||||||
const selectedAllFavorite =
|
const selectedAllFavorite =
|
||||||
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
|
selectedItems.length > 0 && selectedItems.every((inv) => inv.favorite);
|
||||||
const selectedCanSell = selectedItems.some((inv) => !inv.favorite);
|
const selectedCanSell = selectedItems.some((inv) => !inv.favorite && !inv.staked);
|
||||||
|
const selectedStaked = selectedItems.filter((inv) => inv.staked);
|
||||||
|
const selectedUnstaked = selectedItems.filter((inv) => !inv.staked);
|
||||||
|
const vaultFree = Math.max(0, (vault?.slots ?? 3) - (vault?.used ?? 0));
|
||||||
|
const canStake = selectedUnstaked.length > 0 && selectedUnstaked.length <= vaultFree;
|
||||||
|
const canUnstake = selectedStaked.length > 0;
|
||||||
|
|
||||||
const belowValue = useMemo(
|
const belowValue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -155,11 +166,78 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sell = async () => {
|
const sell = async () => {
|
||||||
const ids = selectedItems.filter((inv) => !inv.favorite).map((inv) => inv.id);
|
const ids = selectedItems
|
||||||
|
.filter((inv) => !inv.favorite && !inv.staked)
|
||||||
|
.map((inv) => inv.id);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
await sellIds(ids);
|
await sellIds(ids);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stakeSelected = async () => {
|
||||||
|
const ids = selectedUnstaked.map((inv) => inv.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
setVaultMessage('');
|
||||||
|
try {
|
||||||
|
const data = await api.vaultStake(ids);
|
||||||
|
if (data.user) patchUser({ balance: data.user.balance });
|
||||||
|
setSelected([]);
|
||||||
|
setVaultMessage(`Staked ${ids.length} item${ids.length === 1 ? '' : 's'} in vault`);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unstakeSelected = async () => {
|
||||||
|
const ids = selectedStaked.map((inv) => inv.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
setVaultMessage('');
|
||||||
|
try {
|
||||||
|
const data = await api.vaultUnstake(ids);
|
||||||
|
if (data.user) patchUser({ balance: data.user.balance });
|
||||||
|
setSelected([]);
|
||||||
|
const yieldPart =
|
||||||
|
data.yieldCredited > 0
|
||||||
|
? ` · +${formatCredits(data.yieldCredited)} cr yield`
|
||||||
|
: '';
|
||||||
|
setVaultMessage(
|
||||||
|
`Unstaked ${ids.length} item${ids.length === 1 ? '' : 's'}${yieldPart}`
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimVault = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
setVaultMessage('');
|
||||||
|
try {
|
||||||
|
const data = await api.vaultClaim();
|
||||||
|
if (data.user) patchUser({ balance: data.user.balance });
|
||||||
|
if (data.vault) setVault(data.vault);
|
||||||
|
setVaultMessage(
|
||||||
|
data.credited > 0
|
||||||
|
? `Vault yield +${formatCredits(data.credited)} cr`
|
||||||
|
: 'No vault yield ready yet'
|
||||||
|
);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setFavorite = async (favorite) => {
|
const setFavorite = async (favorite) => {
|
||||||
if (!selected.length) return;
|
if (!selected.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -303,10 +381,34 @@ export default function Dashboard() {
|
|||||||
className="btn"
|
className="btn"
|
||||||
onClick={sell}
|
onClick={sell}
|
||||||
disabled={busy || !selectedCanSell}
|
disabled={busy || !selectedCanSell}
|
||||||
title={selectedHasFavorite ? 'Favorites cannot be sold' : undefined}
|
title={
|
||||||
|
selectedHasFavorite || selectedStaked.length
|
||||||
|
? 'Favorites and vault items cannot be sold'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Sell
|
Sell
|
||||||
</button>
|
</button>
|
||||||
|
{canStake && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={stakeSelected}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Stake ({selectedUnstaked.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canUnstake && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={unstakeSelected}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Unstake ({selectedStaked.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -381,6 +483,57 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="error">{error}</div>}
|
{error && <div className="error">{error}</div>}
|
||||||
|
{vaultMessage && <div className="ok-msg">{vaultMessage}</div>}
|
||||||
|
|
||||||
|
<div className="vault-panel admin-block">
|
||||||
|
<div className="section-head">
|
||||||
|
<div>
|
||||||
|
<h2>Vault</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min
|
||||||
|
(scales with wear & rarity).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={claimVault}
|
||||||
|
disabled={busy || !(vault?.used > 0)}
|
||||||
|
>
|
||||||
|
Claim yield
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="vault-stats">
|
||||||
|
<span>
|
||||||
|
Slots <strong>{vault?.used ?? 0}/{vault?.slots ?? 3}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Online / tick{' '}
|
||||||
|
<strong className="gold">{formatCredits(vault?.onlinePerTick ?? 0)} cr</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Offline / tick{' '}
|
||||||
|
<strong>{formatCredits(vault?.offlinePerTick ?? 0)} cr</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stakedItems.length > 0 ? (
|
||||||
|
<div className="grid vault-grid">
|
||||||
|
{stakedItems.map((inv) => (
|
||||||
|
<ItemTile
|
||||||
|
key={`vault-${inv.id}`}
|
||||||
|
item={inv.item}
|
||||||
|
favorite={inv.favorite}
|
||||||
|
staked
|
||||||
|
selected={selected.includes(inv.id)}
|
||||||
|
onClick={() => toggle(inv.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="muted">No items staked — select items below and Stake.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{inventory.length === 0 ? (
|
{inventory.length === 0 ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
Empty for now —{' '}
|
Empty for now —{' '}
|
||||||
@@ -397,6 +550,7 @@ export default function Dashboard() {
|
|||||||
key={inv.id}
|
key={inv.id}
|
||||||
item={inv.item}
|
item={inv.item}
|
||||||
favorite={inv.favorite}
|
favorite={inv.favorite}
|
||||||
|
staked={inv.staked}
|
||||||
selected={selected.includes(inv.id)}
|
selected={selected.includes(inv.id)}
|
||||||
onClick={() => toggle(inv.id)}
|
onClick={() => toggle(inv.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -162,53 +162,53 @@ export default function PrestigePage() {
|
|||||||
{p && (
|
{p && (
|
||||||
<div className="prestige-totals prestige-totals-wide">
|
<div className="prestige-totals prestige-totals-wide">
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Chance</span>
|
<span className="muted">Fortune</span>
|
||||||
<strong>+{p.chanceBonus}%</strong>
|
<strong>+{p.chanceBonus}%</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Gains</span>
|
<span className="muted">Yield</span>
|
||||||
<strong>+{p.gainBonus}%</strong>
|
<strong>+{p.gainBonus}%</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="muted">Key opens</span>
|
|
||||||
<strong>−{p.keyOpenReduction}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Vault</span>
|
|
||||||
<strong>{formatCredits(p.startBonus || 0)} cr</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Ad reward</span>
|
|
||||||
<strong>+{p.adBonus || 0}%</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Ad cooldown</span>
|
|
||||||
<strong>−{p.adCooldownReduction || 0}s</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Polish</span>
|
<span className="muted">Polish</span>
|
||||||
<strong>+{p.wearBonus || 0}</strong>
|
<strong>+{p.wearBonus || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="muted">Pr echo</span>
|
|
||||||
<strong>+{p.prBonus || 0}%</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Haggle</span>
|
|
||||||
<strong>−{p.shopDiscount || 0} osu</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Purse</span>
|
|
||||||
<strong>+{p.startOsu || 0} osu</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="muted">Magnet</span>
|
|
||||||
<strong>+{p.dropValueBonus || 0}%</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span className="muted">Bulk</span>
|
<span className="muted">Bulk</span>
|
||||||
<strong>+{p.openBonus || 0}</strong>
|
<strong>+{p.openBonus || 0}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Discount</span>
|
||||||
|
<strong>−{p.caseDiscount || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Quality</span>
|
||||||
|
<strong>+{p.qualityBonus || 0}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">noTime</span>
|
||||||
|
<strong>−{p.animReduction || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Refund</span>
|
||||||
|
<strong>{p.refundChance || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Respin</span>
|
||||||
|
<strong>{p.respinChance || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Vault slots</span>
|
||||||
|
<strong>+{p.vaultSlots || 0}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Offline Yield</span>
|
||||||
|
<strong>+{p.offlineYield || 0}%</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="muted">Online Yield</span>
|
||||||
|
<strong>+{p.onlineYield || 0}%</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -256,8 +256,8 @@ export default function PrestigePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3>You lose</h3>
|
<h3>You lose</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Balance (minus Vault / Purse)</li>
|
<li>Balance</li>
|
||||||
<li>Inventory</li>
|
<li>Inventory & vault stakes</li>
|
||||||
<li>Case keys & catalog progress</li>
|
<li>Case keys & catalog progress</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ model User {
|
|||||||
prestigeStartOsu Int @default(0)
|
prestigeStartOsu Int @default(0)
|
||||||
prestigeDropValueBonus Int @default(0)
|
prestigeDropValueBonus Int @default(0)
|
||||||
prestigeOpenBonus Int @default(0)
|
prestigeOpenBonus Int @default(0)
|
||||||
|
prestigeCaseDiscount Int @default(0)
|
||||||
|
prestigeQualityBonus Int @default(0)
|
||||||
|
prestigeAnimReduction Int @default(0)
|
||||||
|
prestigeRefundChance Int @default(0)
|
||||||
|
prestigeRespinChance Int @default(0)
|
||||||
|
prestigeVaultSlots Int @default(0)
|
||||||
|
prestigeOfflineYield Int @default(0)
|
||||||
|
prestigeOnlineYield Int @default(0)
|
||||||
lastConnection DateTime?
|
lastConnection DateTime?
|
||||||
playTimeSeconds BigInt @default(0)
|
playTimeSeconds BigInt @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -96,6 +104,9 @@ model InventoryItem {
|
|||||||
userId Int
|
userId Int
|
||||||
itemId Int
|
itemId Int
|
||||||
locked Boolean @default(false)
|
locked Boolean @default(false)
|
||||||
|
staked Boolean @default(false)
|
||||||
|
stakedAt DateTime?
|
||||||
|
lastYieldAt DateTime?
|
||||||
floatValue Float @default(0.265)
|
floatValue Float @default(0.265)
|
||||||
wear String @default("Field-Tested")
|
wear String @default("Field-Tested")
|
||||||
paintSeed Int @default(0)
|
paintSeed Int @default(0)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export async function applyAutoSell(tx, user, inventoryItems) {
|
|||||||
let balanceDelta = 0;
|
let balanceDelta = 0;
|
||||||
|
|
||||||
for (const inv of inventoryItems) {
|
for (const inv of inventoryItems) {
|
||||||
if (!inv || inv.locked || inv.favorite || inv.valueCents >= threshold) continue;
|
if (!inv || inv.locked || inv.staked || inv.favorite || inv.valueCents >= threshold) continue;
|
||||||
|
|
||||||
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
|
const payout = applyGainMultiplier(inv.valueCents, gainBonus);
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -20,6 +20,7 @@ import feedRoutes from './routes/feed.js';
|
|||||||
import catalogRoutes from './routes/catalog.js';
|
import catalogRoutes from './routes/catalog.js';
|
||||||
import prestigeRoutes from './routes/prestige.js';
|
import prestigeRoutes from './routes/prestige.js';
|
||||||
import achievementsRoutes from './routes/achievements.js';
|
import achievementsRoutes from './routes/achievements.js';
|
||||||
|
import vaultRoutes from './routes/vault.js';
|
||||||
import { setIO } from './realtime.js';
|
import { setIO } from './realtime.js';
|
||||||
import { getDropFeed } from './feed.js';
|
import { getDropFeed } from './feed.js';
|
||||||
import { ensureJackpotRound } from './services/jackpot.js';
|
import { ensureJackpotRound } from './services/jackpot.js';
|
||||||
@@ -28,7 +29,7 @@ import { getJackpotState } from './services/jackpot.js';
|
|||||||
import { ensureAdminFromEnv } from './ensureAdmin.js';
|
import { ensureAdminFromEnv } from './ensureAdmin.js';
|
||||||
import { ensureAchievements, checkAchievements } from './achievements.js';
|
import { ensureAchievements, checkAchievements } from './achievements.js';
|
||||||
import { ensureCategories } from './categories.js';
|
import { ensureCategories } from './categories.js';
|
||||||
import { trackConnect, trackDisconnect } from './presence.js';
|
import { trackConnect, trackDisconnect, settleOnlineVaultForConnected } from './presence.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const uploadsDir = path.join(__dirname, '../uploads');
|
const uploadsDir = path.join(__dirname, '../uploads');
|
||||||
@@ -114,6 +115,7 @@ app.use('/api/feed', feedRoutes);
|
|||||||
app.use('/api/catalog', catalogRoutes);
|
app.use('/api/catalog', catalogRoutes);
|
||||||
app.use('/api/prestige', prestigeRoutes);
|
app.use('/api/prestige', prestigeRoutes);
|
||||||
app.use('/api/achievements', achievementsRoutes);
|
app.use('/api/achievements', achievementsRoutes);
|
||||||
|
app.use('/api/vault', vaultRoutes);
|
||||||
|
|
||||||
app.use((err, _req, res, _next) => {
|
app.use((err, _req, res, _next) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -149,6 +151,7 @@ io.on('connection', async (socket) => {
|
|||||||
socket.emit('session:ok', {
|
socket.emit('session:ok', {
|
||||||
count: result.count,
|
count: result.count,
|
||||||
max: result.max,
|
max: result.max,
|
||||||
|
vaultCredited: result.vaultCredited || 0,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('presence connect', err);
|
console.error('presence connect', err);
|
||||||
@@ -252,6 +255,12 @@ async function bootstrap() {
|
|||||||
await ensureJackpotRound();
|
await ensureJackpotRound();
|
||||||
await recoverDuelSpins();
|
await recoverDuelSpins();
|
||||||
startEmptyDuelRoomPurge();
|
startEmptyDuelRoomPurge();
|
||||||
|
// Online vault dividends: check every 60s for completed 10-min ticks
|
||||||
|
setInterval(() => {
|
||||||
|
settleOnlineVaultForConnected().catch((err) =>
|
||||||
|
console.error('vault online interval', err)
|
||||||
|
);
|
||||||
|
}, 60_000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to bootstrap game services', err);
|
console.error('Failed to bootstrap game services', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,16 @@ export function publicUser(user) {
|
|||||||
prestigeCount: user.prestigeCount ?? 0,
|
prestigeCount: user.prestigeCount ?? 0,
|
||||||
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
|
prestigeChanceBonus: user.prestigeChanceBonus ?? 0,
|
||||||
prestigeGainBonus: user.prestigeGainBonus ?? 0,
|
prestigeGainBonus: user.prestigeGainBonus ?? 0,
|
||||||
prestigeKeyOpenReduction: user.prestigeKeyOpenReduction ?? 0,
|
prestigeWearBonus: user.prestigeWearBonus ?? 0,
|
||||||
|
prestigeOpenBonus: user.prestigeOpenBonus ?? 0,
|
||||||
|
prestigeCaseDiscount: user.prestigeCaseDiscount ?? 0,
|
||||||
|
prestigeQualityBonus: user.prestigeQualityBonus ?? 0,
|
||||||
|
prestigeAnimReduction: user.prestigeAnimReduction ?? 0,
|
||||||
|
prestigeRefundChance: user.prestigeRefundChance ?? 0,
|
||||||
|
prestigeRespinChance: user.prestigeRespinChance ?? 0,
|
||||||
|
prestigeVaultSlots: user.prestigeVaultSlots ?? 0,
|
||||||
|
prestigeOfflineYield: user.prestigeOfflineYield ?? 0,
|
||||||
|
prestigeOnlineYield: user.prestigeOnlineYield ?? 0,
|
||||||
lastConnection: user.lastConnection || null,
|
lastConnection: user.lastConnection || null,
|
||||||
playTimeSeconds: Number(user.playTimeSeconds ?? 0),
|
playTimeSeconds: Number(user.playTimeSeconds ?? 0),
|
||||||
};
|
};
|
||||||
|
|||||||
+39
-3
@@ -1,11 +1,12 @@
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
|
import { settleVaultYield } from './vault.js';
|
||||||
|
|
||||||
export const MAX_SOCKETS_PER_USER = Math.max(
|
export const MAX_SOCKETS_PER_USER = Math.max(
|
||||||
1,
|
1,
|
||||||
Number(process.env.MAX_SOCKETS_PER_USER || 10)
|
Number(process.env.MAX_SOCKETS_PER_USER || 10)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number }>} */
|
/** @type {Map<number, { sockets: Set<string>, sessionStartedAt: number, lastVaultCredit?: number }>} */
|
||||||
const online = new Map();
|
const online = new Map();
|
||||||
|
|
||||||
export function isConnected(userId) {
|
export function isConnected(userId) {
|
||||||
@@ -20,6 +21,10 @@ export function onlineUserCount() {
|
|||||||
return online.size;
|
return online.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function onlineUserIds() {
|
||||||
|
return [...online.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
export function totalSocketCount() {
|
export function totalSocketCount() {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
for (const entry of online.values()) {
|
for (const entry of online.values()) {
|
||||||
@@ -32,7 +37,7 @@ export function totalSocketCount() {
|
|||||||
* Track an authenticated socket. Updates lastConnection on first socket for this user.
|
* Track an authenticated socket. Updates lastConnection on first socket for this user.
|
||||||
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
||||||
* Slot claim is synchronous so concurrent connects cannot exceed the max.
|
* Slot claim is synchronous so concurrent connects cannot exceed the max.
|
||||||
* @returns {Promise<{ ok: true, count: number, max: number } | { ok: false, count: number, max: number }>}
|
* @returns {Promise<{ ok: true, count: number, max: number, vaultCredited?: number } | { ok: false, count: number, max: number }>}
|
||||||
*/
|
*/
|
||||||
export async function trackConnect(userId, socketId) {
|
export async function trackConnect(userId, socketId) {
|
||||||
const id = Number(userId);
|
const id = Number(userId);
|
||||||
@@ -58,6 +63,7 @@ export async function trackConnect(userId, socketId) {
|
|||||||
// Claim slot before any await (avoids race past the limit)
|
// Claim slot before any await (avoids race past the limit)
|
||||||
entry.sockets.add(socketId);
|
entry.sockets.add(socketId);
|
||||||
const isFirst = entry.sockets.size === 1;
|
const isFirst = entry.sockets.size === 1;
|
||||||
|
let vaultCredited = 0;
|
||||||
|
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
try {
|
try {
|
||||||
@@ -68,9 +74,16 @@ export async function trackConnect(userId, socketId) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('presence lastConnection', err);
|
console.error('presence lastConnection', err);
|
||||||
}
|
}
|
||||||
|
// Offline yield accrued while disconnected
|
||||||
|
try {
|
||||||
|
const settled = await settleVaultYield(id, 'offline');
|
||||||
|
vaultCredited = settled.credited;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('presence vault offline settle', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, count: entry.sockets.size, max };
|
return { ok: true, count: entry.sockets.size, max, vaultCredited };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +101,14 @@ export async function trackDisconnect(userId, socketId) {
|
|||||||
|
|
||||||
online.delete(id);
|
online.delete(id);
|
||||||
const elapsed = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
|
const elapsed = Math.max(0, Math.floor((Date.now() - entry.sessionStartedAt) / 1000));
|
||||||
|
|
||||||
|
// Settle remaining online ticks before going offline
|
||||||
|
try {
|
||||||
|
await settleVaultYield(id, 'online');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('presence vault online settle', err);
|
||||||
|
}
|
||||||
|
|
||||||
if (elapsed <= 0) return;
|
if (elapsed <= 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -118,3 +139,18 @@ export function serializePresence(user) {
|
|||||||
maxWindows: MAX_SOCKETS_PER_USER,
|
maxWindows: MAX_SOCKETS_PER_USER,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Settle online vault yield for all currently connected users. */
|
||||||
|
export async function settleOnlineVaultForConnected() {
|
||||||
|
const ids = onlineUserIds();
|
||||||
|
let total = 0;
|
||||||
|
for (const uid of ids) {
|
||||||
|
try {
|
||||||
|
const settled = await settleVaultYield(uid, 'online');
|
||||||
|
total += settled.credited;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('vault online tick', uid, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|||||||
+97
-120
@@ -15,7 +15,6 @@ export const PR_EXCESS_CHUNK_CENTS = 10_000_000_000_000n;
|
|||||||
export const PR_PER_EXCESS_CHUNK = 1000;
|
export const PR_PER_EXCESS_CHUNK = 1000;
|
||||||
|
|
||||||
const BASE_AD_COOLDOWN_MS = 60_000;
|
const BASE_AD_COOLDOWN_MS = 60_000;
|
||||||
const MIN_AD_COOLDOWN_MS = 15_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skill tree upgrades bought with Pr (stackable branches).
|
* Skill tree upgrades bought with Pr (stackable branches).
|
||||||
@@ -40,44 +39,6 @@ export const PRESTIGE_SKILLS = {
|
|||||||
unit: '%',
|
unit: '%',
|
||||||
branch: 'economy',
|
branch: 'economy',
|
||||||
},
|
},
|
||||||
key: {
|
|
||||||
id: 'key',
|
|
||||||
field: 'prestigeKeyOpenReduction',
|
|
||||||
delta: 10,
|
|
||||||
title: 'Keys',
|
|
||||||
description: '−10 opens required for next key (permanent)',
|
|
||||||
unit: '',
|
|
||||||
prefix: '−',
|
|
||||||
branch: 'drops',
|
|
||||||
},
|
|
||||||
vault: {
|
|
||||||
id: 'vault',
|
|
||||||
field: 'prestigeStartBonus',
|
|
||||||
delta: 10000,
|
|
||||||
title: 'Vault',
|
|
||||||
description: '+100.00 cr kept after each prestige reset',
|
|
||||||
unit: 'cr-cents',
|
|
||||||
branch: 'economy',
|
|
||||||
},
|
|
||||||
broadcast: {
|
|
||||||
id: 'broadcast',
|
|
||||||
field: 'prestigeAdBonus',
|
|
||||||
delta: 10,
|
|
||||||
title: 'Broadcast',
|
|
||||||
description: '+10% shop ad rewards (permanent)',
|
|
||||||
unit: '%',
|
|
||||||
branch: 'shop',
|
|
||||||
},
|
|
||||||
tempo: {
|
|
||||||
id: 'tempo',
|
|
||||||
field: 'prestigeAdCooldownReduction',
|
|
||||||
delta: 5,
|
|
||||||
title: 'Tempo',
|
|
||||||
description: '−5s shop ad cooldown (min 15s)',
|
|
||||||
unit: 's',
|
|
||||||
prefix: '−',
|
|
||||||
branch: 'shop',
|
|
||||||
},
|
|
||||||
polish: {
|
polish: {
|
||||||
id: 'polish',
|
id: 'polish',
|
||||||
field: 'prestigeWearBonus',
|
field: 'prestigeWearBonus',
|
||||||
@@ -87,44 +48,6 @@ export const PRESTIGE_SKILLS = {
|
|||||||
unit: '',
|
unit: '',
|
||||||
branch: 'drops',
|
branch: 'drops',
|
||||||
},
|
},
|
||||||
echo: {
|
|
||||||
id: 'echo',
|
|
||||||
field: 'prestigePrBonus',
|
|
||||||
delta: 5,
|
|
||||||
title: 'Echo',
|
|
||||||
description: '+5% Pr earned on prestige (permanent)',
|
|
||||||
unit: '%',
|
|
||||||
costMult: 1.25,
|
|
||||||
branch: 'meta',
|
|
||||||
},
|
|
||||||
haggle: {
|
|
||||||
id: 'haggle',
|
|
||||||
field: 'prestigeShopDiscount',
|
|
||||||
delta: 1,
|
|
||||||
title: 'Haggle',
|
|
||||||
description: '−1 osu on shop pack prices (min 1 osu)',
|
|
||||||
unit: ' osu',
|
|
||||||
prefix: '−',
|
|
||||||
branch: 'shop',
|
|
||||||
},
|
|
||||||
purse: {
|
|
||||||
id: 'purse',
|
|
||||||
field: 'prestigeStartOsu',
|
|
||||||
delta: 10,
|
|
||||||
title: 'Purse',
|
|
||||||
description: '+10 osu granted after each prestige reset',
|
|
||||||
unit: ' osu',
|
|
||||||
branch: 'economy',
|
|
||||||
},
|
|
||||||
magnet: {
|
|
||||||
id: 'magnet',
|
|
||||||
field: 'prestigeDropValueBonus',
|
|
||||||
delta: 2,
|
|
||||||
title: 'Magnet',
|
|
||||||
description: '+2% item value on case drops (permanent)',
|
|
||||||
unit: '%',
|
|
||||||
branch: 'drops',
|
|
||||||
},
|
|
||||||
bulk: {
|
bulk: {
|
||||||
id: 'bulk',
|
id: 'bulk',
|
||||||
field: 'prestigeOpenBonus',
|
field: 'prestigeOpenBonus',
|
||||||
@@ -134,6 +57,81 @@ export const PRESTIGE_SKILLS = {
|
|||||||
unit: '',
|
unit: '',
|
||||||
branch: 'drops',
|
branch: 'drops',
|
||||||
},
|
},
|
||||||
|
discount: {
|
||||||
|
id: 'discount',
|
||||||
|
field: 'prestigeCaseDiscount',
|
||||||
|
delta: 2,
|
||||||
|
title: 'Discount',
|
||||||
|
description: '−2% case open price (permanent)',
|
||||||
|
unit: '%',
|
||||||
|
prefix: '−',
|
||||||
|
branch: 'economy',
|
||||||
|
},
|
||||||
|
quality: {
|
||||||
|
id: 'quality',
|
||||||
|
field: 'prestigeQualityBonus',
|
||||||
|
delta: 1,
|
||||||
|
title: 'Quality',
|
||||||
|
description: 'Better float / wear rolls on case opens (items below Field-Tested get upgraded)',
|
||||||
|
unit: '',
|
||||||
|
branch: 'drops',
|
||||||
|
},
|
||||||
|
noTime: {
|
||||||
|
id: 'noTime',
|
||||||
|
field: 'prestigeAnimReduction',
|
||||||
|
delta: 10,
|
||||||
|
title: 'noTime',
|
||||||
|
description: 'Reduced case opening animation time',
|
||||||
|
unit: '%',
|
||||||
|
prefix: '−',
|
||||||
|
branch: 'drops',
|
||||||
|
},
|
||||||
|
refund: {
|
||||||
|
id: 'refund',
|
||||||
|
field: 'prestigeRefundChance',
|
||||||
|
delta: 2,
|
||||||
|
title: 'Refund',
|
||||||
|
description: 'Chance to get a free case open refund',
|
||||||
|
unit: '%',
|
||||||
|
branch: 'drops',
|
||||||
|
},
|
||||||
|
respin: {
|
||||||
|
id: 'respin',
|
||||||
|
field: 'prestigeRespinChance',
|
||||||
|
delta: 1,
|
||||||
|
title: 'Respin',
|
||||||
|
description: 'Chance to get a free case respin (respins all cases if multi-opening)',
|
||||||
|
unit: '%',
|
||||||
|
branch: 'drops',
|
||||||
|
},
|
||||||
|
vaultCap: {
|
||||||
|
id: 'vaultCap',
|
||||||
|
field: 'prestigeVaultSlots',
|
||||||
|
delta: 1,
|
||||||
|
title: 'Vault Capacity',
|
||||||
|
description: '+1 max staking slot in the vault',
|
||||||
|
unit: '',
|
||||||
|
prefix: '+',
|
||||||
|
branch: 'vault',
|
||||||
|
},
|
||||||
|
offlineYield: {
|
||||||
|
id: 'offlineYield',
|
||||||
|
field: 'prestigeOfflineYield',
|
||||||
|
delta: 10,
|
||||||
|
title: 'Offline Yield',
|
||||||
|
description: 'Increased dividends for staked items while offline',
|
||||||
|
unit: '%',
|
||||||
|
branch: 'vault',
|
||||||
|
},
|
||||||
|
onlineYield: {
|
||||||
|
id: 'onlineYield',
|
||||||
|
field: 'prestigeOnlineYield',
|
||||||
|
delta: 10,
|
||||||
|
title: 'Online Yield',
|
||||||
|
description: 'Increased dividends for staked items while online',
|
||||||
|
unit: '%',
|
||||||
|
branch: 'vault',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cost in Pr for the next purchase of a skill (level = current purchases). */
|
/** Cost in Pr for the next purchase of a skill (level = current purchases). */
|
||||||
@@ -153,24 +151,23 @@ export function skillLevelFromUser(user, skillId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Expected Pr (no RNG) for UI preview, including Echo bonus.
|
* Expected Pr (no RNG) for UI preview.
|
||||||
*/
|
*/
|
||||||
export function estimatePrReward(balanceCents, prBonusPct = 0) {
|
export function estimatePrReward(balanceCents) {
|
||||||
const balance = BigInt(balanceCents);
|
const balance = BigInt(balanceCents);
|
||||||
const cost = PRESTIGE_COST_CENTS;
|
const cost = PRESTIGE_COST_CENTS;
|
||||||
if (balance < cost) return 0;
|
if (balance < cost) return 0;
|
||||||
const excess = balance - cost;
|
const excess = balance - cost;
|
||||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||||
const exact = Number(excess) / Number(chunk);
|
const exact = Number(excess) / Number(chunk);
|
||||||
const base = Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
|
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
|
||||||
return applyPrBonus(base, prBonusPct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
|
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
|
||||||
* roll so partial chunks average to the remaining Pr. Then Echo %.
|
* roll so partial chunks average to the remaining Pr.
|
||||||
*/
|
*/
|
||||||
export function computePrReward(balanceCents, prBonusPct = 0) {
|
export function computePrReward(balanceCents) {
|
||||||
const balance = BigInt(balanceCents);
|
const balance = BigInt(balanceCents);
|
||||||
const cost = PRESTIGE_COST_CENTS;
|
const cost = PRESTIGE_COST_CENTS;
|
||||||
const excess = balance > cost ? balance - cost : 0n;
|
const excess = balance > cost ? balance - cost : 0n;
|
||||||
@@ -181,26 +178,11 @@ export function computePrReward(balanceCents, prBonusPct = 0) {
|
|||||||
const bonus =
|
const bonus =
|
||||||
full * PR_PER_EXCESS_CHUNK +
|
full * PR_PER_EXCESS_CHUNK +
|
||||||
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
|
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
|
||||||
return applyPrBonus(PR_BASE_REWARD + bonus, prBonusPct);
|
return PR_BASE_REWARD + bonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyPrBonus(basePr, prBonusPct = 0) {
|
export function adCooldownMs(_user) {
|
||||||
const base = Math.max(0, Math.floor(Number(basePr) || 0));
|
return BASE_AD_COOLDOWN_MS;
|
||||||
const pct = Number(prBonusPct) || 0;
|
|
||||||
if (pct <= 0) return base;
|
|
||||||
return Math.floor(base * (1 + pct / 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function adCooldownMs(user) {
|
|
||||||
const reductionSec = Math.max(0, Number(user?.prestigeAdCooldownReduction) || 0);
|
|
||||||
return Math.max(MIN_AD_COOLDOWN_MS, BASE_AD_COOLDOWN_MS - reductionSec * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyAdRewardBonus(cents, adBonusPct = 0) {
|
|
||||||
const base = Math.max(0, Math.floor(Number(cents) || 0));
|
|
||||||
const pct = Number(adBonusPct) || 0;
|
|
||||||
if (pct <= 0) return base;
|
|
||||||
return Math.floor(base * (1 + pct / 100));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializePrestige(user) {
|
export function serializePrestige(user) {
|
||||||
@@ -208,16 +190,16 @@ export function serializePrestige(user) {
|
|||||||
count: user?.prestigeCount ?? 0,
|
count: user?.prestigeCount ?? 0,
|
||||||
chanceBonus: user?.prestigeChanceBonus ?? 0,
|
chanceBonus: user?.prestigeChanceBonus ?? 0,
|
||||||
gainBonus: user?.prestigeGainBonus ?? 0,
|
gainBonus: user?.prestigeGainBonus ?? 0,
|
||||||
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
|
|
||||||
startBonus: user?.prestigeStartBonus ?? 0,
|
|
||||||
adBonus: user?.prestigeAdBonus ?? 0,
|
|
||||||
adCooldownReduction: user?.prestigeAdCooldownReduction ?? 0,
|
|
||||||
wearBonus: user?.prestigeWearBonus ?? 0,
|
wearBonus: user?.prestigeWearBonus ?? 0,
|
||||||
prBonus: user?.prestigePrBonus ?? 0,
|
|
||||||
shopDiscount: user?.prestigeShopDiscount ?? 0,
|
|
||||||
startOsu: user?.prestigeStartOsu ?? 0,
|
|
||||||
dropValueBonus: user?.prestigeDropValueBonus ?? 0,
|
|
||||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||||
|
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
||||||
|
qualityBonus: user?.prestigeQualityBonus ?? 0,
|
||||||
|
animReduction: user?.prestigeAnimReduction ?? 0,
|
||||||
|
refundChance: user?.prestigeRefundChance ?? 0,
|
||||||
|
respinChance: user?.prestigeRespinChance ?? 0,
|
||||||
|
vaultSlots: user?.prestigeVaultSlots ?? 0,
|
||||||
|
offlineYield: user?.prestigeOfflineYield ?? 0,
|
||||||
|
onlineYield: user?.prestigeOnlineYield ?? 0,
|
||||||
prBalance: user?.prBalance ?? 0,
|
prBalance: user?.prBalance ?? 0,
|
||||||
costCents: Number(PRESTIGE_COST_CENTS),
|
costCents: Number(PRESTIGE_COST_CENTS),
|
||||||
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
|
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
|
||||||
@@ -249,17 +231,12 @@ export function serializeSkillTree(user) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shopOsuCost(baseCost, discount = 0) {
|
/** Apply case price discount (%). Min 1 cent. */
|
||||||
const base = Math.max(1, Math.floor(Number(baseCost) || 0));
|
export function applyCaseDiscount(priceCents, discountPct = 0) {
|
||||||
const off = Math.max(0, Math.floor(Number(discount) || 0));
|
const base = Math.max(0, Math.floor(Number(priceCents) || 0));
|
||||||
return Math.max(1, base - off);
|
const pct = Math.max(0, Number(discountPct) || 0);
|
||||||
}
|
if (pct <= 0) return Math.max(1, base);
|
||||||
|
return Math.max(1, Math.floor(base * (1 - pct / 100)));
|
||||||
export function applyDropValueBonus(cents, dropValueBonusPct = 0) {
|
|
||||||
const base = Math.max(1, Math.floor(Number(cents) || 0));
|
|
||||||
const pct = Number(dropValueBonusPct) || 0;
|
|
||||||
if (pct <= 0) return base;
|
|
||||||
return Math.max(1, Math.floor(base * (1 + pct / 100)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply lifetime gain % to a credit amount (cents). */
|
/** Apply lifetime gain % to a credit amount (cents). */
|
||||||
|
|||||||
@@ -96,7 +96,25 @@ router.post('/login', async (req, res) => {
|
|||||||
data: { lastConnection: new Date() },
|
data: { lastConnection: new Date() },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ user: publicUser(withConn) });
|
// Offline vault yield if user is not already socket-connected
|
||||||
|
let vaultCredited = 0;
|
||||||
|
try {
|
||||||
|
const { isConnected } = await import('../presence.js');
|
||||||
|
const { settleVaultYield } = await import('../vault.js');
|
||||||
|
if (!isConnected(user.id)) {
|
||||||
|
const settled = await settleVaultYield(user.id, 'offline');
|
||||||
|
vaultCredited = settled.credited;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('login vault settle', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed =
|
||||||
|
vaultCredited > 0
|
||||||
|
? await prisma.user.findUnique({ where: { id: user.id } })
|
||||||
|
: withConn;
|
||||||
|
|
||||||
|
res.json({ user: publicUser(refreshed), vaultCredited });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Login failed' });
|
res.status(500).json({ error: 'Login failed' });
|
||||||
|
|||||||
+115
-29
@@ -15,12 +15,12 @@ import {
|
|||||||
fillCatalogSlots,
|
fillCatalogSlots,
|
||||||
formatCaseItemsWithLuck,
|
formatCaseItemsWithLuck,
|
||||||
} from '../catalog.js';
|
} from '../catalog.js';
|
||||||
import { totalLuckPercent, applyDropValueBonus } from '../prestige.js';
|
import { totalLuckPercent, applyCaseDiscount } from '../prestige.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
function formatCase(c, luckPercent = 0) {
|
function formatCase(c, luckPercent = 0, unitPrice = null) {
|
||||||
const items = (c.items || []).map((ci) => ({
|
const items = (c.items || []).map((ci) => ({
|
||||||
id: ci.item.id,
|
id: ci.item.id,
|
||||||
name: ci.item.name,
|
name: ci.item.name,
|
||||||
@@ -34,7 +34,8 @@ function formatCase(c, luckPercent = 0) {
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
imageUrl: c.imageUrl,
|
imageUrl: c.imageUrl,
|
||||||
price: c.price,
|
price: unitPrice != null ? unitPrice : c.price,
|
||||||
|
basePrice: c.price,
|
||||||
active: c.active,
|
active: c.active,
|
||||||
catalogLuckPercent: luckPercent,
|
catalogLuckPercent: luckPercent,
|
||||||
items: formatCaseItemsWithLuck(items, luckPercent),
|
items: formatCaseItemsWithLuck(items, luckPercent),
|
||||||
@@ -43,7 +44,12 @@ function formatCase(c, luckPercent = 0) {
|
|||||||
|
|
||||||
async function loadUserLuckAndKeys(userId) {
|
async function loadUserLuckAndKeys(userId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return { luckPercent: 0, keyOpenReduction: 0, openBonus: 0, dropValueBonus: 0, user: null };
|
return {
|
||||||
|
luckPercent: 0,
|
||||||
|
openBonus: 0,
|
||||||
|
caseDiscount: 0,
|
||||||
|
user: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const [catalogLuck, user] = await Promise.all([
|
const [catalogLuck, user] = await Promise.all([
|
||||||
countCompletedChapters(prisma, userId),
|
countCompletedChapters(prisma, userId),
|
||||||
@@ -51,18 +57,20 @@ async function loadUserLuckAndKeys(userId) {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: {
|
select: {
|
||||||
prestigeChanceBonus: true,
|
prestigeChanceBonus: true,
|
||||||
prestigeKeyOpenReduction: true,
|
|
||||||
prestigeWearBonus: true,
|
prestigeWearBonus: true,
|
||||||
prestigeOpenBonus: true,
|
prestigeOpenBonus: true,
|
||||||
prestigeDropValueBonus: true,
|
prestigeCaseDiscount: true,
|
||||||
|
prestigeQualityBonus: true,
|
||||||
|
prestigeAnimReduction: true,
|
||||||
|
prestigeRefundChance: true,
|
||||||
|
prestigeRespinChance: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
||||||
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
|
|
||||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||||
dropValueBonus: user?.prestigeDropValueBonus ?? 0,
|
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
||||||
user,
|
user,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,6 +83,17 @@ async function loadKeyProgressMap(userId) {
|
|||||||
return new Map(rows.map((r) => [r.caseId, r]));
|
return new Map(rows.map((r) => [r.caseId, r]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rollOneDrop(weighted, user) {
|
||||||
|
const winner = pickWeighted(weighted);
|
||||||
|
const item = winner.caseItem.item;
|
||||||
|
const rolled = rollInstanceValue(
|
||||||
|
item.marketValue,
|
||||||
|
user.prestigeWearBonus ?? 0,
|
||||||
|
user.prestigeQualityBonus ?? 0
|
||||||
|
);
|
||||||
|
return { item, rolled };
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.session?.userId;
|
const userId = req.session?.userId;
|
||||||
@@ -86,16 +105,19 @@ router.get('/', async (req, res) => {
|
|||||||
orderBy: { price: 'asc' },
|
orderBy: { price: 'asc' },
|
||||||
});
|
});
|
||||||
const progressMap = await loadKeyProgressMap(userId);
|
const progressMap = await loadKeyProgressMap(userId);
|
||||||
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
|
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
||||||
res.json({
|
res.json({
|
||||||
catalogLuckPercent: luckPercent,
|
catalogLuckPercent: luckPercent,
|
||||||
cases: cases.map((c) => {
|
cases: cases.map((c) => {
|
||||||
const formatted = formatCase(c, luckPercent);
|
const unitPrice = userId
|
||||||
|
? applyCaseDiscount(c.price, caseDiscount)
|
||||||
|
: Number(c.price);
|
||||||
|
const formatted = formatCase(c, luckPercent, unitPrice);
|
||||||
if (!userId) return formatted;
|
if (!userId) return formatted;
|
||||||
const progress = progressMap.get(c.id);
|
const progress = progressMap.get(c.id);
|
||||||
return {
|
return {
|
||||||
...formatted,
|
...formatted,
|
||||||
keyProgress: serializeKeyProgress(progress, keyOpenReduction, openBonus),
|
keyProgress: serializeKeyProgress(progress, 0, openBonus),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -118,13 +140,16 @@ router.get('/:id', async (req, res) => {
|
|||||||
if (!c || !c.active) {
|
if (!c || !c.active) {
|
||||||
return res.status(404).json({ error: 'Case not found' });
|
return res.status(404).json({ error: 'Case not found' });
|
||||||
}
|
}
|
||||||
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
|
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
||||||
const formatted = formatCase(c, luckPercent);
|
const unitPrice = userId
|
||||||
|
? applyCaseDiscount(c.price, caseDiscount)
|
||||||
|
: Number(c.price);
|
||||||
|
const formatted = formatCase(c, luckPercent, unitPrice);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const progress = await prisma.caseKeyProgress.findUnique({
|
const progress = await prisma.caseKeyProgress.findUnique({
|
||||||
where: { userId_caseId: { userId, caseId: id } },
|
where: { userId_caseId: { userId, caseId: id } },
|
||||||
});
|
});
|
||||||
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction, openBonus);
|
formatted.keyProgress = serializeKeyProgress(progress, 0, openBonus);
|
||||||
}
|
}
|
||||||
res.json({ case: formatted });
|
res.json({ case: formatted });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -169,7 +194,6 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
const progressRow = await getOrCreateProgress(tx, userId, caseId);
|
const progressRow = await getOrCreateProgress(tx, userId, caseId);
|
||||||
const openBonus = user.prestigeOpenBonus ?? 0;
|
const openBonus = user.prestigeOpenBonus ?? 0;
|
||||||
const dropValueBonus = user.prestigeDropValueBonus ?? 0;
|
|
||||||
const maxOpens = maxOpensForKeys(progressRow.keys, openBonus);
|
const maxOpens = maxOpensForKeys(progressRow.keys, openBonus);
|
||||||
if (count > maxOpens) {
|
if (count > maxOpens) {
|
||||||
const err = new Error(`You can open at most ${maxOpens} at once for this case`);
|
const err = new Error(`You can open at most ${maxOpens} at once for this case`);
|
||||||
@@ -177,7 +201,8 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCost = c.price * count;
|
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0);
|
||||||
|
const totalCost = unitPrice * count;
|
||||||
if (user.balance < totalCost) {
|
if (user.balance < totalCost) {
|
||||||
const err = new Error('Insufficient balance');
|
const err = new Error('Insufficient balance');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
@@ -185,7 +210,6 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const catalogLuck = await countCompletedChapters(tx, userId);
|
const catalogLuck = await countCompletedChapters(tx, userId);
|
||||||
const keyOpenReduction = user.prestigeKeyOpenReduction ?? 0;
|
|
||||||
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
|
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
|
||||||
const weighted = applyCatalogLuck(
|
const weighted = applyCatalogLuck(
|
||||||
c.items.map((ci) => ({
|
c.items.map((ci) => ({
|
||||||
@@ -195,13 +219,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
luckPercent
|
luckPercent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function createDrops(batchCount) {
|
||||||
const drops = [];
|
const drops = [];
|
||||||
for (let i = 0; i < count; i += 1) {
|
const createdTxIds = [];
|
||||||
const winner = pickWeighted(weighted);
|
for (let i = 0; i < batchCount; i += 1) {
|
||||||
const item = winner.caseItem.item;
|
const { item, rolled } = rollOneDrop(weighted, user);
|
||||||
const rolled = rollInstanceValue(item.marketValue, user.prestigeWearBonus ?? 0);
|
|
||||||
rolled.valueCents = applyDropValueBonus(rolled.valueCents, dropValueBonus);
|
|
||||||
|
|
||||||
const inventoryItem = await tx.inventoryItem.create({
|
const inventoryItem = await tx.inventoryItem.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
@@ -215,11 +237,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
const willAutoSell = shouldAutoSell(user, rolled.valueCents);
|
const willAutoSell = shouldAutoSell(user, rolled.valueCents);
|
||||||
|
|
||||||
await tx.transaction.create({
|
const openTx = await tx.transaction.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
type: 'open_case',
|
type: 'open_case',
|
||||||
amount: -c.price,
|
amount: -unitPrice,
|
||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
caseId: c.id,
|
caseId: c.id,
|
||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
@@ -232,10 +254,13 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
valueCents: rolled.valueCents,
|
valueCents: rolled.valueCents,
|
||||||
willAutoSell,
|
willAutoSell,
|
||||||
openIndex: i,
|
openIndex: i,
|
||||||
openCount: count,
|
openCount: batchCount,
|
||||||
|
unitPrice,
|
||||||
|
basePrice: Number(c.price),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
createdTxIds.push(openTx.id);
|
||||||
|
|
||||||
drops.push({
|
drops.push({
|
||||||
inventoryId: inventoryItem.id,
|
inventoryId: inventoryItem.id,
|
||||||
@@ -251,6 +276,38 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
willAutoSell,
|
willAutoSell,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return { drops, createdTxIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { drops, createdTxIds } = await createDrops(count);
|
||||||
|
|
||||||
|
// Respin: re-roll all drops for free (replace first batch)
|
||||||
|
const respinChance = Math.max(0, Number(user.prestigeRespinChance) || 0);
|
||||||
|
const didRespin = respinChance > 0 && Math.random() * 100 < respinChance;
|
||||||
|
if (didRespin) {
|
||||||
|
const oldIds = drops.map((d) => d.inventoryId);
|
||||||
|
await tx.inventoryItem.deleteMany({ where: { id: { in: oldIds } } });
|
||||||
|
await tx.transaction.deleteMany({ where: { id: { in: createdTxIds } } });
|
||||||
|
const again = await createDrops(count);
|
||||||
|
drops = again.drops;
|
||||||
|
createdTxIds = again.createdTxIds;
|
||||||
|
await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'case_respin',
|
||||||
|
amount: 0,
|
||||||
|
meta: JSON.stringify({
|
||||||
|
caseId: c.id,
|
||||||
|
openCount: count,
|
||||||
|
inventoryItemIds: drops.map((d) => d.inventoryId),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund: chance to refund total cost
|
||||||
|
const refundChance = Math.max(0, Number(user.prestigeRefundChance) || 0);
|
||||||
|
const didRefund = refundChance > 0 && Math.random() * 100 < refundChance;
|
||||||
|
|
||||||
const catalog = await fillCatalogSlots(
|
const catalog = await fillCatalogSlots(
|
||||||
tx,
|
tx,
|
||||||
@@ -259,7 +316,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
drops.map((d) => d.id)
|
drops.map((d) => d.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
const applied = applyOpens(progressRow, count, keyOpenReduction);
|
const applied = applyOpens(progressRow, count, 0);
|
||||||
const updatedProgress = await tx.caseKeyProgress.update({
|
const updatedProgress = await tx.caseKeyProgress.update({
|
||||||
where: { id: progressRow.id },
|
where: { id: progressRow.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -269,10 +326,29 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let balanceDelta = -totalCost;
|
||||||
|
if (didRefund) {
|
||||||
|
balanceDelta += totalCost;
|
||||||
|
await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
type: 'case_refund',
|
||||||
|
amount: totalCost,
|
||||||
|
meta: JSON.stringify({
|
||||||
|
caseId: c.id,
|
||||||
|
caseName: c.name,
|
||||||
|
openCount: count,
|
||||||
|
unitPrice,
|
||||||
|
totalCost,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedUser = await tx.user.update({
|
const updatedUser = await tx.user.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
balance: { decrement: totalCost },
|
balance: { increment: balanceDelta },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,9 +356,14 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
user: publicUser(updatedUser),
|
user: publicUser(updatedUser),
|
||||||
caseName: c.name,
|
caseName: c.name,
|
||||||
items: drops,
|
items: drops,
|
||||||
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction, openBonus),
|
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
||||||
keysGained: applied.keysGained,
|
keysGained: applied.keysGained,
|
||||||
catalog,
|
catalog,
|
||||||
|
unitPrice,
|
||||||
|
totalCost,
|
||||||
|
refunded: didRefund,
|
||||||
|
respin: didRespin,
|
||||||
|
animReduction: user.prestigeAnimReduction ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,6 +378,11 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
|||||||
keysGained: result.keysGained,
|
keysGained: result.keysGained,
|
||||||
catalog: result.catalog,
|
catalog: result.catalog,
|
||||||
item: result.items[0],
|
item: result.items[0],
|
||||||
|
unitPrice: result.unitPrice,
|
||||||
|
totalCost: result.totalCost,
|
||||||
|
refunded: result.refunded,
|
||||||
|
respin: result.respin,
|
||||||
|
animReduction: result.animReduction,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ import { prisma } from '../db.js';
|
|||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import { applyAutoSell } from '../autoSell.js';
|
import { applyAutoSell } from '../autoSell.js';
|
||||||
import { applyGainMultiplier } from '../prestige.js';
|
import { applyGainMultiplier } from '../prestige.js';
|
||||||
|
import {
|
||||||
|
maxVaultSlots,
|
||||||
|
summarizeVaultRates,
|
||||||
|
estimatePendingYield,
|
||||||
|
} from '../vault.js';
|
||||||
|
import { isConnected } from '../presence.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -11,6 +17,9 @@ function serializeInv(inv) {
|
|||||||
id: inv.id,
|
id: inv.id,
|
||||||
obtainedAt: inv.obtainedAt,
|
obtainedAt: inv.obtainedAt,
|
||||||
locked: inv.locked,
|
locked: inv.locked,
|
||||||
|
staked: Boolean(inv.staked),
|
||||||
|
stakedAt: inv.stakedAt || null,
|
||||||
|
lastYieldAt: inv.lastYieldAt || null,
|
||||||
floatValue: inv.floatValue,
|
floatValue: inv.floatValue,
|
||||||
wear: inv.wear,
|
wear: inv.wear,
|
||||||
paintSeed: inv.paintSeed,
|
paintSeed: inv.paintSeed,
|
||||||
@@ -22,7 +31,6 @@ function serializeInv(inv) {
|
|||||||
imageUrl: inv.item.imageUrl,
|
imageUrl: inv.item.imageUrl,
|
||||||
rarity: inv.item.rarity,
|
rarity: inv.item.rarity,
|
||||||
marketValue: inv.item.marketValue,
|
marketValue: inv.item.marketValue,
|
||||||
// Instance price for UI (prefer over catalog FT reference)
|
|
||||||
valueCents: inv.valueCents,
|
valueCents: inv.valueCents,
|
||||||
floatValue: inv.floatValue,
|
floatValue: inv.floatValue,
|
||||||
wear: inv.wear,
|
wear: inv.wear,
|
||||||
@@ -33,14 +41,29 @@ function serializeInv(inv) {
|
|||||||
|
|
||||||
router.get('/', requireAuth, async (req, res) => {
|
router.get('/', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const items = await prisma.inventoryItem.findMany({
|
const userId = req.session.userId;
|
||||||
where: { userId: req.session.userId, locked: false },
|
const [user, items] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId } }),
|
||||||
|
prisma.inventoryItem.findMany({
|
||||||
|
where: { userId, locked: false },
|
||||||
include: { item: true },
|
include: { item: true },
|
||||||
orderBy: { obtainedAt: 'desc' },
|
orderBy: { obtainedAt: 'desc' },
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const staked = items.filter((i) => i.staked);
|
||||||
|
const mode = isConnected(userId) ? 'online' : 'offline';
|
||||||
|
const rates = summarizeVaultRates(staked, user);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
inventory: items.map(serializeInv),
|
inventory: items.map(serializeInv),
|
||||||
|
vault: {
|
||||||
|
used: staked.length,
|
||||||
|
slots: maxVaultSlots(user),
|
||||||
|
...rates,
|
||||||
|
pendingYield: estimatePendingYield(staked, user, mode),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -98,6 +121,7 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
|
|||||||
id: { in: ids },
|
id: { in: ids },
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
locked: false,
|
locked: false,
|
||||||
|
staked: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,12 +174,13 @@ router.post('/sell', requireAuth, async (req, res) => {
|
|||||||
id: { in: ids },
|
id: { in: ids },
|
||||||
userId: req.session.userId,
|
userId: req.session.userId,
|
||||||
locked: false,
|
locked: false,
|
||||||
|
staked: false,
|
||||||
},
|
},
|
||||||
include: { item: true },
|
include: { item: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (invItems.length !== ids.length) {
|
if (invItems.length !== ids.length) {
|
||||||
const err = new Error('Invalid or locked inventory items');
|
const err = new Error('Invalid, locked, or staked inventory items');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
const pageVisible = isPrestigePageVisible(user, totalCents);
|
const pageVisible = isPrestigePageVisible(user, totalCents);
|
||||||
const canAfford = balance >= COST;
|
const canAfford = balance >= COST;
|
||||||
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
||||||
const previewPr = canAfford
|
const previewPr = canAfford ? estimatePrReward(user.balance) : 0;
|
||||||
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
prestige: serializePrestige(user),
|
prestige: serializePrestige(user),
|
||||||
@@ -69,8 +67,14 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
router.post('/', requireAuth, async (req, res) => {
|
router.post('/', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
// Pay out pending vault yield before wipe (own transaction)
|
||||||
|
const { settleVaultYield } = await import('../vault.js');
|
||||||
|
await settleVaultYield(userId, 'offline');
|
||||||
|
|
||||||
const result = await prisma.$transaction(async (tx) => {
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
const user = await tx.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
const err = new Error('User not found');
|
const err = new Error('User not found');
|
||||||
err.status = 401;
|
err.status = 401;
|
||||||
@@ -98,10 +102,8 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prGranted = computePrReward(user.balance, user.prestigePrBonus ?? 0);
|
const prGranted = computePrReward(user.balance);
|
||||||
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
const prestigeCount = (user.prestigeCount ?? 0) + 1;
|
||||||
const startBalance = Math.max(0, Number(user.prestigeStartBonus) || 0);
|
|
||||||
const startOsu = Math.max(0, Number(user.prestigeStartOsu) || 0);
|
|
||||||
|
|
||||||
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
await tx.inventoryItem.deleteMany({ where: { userId: user.id } });
|
||||||
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
await tx.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
||||||
@@ -116,8 +118,6 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
prestigeCount,
|
prestigeCount,
|
||||||
prGranted,
|
prGranted,
|
||||||
previousBalance: Number(user.balance),
|
previousBalance: Number(user.balance),
|
||||||
startBalance,
|
|
||||||
startOsu,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -134,8 +134,7 @@ router.post('/', requireAuth, async (req, res) => {
|
|||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: {
|
||||||
prestigeCount,
|
prestigeCount,
|
||||||
balance: startBalance,
|
balance: 0,
|
||||||
...(startOsu > 0 ? { osuBalance: { increment: startOsu } } : {}),
|
|
||||||
prBalance: { increment: prGranted },
|
prBalance: { increment: prGranted },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|||||||
import { prisma } from '../db.js';
|
import { prisma } from '../db.js';
|
||||||
import { requireAuth, publicUser } from '../middleware.js';
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
import { checkAchievements } from '../achievements.js';
|
import { checkAchievements } from '../achievements.js';
|
||||||
import { adCooldownMs, applyAdRewardBonus, shopOsuCost } from '../prestige.js';
|
import { adCooldownMs } from '../prestige.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -22,11 +22,10 @@ function adPayload(user, overrides = {}) {
|
|||||||
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
||||||
const nextAdAt = lastAdAt + cooldownMs;
|
const nextAdAt = lastAdAt + cooldownMs;
|
||||||
const adReady = Date.now() >= nextAdAt;
|
const adReady = Date.now() >= nextAdAt;
|
||||||
const adBonus = user.prestigeAdBonus ?? 0;
|
|
||||||
return {
|
return {
|
||||||
cooldownMs,
|
cooldownMs,
|
||||||
rewardMin: applyAdRewardBonus(AD_REWARD_MIN, adBonus),
|
rewardMin: AD_REWARD_MIN,
|
||||||
rewardMax: applyAdRewardBonus(AD_REWARD_MAX, adBonus),
|
rewardMax: AD_REWARD_MAX,
|
||||||
ready: adReady,
|
ready: adReady,
|
||||||
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
|
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
|
||||||
lastAdAt: user.lastAdAt,
|
lastAdAt: user.lastAdAt,
|
||||||
@@ -39,10 +38,8 @@ router.get('/', requireAuth, async (req, res) => {
|
|||||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
const discount = user.prestigeShopDiscount ?? 0;
|
|
||||||
const packages = SHOP_PACKAGES.map((pack) => ({
|
const packages = SHOP_PACKAGES.map((pack) => ({
|
||||||
...pack,
|
...pack,
|
||||||
osuCost: shopOsuCost(pack.osuCost, discount),
|
|
||||||
baseOsuCost: pack.osuCost,
|
baseOsuCost: pack.osuCost,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -72,7 +69,7 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
err.status = 404;
|
err.status = 404;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const osuCost = shopOsuCost(pack.osuCost, user.prestigeShopDiscount ?? 0);
|
const osuCost = pack.osuCost;
|
||||||
if (user.osuBalance < osuCost) {
|
if (user.osuBalance < osuCost) {
|
||||||
const err = new Error('Not enough osu');
|
const err = new Error('Not enough osu');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
@@ -95,7 +92,6 @@ router.post('/buy', requireAuth, async (req, res) => {
|
|||||||
meta: JSON.stringify({
|
meta: JSON.stringify({
|
||||||
packageId: pack.id,
|
packageId: pack.id,
|
||||||
osuCost,
|
osuCost,
|
||||||
baseOsuCost: pack.osuCost,
|
|
||||||
credits: pack.credits,
|
credits: pack.credits,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -138,9 +134,8 @@ router.post('/ad', requireAuth, async (req, res) => {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base =
|
const reward =
|
||||||
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
|
||||||
const reward = applyAdRewardBonus(base, user.prestigeAdBonus ?? 0);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const updated = await tx.user.update({
|
const updated = await tx.user.update({
|
||||||
@@ -156,7 +151,7 @@ router.post('/ad', requireAuth, async (req, res) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: 'ad_reward',
|
type: 'ad_reward',
|
||||||
amount: reward,
|
amount: reward,
|
||||||
meta: JSON.stringify({ reward, base, adBonus: user.prestigeAdBonus ?? 0 }),
|
meta: JSON.stringify({ reward }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { prisma } from '../db.js';
|
||||||
|
import { requireAuth, publicUser } from '../middleware.js';
|
||||||
|
import {
|
||||||
|
maxVaultSlots,
|
||||||
|
settleVaultYield,
|
||||||
|
summarizeVaultRates,
|
||||||
|
estimatePendingYield,
|
||||||
|
} from '../vault.js';
|
||||||
|
import { isConnected } from '../presence.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/stake', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||||
|
if (!ids.length) {
|
||||||
|
return res.status(400).json({ error: 'Select at least one item to stake' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||||
|
if (!user) {
|
||||||
|
const err = new Error('User not found');
|
||||||
|
err.status = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSlots = maxVaultSlots(user);
|
||||||
|
const used = await tx.inventoryItem.count({
|
||||||
|
where: { userId: user.id, staked: true },
|
||||||
|
});
|
||||||
|
const free = maxSlots - used;
|
||||||
|
if (ids.length > free) {
|
||||||
|
const err = new Error(`Vault full (${used}/${maxSlots} slots)`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
userId: user.id,
|
||||||
|
locked: false,
|
||||||
|
staked: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (invItems.length !== ids.length) {
|
||||||
|
const err = new Error('Invalid, locked, or already staked items');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await tx.inventoryItem.updateMany({
|
||||||
|
where: { id: { in: ids }, userId: user.id },
|
||||||
|
data: {
|
||||||
|
staked: true,
|
||||||
|
stakedAt: now,
|
||||||
|
lastYieldAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, stakedIds: ids, maxSlots, used: used + ids.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: publicUser(result.user),
|
||||||
|
stakedIds: result.stakedIds,
|
||||||
|
vault: {
|
||||||
|
used: result.used,
|
||||||
|
slots: result.maxSlots,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to stake items' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/unstake', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
|
||||||
|
if (!ids.length) {
|
||||||
|
return res.status(400).json({ error: 'Select at least one item to unstake' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle yield before unstake (online if connected, else offline)
|
||||||
|
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||||
|
const settled = await settleVaultYield(req.session.userId, mode);
|
||||||
|
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||||
|
if (!user) {
|
||||||
|
const err = new Error('User not found');
|
||||||
|
err.status = 401;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: ids },
|
||||||
|
userId: user.id,
|
||||||
|
staked: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (invItems.length !== ids.length) {
|
||||||
|
const err = new Error('Invalid or not-staked items');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.inventoryItem.updateMany({
|
||||||
|
where: { id: { in: ids }, userId: user.id },
|
||||||
|
data: {
|
||||||
|
staked: false,
|
||||||
|
stakedAt: null,
|
||||||
|
lastYieldAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const used = await tx.inventoryItem.count({
|
||||||
|
where: { userId: user.id, staked: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshed = await tx.user.findUnique({ where: { id: user.id } });
|
||||||
|
return {
|
||||||
|
user: refreshed,
|
||||||
|
unstakedIds: ids,
|
||||||
|
maxSlots: maxVaultSlots(refreshed),
|
||||||
|
used,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: publicUser(result.user),
|
||||||
|
unstakedIds: result.unstakedIds,
|
||||||
|
yieldCredited: settled.credited,
|
||||||
|
vault: {
|
||||||
|
used: result.used,
|
||||||
|
slots: result.maxSlots,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.status || 500;
|
||||||
|
if (status >= 500) console.error(err);
|
||||||
|
res.status(status).json({ error: err.message || 'Failed to unstake items' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/claim', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
|
||||||
|
const settled = await settleVaultYield(req.session.userId, mode);
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const staked = await prisma.inventoryItem.findMany({
|
||||||
|
where: { userId: user.id, staked: true },
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
|
const rates = summarizeVaultRates(staked, user);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
user: publicUser(user),
|
||||||
|
credited: settled.credited,
|
||||||
|
ticks: settled.ticks,
|
||||||
|
vault: {
|
||||||
|
used: staked.length,
|
||||||
|
slots: maxVaultSlots(user),
|
||||||
|
...rates,
|
||||||
|
pendingYield: estimatePendingYield(staked, user, mode),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to claim vault yield' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -241,11 +241,11 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invItems = await tx.inventoryItem.findMany({
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
where: { id: { in: ids }, userId, locked: false },
|
where: { id: { in: ids }, userId, locked: false, staked: false },
|
||||||
include: { item: true },
|
include: { item: true },
|
||||||
});
|
});
|
||||||
if (invItems.length !== ids.length) {
|
if (invItems.length !== ids.length) {
|
||||||
throw Object.assign(new Error('Invalid or locked inventory items'), { status: 400 });
|
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const inv of invItems) {
|
for (const inv of invItems) {
|
||||||
|
|||||||
@@ -248,11 +248,11 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const invItems = await tx.inventoryItem.findMany({
|
const invItems = await tx.inventoryItem.findMany({
|
||||||
where: { id: { in: ids }, userId, locked: false },
|
where: { id: { in: ids }, userId, locked: false, staked: false },
|
||||||
include: { item: true },
|
include: { item: true },
|
||||||
});
|
});
|
||||||
if (invItems.length !== ids.length) {
|
if (invItems.length !== ids.length) {
|
||||||
throw Object.assign(new Error('Invalid or locked inventory items'), { status: 400 });
|
throw Object.assign(new Error('Invalid, locked, or staked inventory items'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const inv of invItems) {
|
for (const inv of invItems) {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { prisma } from './db.js';
|
||||||
|
|
||||||
|
export const BASE_VAULT_SLOTS = 3;
|
||||||
|
export const VAULT_TICK_MS = 600_000; // 10 minutes
|
||||||
|
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
|
||||||
|
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
|
||||||
|
export const WEAR_FLOAT_K = 3;
|
||||||
|
|
||||||
|
/** Rarity → yield multiplier (seed rarities + fallbacks). */
|
||||||
|
export const RARITY_YIELD_MULT = {
|
||||||
|
Consumer: 0.8,
|
||||||
|
Industrial: 0.9,
|
||||||
|
MilSpec: 1.0,
|
||||||
|
Restricted: 1.15,
|
||||||
|
Classified: 1.35,
|
||||||
|
Covert: 1.55,
|
||||||
|
Extraordinary: 1.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function maxVaultSlots(user) {
|
||||||
|
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wearYieldMult(floatValue) {
|
||||||
|
const f = Math.min(1, Math.max(0, Number(floatValue) || 0));
|
||||||
|
return 1 + f * f * WEAR_FLOAT_K;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rarityYieldMult(rarity) {
|
||||||
|
const key = String(rarity || '');
|
||||||
|
return RARITY_YIELD_MULT[key] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skillYieldMult(user, mode) {
|
||||||
|
const pct =
|
||||||
|
mode === 'online'
|
||||||
|
? Number(user?.prestigeOnlineYield) || 0
|
||||||
|
: Number(user?.prestigeOfflineYield) || 0;
|
||||||
|
if (pct <= 0) return 1;
|
||||||
|
return 1 + pct / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tickPayoutCents(inv, user, mode) {
|
||||||
|
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0));
|
||||||
|
if (value <= 0) return 0;
|
||||||
|
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
|
||||||
|
const wear = wearYieldMult(inv.floatValue);
|
||||||
|
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
|
||||||
|
const skill = skillYieldMult(user, mode);
|
||||||
|
const payout = Math.floor(value * baseRate * wear * rarity * skill);
|
||||||
|
// Soft cap: at most 100% of item value per tick
|
||||||
|
return Math.min(value, Math.max(0, payout));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateTickYield(inv, user, mode) {
|
||||||
|
return tickPayoutCents(inv, user, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle complete vault ticks for a user.
|
||||||
|
* @param {'online'|'offline'} mode
|
||||||
|
* @param {Date|number} [until] settle up to this time (default now)
|
||||||
|
* @returns {{ credited: number, ticks: number, itemIds: number[] }}
|
||||||
|
*/
|
||||||
|
export async function settleVaultYield(userId, mode, until = Date.now()) {
|
||||||
|
const id = Number(userId);
|
||||||
|
if (!id) return { credited: 0, ticks: 0, itemIds: [] };
|
||||||
|
|
||||||
|
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
|
||||||
|
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.findUnique({ where: { id } });
|
||||||
|
if (!user) return { credited: 0, ticks: 0, itemIds: [] };
|
||||||
|
|
||||||
|
const items = await tx.inventoryItem.findMany({
|
||||||
|
where: { userId: id, staked: true },
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
|
if (!items.length) return { credited: 0, ticks: 0, itemIds: [] };
|
||||||
|
|
||||||
|
let totalCredit = 0;
|
||||||
|
let maxTicks = 0;
|
||||||
|
const itemIds = [];
|
||||||
|
const now = new Date(untilMs);
|
||||||
|
|
||||||
|
for (const inv of items) {
|
||||||
|
const anchor = inv.lastYieldAt || inv.stakedAt || now;
|
||||||
|
const anchorMs = new Date(anchor).getTime();
|
||||||
|
const elapsed = Math.max(0, untilMs - anchorMs);
|
||||||
|
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
|
||||||
|
if (ticks <= 0) continue;
|
||||||
|
|
||||||
|
const perTick = tickPayoutCents(inv, user, mode);
|
||||||
|
const credit = perTick * ticks;
|
||||||
|
if (credit > 0) {
|
||||||
|
totalCredit += credit;
|
||||||
|
itemIds.push(inv.id);
|
||||||
|
}
|
||||||
|
maxTicks = Math.max(maxTicks, ticks);
|
||||||
|
|
||||||
|
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
|
||||||
|
await tx.inventoryItem.update({
|
||||||
|
where: { id: inv.id },
|
||||||
|
data: { lastYieldAt: advanced },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCredit <= 0) {
|
||||||
|
return { credited: 0, ticks: maxTicks, itemIds: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { balance: { increment: totalCredit } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.transaction.create({
|
||||||
|
data: {
|
||||||
|
userId: id,
|
||||||
|
type: 'vault_yield',
|
||||||
|
amount: totalCredit,
|
||||||
|
meta: JSON.stringify({
|
||||||
|
mode,
|
||||||
|
ticks: maxTicks,
|
||||||
|
inventoryItemIds: itemIds,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { credited: totalCredit, ticks: maxTicks, itemIds };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate pending yield for UI (incomplete ticks not included). */
|
||||||
|
export function estimatePendingYield(items, user, mode, now = Date.now()) {
|
||||||
|
let total = 0;
|
||||||
|
for (const inv of items) {
|
||||||
|
const anchor = inv.lastYieldAt || inv.stakedAt;
|
||||||
|
if (!anchor) continue;
|
||||||
|
const elapsed = Math.max(0, now - new Date(anchor).getTime());
|
||||||
|
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
|
||||||
|
if (ticks <= 0) continue;
|
||||||
|
total += tickPayoutCents(inv, user, mode) * ticks;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-tick rate summary for UI (one tick online / offline). */
|
||||||
|
export function summarizeVaultRates(items, user) {
|
||||||
|
let onlinePerTick = 0;
|
||||||
|
let offlinePerTick = 0;
|
||||||
|
for (const inv of items) {
|
||||||
|
onlinePerTick += tickPayoutCents(inv, user, 'online');
|
||||||
|
offlinePerTick += tickPayoutCents(inv, user, 'offline');
|
||||||
|
}
|
||||||
|
return { onlinePerTick, offlinePerTick, tickMs: VAULT_TICK_MS };
|
||||||
|
}
|
||||||
+14
-1
@@ -110,13 +110,26 @@ export function rollPaintSeed() {
|
|||||||
return Math.floor(Math.random() * 1000);
|
return Math.floor(Math.random() * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quality skill: pull floats below Field-Tested (WW/BS, float ≥ 0.38)
|
||||||
|
* toward FT band. Clamp so result stays ≥ FT min (0.15).
|
||||||
|
*/
|
||||||
|
export function applyQualityUpgrade(floatValue, qualityBonus = 0) {
|
||||||
|
let f = clampFloat(floatValue);
|
||||||
|
const levels = Math.max(0, Math.floor(Number(qualityBonus) || 0));
|
||||||
|
if (levels <= 0 || f < 0.38) return f;
|
||||||
|
f = clampFloat(f - levels * 0.02);
|
||||||
|
return Math.max(0.15, f);
|
||||||
|
}
|
||||||
|
|
||||||
/** Full roll for a new inventory instance from catalog marketValue */
|
/** Full roll for a new inventory instance from catalog marketValue */
|
||||||
export function rollInstanceValue(baseMarketValue, wearBonus = 0) {
|
export function rollInstanceValue(baseMarketValue, wearBonus = 0, qualityBonus = 0) {
|
||||||
let floatValue = rollFloat();
|
let floatValue = rollFloat();
|
||||||
const bias = Math.min(0.55, Math.max(0, Number(wearBonus) || 0) * 0.008);
|
const bias = Math.min(0.55, Math.max(0, Number(wearBonus) || 0) * 0.008);
|
||||||
if (bias > 0) {
|
if (bias > 0) {
|
||||||
floatValue = clampFloat(floatValue * (1 - bias));
|
floatValue = clampFloat(floatValue * (1 - bias));
|
||||||
}
|
}
|
||||||
|
floatValue = applyQualityUpgrade(floatValue, qualityBonus);
|
||||||
return {
|
return {
|
||||||
floatValue,
|
floatValue,
|
||||||
wear: wearFromFloat(floatValue),
|
wear: wearFromFloat(floatValue),
|
||||||
|
|||||||
Reference in New Issue
Block a user