cool prestige update

This commit is contained in:
gpatruno
2026-07-19 00:29:46 +02:00
parent d0f868ffa5
commit 3a8871cbae
27 changed files with 1165 additions and 353 deletions
+4 -4
View File
@@ -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 `01` 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 +1530%, FT mid ≈ catalog, WW down to 40%, BS lower) - **Wear (float)**: each case drop gets a random float `01` 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 +1530%, 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 **1000000000000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100000000000.00** cr excess); spend Pr on a skill tree (chance / sell gain / key progress) - **Prestige**: pay **1000000000000.00** cr to reset; earn **Pr** (1000 base + ~1000 per **100000000000.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`
+9 -7
View File
@@ -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
View File
@@ -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 → "1234.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)}%`;
} }
+11 -4
View File
@@ -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',
}} }}
> >
+16 -5
View File
@@ -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>
); );
+12 -3
View File
@@ -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 ? (
+52 -60
View File
@@ -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>
); );
+42
View File
@@ -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;
+17 -2
View File
@@ -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
+157 -3
View File
@@ -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 &amp; 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)}
/> />
+36 -36
View File
@@ -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 &amp; vault stakes</li>
<li>Case keys &amp; catalog progress</li> <li>Case keys &amp; catalog progress</li>
</ul> </ul>
</div> </div>
+11
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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);
} }
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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). */
+19 -1
View File
@@ -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' });
+152 -66
View File
@@ -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,63 +219,96 @@ router.post('/:id/open', requireAuth, async (req, res) => {
luckPercent luckPercent
); );
const drops = []; async function createDrops(batchCount) {
for (let i = 0; i < count; i += 1) { const drops = [];
const winner = pickWeighted(weighted); const createdTxIds = [];
const item = winner.caseItem.item; for (let i = 0; i < batchCount; i += 1) {
const rolled = rollInstanceValue(item.marketValue, user.prestigeWearBonus ?? 0); const { item, rolled } = rollOneDrop(weighted, user);
rolled.valueCents = applyDropValueBonus(rolled.valueCents, dropValueBonus); const inventoryItem = await tx.inventoryItem.create({
data: {
const inventoryItem = await tx.inventoryItem.create({ userId,
data: {
userId,
itemId: item.id,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
},
});
const willAutoSell = shouldAutoSell(user, rolled.valueCents);
await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -c.price,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
itemId: item.id, itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
floatValue: rolled.floatValue, floatValue: rolled.floatValue,
wear: rolled.wear, wear: rolled.wear,
paintSeed: rolled.paintSeed, paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents, valueCents: rolled.valueCents,
willAutoSell, },
openIndex: i, });
const willAutoSell = shouldAutoSell(user, rolled.valueCents);
const openTx = await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -unitPrice,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
willAutoSell,
openIndex: i,
openCount: batchCount,
unitPrice,
basePrice: Number(c.price),
}),
},
});
createdTxIds.push(openTx.id);
drops.push({
inventoryId: inventoryItem.id,
id: item.id,
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
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, openCount: count,
inventoryItemIds: drops.map((d) => d.inventoryId),
}), }),
}, },
}); });
drops.push({
inventoryId: inventoryItem.id,
id: item.id,
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
floatValue: rolled.floatValue,
wear: rolled.wear,
paintSeed: rolled.paintSeed,
valueCents: rolled.valueCents,
willAutoSell,
});
} }
// 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,
userId, userId,
@@ -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);
+32 -7
View File
@@ -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([
include: { item: true }, prisma.user.findUnique({ where: { id: userId } }),
orderBy: { obtainedAt: 'desc' }, prisma.inventoryItem.findMany({
}); where: { userId, locked: false },
include: { item: true },
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;
} }
+10 -11
View File
@@ -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 },
}, },
}); });
+6 -11
View File
@@ -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 }),
}, },
}); });
+182
View File
@@ -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;
+2 -2
View File
@@ -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) {
+2 -2
View File
@@ -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) {
+157
View File
@@ -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
View File
@@ -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),