cool prestige update
This commit is contained in:
@@ -47,7 +47,7 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
||||
## Features
|
||||
|
||||
- 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
|
||||
- **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
|
||||
@@ -61,7 +61,7 @@ New registrations receive **100.00** starting credits and **100 osu** (`STARTING
|
||||
- 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
|
||||
- **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
|
||||
- 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
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
- 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`
|
||||
|
||||
@@ -37,10 +37,12 @@ export default function AdminDrops() {
|
||||
[cases, selectedCaseId]
|
||||
);
|
||||
|
||||
const percents = useMemo(
|
||||
() => (selected ? dropPercents(selected.items) : []),
|
||||
[selected]
|
||||
);
|
||||
const poolItems = useMemo(() => {
|
||||
if (!selected) return [];
|
||||
return [...selected.items].sort((a, b) => Number(a.dropRate) - Number(b.dropRate));
|
||||
}, [selected]);
|
||||
|
||||
const percents = useMemo(() => dropPercents(poolItems), [poolItems]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -116,12 +118,12 @@ export default function AdminDrops() {
|
||||
</div>
|
||||
|
||||
<div className="admin-block">
|
||||
<h3>Pool ({selected.items.length})</h3>
|
||||
{selected.items.length === 0 ? (
|
||||
<h3>Pool ({poolItems.length})</h3>
|
||||
{poolItems.length === 0 ? (
|
||||
<div className="muted">No drops yet.</div>
|
||||
) : (
|
||||
<div className="drop-list">
|
||||
{selected.items.map((ci, idx) => (
|
||||
{poolItems.map((ci, idx) => (
|
||||
<div key={ci.id} className="drop-row">
|
||||
<span className="drop-item-cell">
|
||||
{ci.item.imageUrl ? (
|
||||
|
||||
+65
-2
@@ -46,6 +46,17 @@ export const api = {
|
||||
body: JSON.stringify({ count }),
|
||||
}),
|
||||
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) =>
|
||||
request('/api/inventory/sell', {
|
||||
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 body = neg ? fixed.slice(1) : fixed;
|
||||
const [intPart, dec] = body.split('.');
|
||||
@@ -183,6 +199,53 @@ export function formatCredits(cents) {
|
||||
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) {
|
||||
return `${Number(pct || 0).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@ import { rarityColor } from '../rarities';
|
||||
|
||||
const ITEM_WIDTH = 120;
|
||||
const ITEM_WIDTH_COMPACT = 72;
|
||||
const SPIN_DURATION_MS = 5500;
|
||||
const BASE_SPIN_DURATION_MS = 5500;
|
||||
const SPIN_SLOTS = 42;
|
||||
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) {
|
||||
if (!items.length) return null;
|
||||
if (items.length === 1) return items[0];
|
||||
@@ -78,6 +83,7 @@ export default function CaseReel({
|
||||
onDone,
|
||||
showOwners = false,
|
||||
compact = false,
|
||||
animReduction = 0,
|
||||
}) {
|
||||
const trackRef = useRef(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
@@ -85,6 +91,7 @@ export default function CaseReel({
|
||||
const onDoneRef = useRef(onDone);
|
||||
onDoneRef.current = onDone;
|
||||
const itemWidth = compact ? ITEM_WIDTH_COMPACT : ITEM_WIDTH;
|
||||
const durationMs = spinDurationMs(animReduction);
|
||||
|
||||
const spinKey =
|
||||
winnerId && items?.length ? `${winnerId}:${items.map((i) => i.id).join(',')}` : '';
|
||||
@@ -122,7 +129,7 @@ export default function CaseReel({
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
onDoneRef.current?.();
|
||||
}, SPIN_DURATION_MS + 80);
|
||||
}, durationMs + 80);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
@@ -131,7 +138,7 @@ export default function CaseReel({
|
||||
};
|
||||
// intentionally only re-run when a new spin starts
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [spinning, winnerId, itemWidth]);
|
||||
}, [spinning, winnerId, itemWidth, durationMs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -144,7 +151,7 @@ export default function CaseReel({
|
||||
style={{
|
||||
transform: `translateX(-${offset}px)`,
|
||||
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',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatCredits } from '../api';
|
||||
import { formatCredits, formatCreditsExact } from '../api';
|
||||
import { rarityColor } from '../rarities';
|
||||
|
||||
/** Instance sell/bet price, else catalog FT reference */
|
||||
@@ -22,6 +22,7 @@ export default function ItemTile({
|
||||
showWear = true,
|
||||
selected = false,
|
||||
favorite = false,
|
||||
staked = false,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}) {
|
||||
@@ -37,16 +38,23 @@ export default function ItemTile({
|
||||
}`
|
||||
: 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 (
|
||||
<div
|
||||
className={`item-tile${selected ? ' selected' : ''}${favorite ? ' favorite' : ''}${
|
||||
clickable ? ' selectable' : ''
|
||||
}${disabled ? ' disabled' : ''}`}
|
||||
staked ? ' staked' : ''
|
||||
}${clickable ? ' selectable' : ''}${disabled ? ' disabled' : ''}`}
|
||||
style={{ borderTopColor: color }}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable && !disabled ? 0 : undefined}
|
||||
title={favorite ? `Favorite · ${floatTitle || item.name}` : floatTitle}
|
||||
title={titleParts.join(' · ')}
|
||||
onKeyDown={
|
||||
clickable && !disabled
|
||||
? (e) => {
|
||||
@@ -60,6 +68,7 @@ export default function ItemTile({
|
||||
>
|
||||
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
|
||||
{favorite ? <span className="item-favorite-badge" aria-label="Favorite">★</span> : null}
|
||||
{staked ? <span className="item-vault-badge" aria-label="Vault">V</span> : null}
|
||||
{hasImage ? (
|
||||
<img src={item.imageUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
@@ -76,7 +85,9 @@ export default function ItemTile({
|
||||
</div>
|
||||
) : null}
|
||||
{showValue && (
|
||||
<div className="item-meta">{formatCredits(value)} cr</div>
|
||||
<div className="item-meta" title={`${formatCreditsExact(value)} cr`}>
|
||||
{formatCredits(value)} cr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { api, formatCredits } from '../api';
|
||||
import { api, formatCredits, formatCreditsExact } from '../api';
|
||||
import DropFeed from './DropFeed';
|
||||
import SessionLimitOverlay from './SessionLimitOverlay';
|
||||
import { onInventoryChanged } from '../inventoryEvents';
|
||||
@@ -213,11 +213,20 @@ export default function Layout() {
|
||||
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
|
||||
{user && (
|
||||
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
|
||||
</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 ? (
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
import { formatCredits } from '../api';
|
||||
|
||||
export default function ProfilePrestigeStats({ prestige }) {
|
||||
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 (
|
||||
<div className="prestige-profile-block">
|
||||
<div className="catalog-badges-head">
|
||||
@@ -10,65 +56,11 @@ export default function ProfilePrestigeStats({ prestige }) {
|
||||
<span className="catalog-badges-luck">×{prestige.count}</span>
|
||||
</div>
|
||||
<ul className="stat-list prestige-profile-stats">
|
||||
{typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && (
|
||||
<li>
|
||||
Pr <strong>{prestige.prBalance}</strong>
|
||||
{rows.map((row) => (
|
||||
<li key={row.label}>
|
||||
{row.label} {row.value}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1841,6 +1841,48 @@ a.balance-pill-link.active {
|
||||
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 {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -27,6 +27,8 @@ export default function CasePage() {
|
||||
const [error, setError] = useState('');
|
||||
const [keyMessage, setKeyMessage] = useState('');
|
||||
const [catalogMessage, setCatalogMessage] = useState('');
|
||||
const [openBonusMessage, setOpenBonusMessage] = useState('');
|
||||
const [animReduction, setAnimReduction] = useState(0);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [spins, setSpins] = useState([]);
|
||||
@@ -243,6 +245,7 @@ export default function CasePage() {
|
||||
setError('');
|
||||
setKeyMessage('');
|
||||
setCatalogMessage('');
|
||||
setOpenBonusMessage('');
|
||||
setSpins([]);
|
||||
setOpening(true);
|
||||
announcedRef.current = false;
|
||||
@@ -253,6 +256,13 @@ export default function CasePage() {
|
||||
updateBalance(result.user.balance);
|
||||
notifyInventoryChanged();
|
||||
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) {
|
||||
setCrate((prev) => (prev ? { ...prev, keyProgress: result.keyProgress } : prev));
|
||||
}
|
||||
@@ -352,14 +362,18 @@ export default function CasePage() {
|
||||
{error && <div className="error">{error}</div>}
|
||||
{keyMessage && <div className="ok-msg">{keyMessage}</div>}
|
||||
{catalogMessage && <div className="ok-msg">{catalogMessage}</div>}
|
||||
{openBonusMessage && <div className="ok-msg">{openBonusMessage}</div>}
|
||||
|
||||
{crate && (
|
||||
<>
|
||||
<div className="case-hero">
|
||||
<h1>{crate.name}</h1>
|
||||
<p className="muted">
|
||||
Cost {formatCredits(crate.price)} cr each · Balance{' '}
|
||||
{formatCredits(user.balance)} cr
|
||||
Cost {formatCredits(crate.price)} cr each
|
||||
{crate.basePrice != null && Number(crate.basePrice) !== Number(crate.price)
|
||||
? ` (was ${formatCredits(crate.basePrice)})`
|
||||
: ''}{' '}
|
||||
· Balance {formatCredits(user.balance)} cr
|
||||
{crate.catalogLuckPercent > 0
|
||||
? ` · Catalog luck +${crate.catalogLuckPercent}%`
|
||||
: ''}
|
||||
@@ -435,6 +449,7 @@ export default function CasePage() {
|
||||
winnerId={spin.item.id}
|
||||
spinning={spin.spinning}
|
||||
onDone={() => onReelDone(spin.key)}
|
||||
animReduction={animReduction}
|
||||
/>
|
||||
{spin.done && (
|
||||
<div
|
||||
|
||||
@@ -11,6 +11,8 @@ const PAGE_SIZE = 50;
|
||||
export default function Dashboard() {
|
||||
const { user, loading, patchUser } = useAuth();
|
||||
const [inventory, setInventory] = useState([]);
|
||||
const [vault, setVault] = useState(null);
|
||||
const [vaultMessage, setVaultMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [sort, setSort] = useState('value');
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -37,6 +39,7 @@ export default function Dashboard() {
|
||||
const load = async () => {
|
||||
const inv = await api.inventory();
|
||||
setInventory(inv.inventory);
|
||||
setVault(inv.vault || null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,6 +112,7 @@ export default function Dashboard() {
|
||||
return inventory.filter(
|
||||
(inv) =>
|
||||
!inv.favorite &&
|
||||
!inv.staked &&
|
||||
(inv.valueCents ?? inv.item?.valueCents ?? 0) < thresholdCents
|
||||
);
|
||||
}, [inventory, thresholdCents]);
|
||||
@@ -118,10 +122,17 @@ export default function Dashboard() {
|
||||
[inventory, selected]
|
||||
);
|
||||
|
||||
const stakedItems = useMemo(() => inventory.filter((inv) => inv.staked), [inventory]);
|
||||
|
||||
const selectedHasFavorite = selectedItems.some((inv) => inv.favorite);
|
||||
const selectedAllFavorite =
|
||||
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(
|
||||
() =>
|
||||
@@ -155,11 +166,78 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
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;
|
||||
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) => {
|
||||
if (!selected.length) return;
|
||||
setBusy(true);
|
||||
@@ -303,10 +381,34 @@ export default function Dashboard() {
|
||||
className="btn"
|
||||
onClick={sell}
|
||||
disabled={busy || !selectedCanSell}
|
||||
title={selectedHasFavorite ? 'Favorites cannot be sold' : undefined}
|
||||
title={
|
||||
selectedHasFavorite || selectedStaked.length
|
||||
? 'Favorites and vault items cannot be sold'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Sell
|
||||
</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>
|
||||
@@ -381,6 +483,57 @@ export default function Dashboard() {
|
||||
</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 ? (
|
||||
<div className="empty">
|
||||
Empty for now —{' '}
|
||||
@@ -397,6 +550,7 @@ export default function Dashboard() {
|
||||
key={inv.id}
|
||||
item={inv.item}
|
||||
favorite={inv.favorite}
|
||||
staked={inv.staked}
|
||||
selected={selected.includes(inv.id)}
|
||||
onClick={() => toggle(inv.id)}
|
||||
/>
|
||||
|
||||
@@ -162,53 +162,53 @@ export default function PrestigePage() {
|
||||
{p && (
|
||||
<div className="prestige-totals prestige-totals-wide">
|
||||
<div>
|
||||
<span className="muted">Chance</span>
|
||||
<span className="muted">Fortune</span>
|
||||
<strong>+{p.chanceBonus}%</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span className="muted">Gains</span>
|
||||
<span className="muted">Yield</span>
|
||||
<strong>+{p.gainBonus}%</strong>
|
||||
</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>
|
||||
<span className="muted">Polish</span>
|
||||
<strong>+{p.wearBonus || 0}</strong>
|
||||
</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>
|
||||
<span className="muted">Bulk</span>
|
||||
<strong>+{p.openBonus || 0}</strong>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -256,8 +256,8 @@ export default function PrestigePage() {
|
||||
<div>
|
||||
<h3>You lose</h3>
|
||||
<ul>
|
||||
<li>Balance (minus Vault / Purse)</li>
|
||||
<li>Inventory</li>
|
||||
<li>Balance</li>
|
||||
<li>Inventory & vault stakes</li>
|
||||
<li>Case keys & catalog progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,14 @@ model User {
|
||||
prestigeStartOsu Int @default(0)
|
||||
prestigeDropValueBonus 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?
|
||||
playTimeSeconds BigInt @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -96,6 +104,9 @@ model InventoryItem {
|
||||
userId Int
|
||||
itemId Int
|
||||
locked Boolean @default(false)
|
||||
staked Boolean @default(false)
|
||||
stakedAt DateTime?
|
||||
lastYieldAt DateTime?
|
||||
floatValue Float @default(0.265)
|
||||
wear String @default("Field-Tested")
|
||||
paintSeed Int @default(0)
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function applyAutoSell(tx, user, inventoryItems) {
|
||||
let balanceDelta = 0;
|
||||
|
||||
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);
|
||||
|
||||
|
||||
+10
-1
@@ -20,6 +20,7 @@ import feedRoutes from './routes/feed.js';
|
||||
import catalogRoutes from './routes/catalog.js';
|
||||
import prestigeRoutes from './routes/prestige.js';
|
||||
import achievementsRoutes from './routes/achievements.js';
|
||||
import vaultRoutes from './routes/vault.js';
|
||||
import { setIO } from './realtime.js';
|
||||
import { getDropFeed } from './feed.js';
|
||||
import { ensureJackpotRound } from './services/jackpot.js';
|
||||
@@ -28,7 +29,7 @@ import { getJackpotState } from './services/jackpot.js';
|
||||
import { ensureAdminFromEnv } from './ensureAdmin.js';
|
||||
import { ensureAchievements, checkAchievements } from './achievements.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 uploadsDir = path.join(__dirname, '../uploads');
|
||||
@@ -114,6 +115,7 @@ app.use('/api/feed', feedRoutes);
|
||||
app.use('/api/catalog', catalogRoutes);
|
||||
app.use('/api/prestige', prestigeRoutes);
|
||||
app.use('/api/achievements', achievementsRoutes);
|
||||
app.use('/api/vault', vaultRoutes);
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error(err);
|
||||
@@ -149,6 +151,7 @@ io.on('connection', async (socket) => {
|
||||
socket.emit('session:ok', {
|
||||
count: result.count,
|
||||
max: result.max,
|
||||
vaultCredited: result.vaultCredited || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('presence connect', err);
|
||||
@@ -252,6 +255,12 @@ async function bootstrap() {
|
||||
await ensureJackpotRound();
|
||||
await recoverDuelSpins();
|
||||
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) {
|
||||
console.error('Failed to bootstrap game services', err);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,16 @@ export function publicUser(user) {
|
||||
prestigeCount: user.prestigeCount ?? 0,
|
||||
prestigeChanceBonus: user.prestigeChanceBonus ?? 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,
|
||||
playTimeSeconds: Number(user.playTimeSeconds ?? 0),
|
||||
};
|
||||
|
||||
+39
-3
@@ -1,11 +1,12 @@
|
||||
import { prisma } from './db.js';
|
||||
import { settleVaultYield } from './vault.js';
|
||||
|
||||
export const MAX_SOCKETS_PER_USER = Math.max(
|
||||
1,
|
||||
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();
|
||||
|
||||
export function isConnected(userId) {
|
||||
@@ -20,6 +21,10 @@ export function onlineUserCount() {
|
||||
return online.size;
|
||||
}
|
||||
|
||||
export function onlineUserIds() {
|
||||
return [...online.keys()];
|
||||
}
|
||||
|
||||
export function totalSocketCount() {
|
||||
let n = 0;
|
||||
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.
|
||||
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
|
||||
* 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) {
|
||||
const id = Number(userId);
|
||||
@@ -58,6 +63,7 @@ export async function trackConnect(userId, socketId) {
|
||||
// Claim slot before any await (avoids race past the limit)
|
||||
entry.sockets.add(socketId);
|
||||
const isFirst = entry.sockets.size === 1;
|
||||
let vaultCredited = 0;
|
||||
|
||||
if (isFirst) {
|
||||
try {
|
||||
@@ -68,9 +74,16 @@ export async function trackConnect(userId, socketId) {
|
||||
} catch (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);
|
||||
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;
|
||||
|
||||
try {
|
||||
@@ -118,3 +139,18 @@ export function serializePresence(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;
|
||||
|
||||
const BASE_AD_COOLDOWN_MS = 60_000;
|
||||
const MIN_AD_COOLDOWN_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Skill tree upgrades bought with Pr (stackable branches).
|
||||
@@ -40,44 +39,6 @@ export const PRESTIGE_SKILLS = {
|
||||
unit: '%',
|
||||
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: {
|
||||
id: 'polish',
|
||||
field: 'prestigeWearBonus',
|
||||
@@ -87,44 +48,6 @@ export const PRESTIGE_SKILLS = {
|
||||
unit: '',
|
||||
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: {
|
||||
id: 'bulk',
|
||||
field: 'prestigeOpenBonus',
|
||||
@@ -134,6 +57,81 @@ export const PRESTIGE_SKILLS = {
|
||||
unit: '',
|
||||
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). */
|
||||
@@ -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 cost = PRESTIGE_COST_CENTS;
|
||||
if (balance < cost) return 0;
|
||||
const excess = balance - cost;
|
||||
const chunk = PR_EXCESS_CHUNK_CENTS;
|
||||
const exact = Number(excess) / Number(chunk);
|
||||
const base = Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
|
||||
return applyPrBonus(base, prBonusPct);
|
||||
return Math.round(PR_BASE_REWARD + exact * PR_PER_EXCESS_CHUNK);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 cost = PRESTIGE_COST_CENTS;
|
||||
const excess = balance > cost ? balance - cost : 0n;
|
||||
@@ -181,26 +178,11 @@ export function computePrReward(balanceCents, prBonusPct = 0) {
|
||||
const bonus =
|
||||
full * PR_PER_EXCESS_CHUNK +
|
||||
(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) {
|
||||
const base = Math.max(0, Math.floor(Number(basePr) || 0));
|
||||
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 adCooldownMs(_user) {
|
||||
return BASE_AD_COOLDOWN_MS;
|
||||
}
|
||||
|
||||
export function serializePrestige(user) {
|
||||
@@ -208,16 +190,16 @@ export function serializePrestige(user) {
|
||||
count: user?.prestigeCount ?? 0,
|
||||
chanceBonus: user?.prestigeChanceBonus ?? 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,
|
||||
prBonus: user?.prestigePrBonus ?? 0,
|
||||
shopDiscount: user?.prestigeShopDiscount ?? 0,
|
||||
startOsu: user?.prestigeStartOsu ?? 0,
|
||||
dropValueBonus: user?.prestigeDropValueBonus ?? 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,
|
||||
costCents: Number(PRESTIGE_COST_CENTS),
|
||||
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
|
||||
@@ -249,17 +231,12 @@ export function serializeSkillTree(user) {
|
||||
});
|
||||
}
|
||||
|
||||
export function shopOsuCost(baseCost, discount = 0) {
|
||||
const base = Math.max(1, Math.floor(Number(baseCost) || 0));
|
||||
const off = Math.max(0, Math.floor(Number(discount) || 0));
|
||||
return Math.max(1, base - off);
|
||||
}
|
||||
|
||||
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 case price discount (%). Min 1 cent. */
|
||||
export function applyCaseDiscount(priceCents, discountPct = 0) {
|
||||
const base = Math.max(0, Math.floor(Number(priceCents) || 0));
|
||||
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)));
|
||||
}
|
||||
|
||||
/** Apply lifetime gain % to a credit amount (cents). */
|
||||
|
||||
@@ -96,7 +96,25 @@ router.post('/login', async (req, res) => {
|
||||
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) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
|
||||
+152
-66
@@ -15,12 +15,12 @@ import {
|
||||
fillCatalogSlots,
|
||||
formatCaseItemsWithLuck,
|
||||
} from '../catalog.js';
|
||||
import { totalLuckPercent, applyDropValueBonus } from '../prestige.js';
|
||||
import { totalLuckPercent, applyCaseDiscount } from '../prestige.js';
|
||||
import { checkAchievements } from '../achievements.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function formatCase(c, luckPercent = 0) {
|
||||
function formatCase(c, luckPercent = 0, unitPrice = null) {
|
||||
const items = (c.items || []).map((ci) => ({
|
||||
id: ci.item.id,
|
||||
name: ci.item.name,
|
||||
@@ -34,7 +34,8 @@ function formatCase(c, luckPercent = 0) {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
imageUrl: c.imageUrl,
|
||||
price: c.price,
|
||||
price: unitPrice != null ? unitPrice : c.price,
|
||||
basePrice: c.price,
|
||||
active: c.active,
|
||||
catalogLuckPercent: luckPercent,
|
||||
items: formatCaseItemsWithLuck(items, luckPercent),
|
||||
@@ -43,7 +44,12 @@ function formatCase(c, luckPercent = 0) {
|
||||
|
||||
async function loadUserLuckAndKeys(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([
|
||||
countCompletedChapters(prisma, userId),
|
||||
@@ -51,18 +57,20 @@ async function loadUserLuckAndKeys(userId) {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
prestigeChanceBonus: true,
|
||||
prestigeKeyOpenReduction: true,
|
||||
prestigeWearBonus: true,
|
||||
prestigeOpenBonus: true,
|
||||
prestigeDropValueBonus: true,
|
||||
prestigeCaseDiscount: true,
|
||||
prestigeQualityBonus: true,
|
||||
prestigeAnimReduction: true,
|
||||
prestigeRefundChance: true,
|
||||
prestigeRespinChance: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
|
||||
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
|
||||
openBonus: user?.prestigeOpenBonus ?? 0,
|
||||
dropValueBonus: user?.prestigeDropValueBonus ?? 0,
|
||||
caseDiscount: user?.prestigeCaseDiscount ?? 0,
|
||||
user,
|
||||
};
|
||||
}
|
||||
@@ -75,6 +83,17 @@ async function loadKeyProgressMap(userId) {
|
||||
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) => {
|
||||
try {
|
||||
const userId = req.session?.userId;
|
||||
@@ -86,16 +105,19 @@ router.get('/', async (req, res) => {
|
||||
orderBy: { price: 'asc' },
|
||||
});
|
||||
const progressMap = await loadKeyProgressMap(userId);
|
||||
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
|
||||
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
||||
res.json({
|
||||
catalogLuckPercent: luckPercent,
|
||||
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;
|
||||
const progress = progressMap.get(c.id);
|
||||
return {
|
||||
...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) {
|
||||
return res.status(404).json({ error: 'Case not found' });
|
||||
}
|
||||
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
|
||||
const formatted = formatCase(c, luckPercent);
|
||||
const { luckPercent, openBonus, caseDiscount } = await loadUserLuckAndKeys(userId);
|
||||
const unitPrice = userId
|
||||
? applyCaseDiscount(c.price, caseDiscount)
|
||||
: Number(c.price);
|
||||
const formatted = formatCase(c, luckPercent, unitPrice);
|
||||
if (userId) {
|
||||
const progress = await prisma.caseKeyProgress.findUnique({
|
||||
where: { userId_caseId: { userId, caseId: id } },
|
||||
});
|
||||
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction, openBonus);
|
||||
formatted.keyProgress = serializeKeyProgress(progress, 0, openBonus);
|
||||
}
|
||||
res.json({ case: formatted });
|
||||
} catch (err) {
|
||||
@@ -169,7 +194,6 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
|
||||
const progressRow = await getOrCreateProgress(tx, userId, caseId);
|
||||
const openBonus = user.prestigeOpenBonus ?? 0;
|
||||
const dropValueBonus = user.prestigeDropValueBonus ?? 0;
|
||||
const maxOpens = maxOpensForKeys(progressRow.keys, openBonus);
|
||||
if (count > maxOpens) {
|
||||
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;
|
||||
}
|
||||
|
||||
const totalCost = c.price * count;
|
||||
const unitPrice = applyCaseDiscount(c.price, user.prestigeCaseDiscount ?? 0);
|
||||
const totalCost = unitPrice * count;
|
||||
if (user.balance < totalCost) {
|
||||
const err = new Error('Insufficient balance');
|
||||
err.status = 400;
|
||||
@@ -185,7 +210,6 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const catalogLuck = await countCompletedChapters(tx, userId);
|
||||
const keyOpenReduction = user.prestigeKeyOpenReduction ?? 0;
|
||||
const luckPercent = totalLuckPercent(catalogLuck, user.prestigeChanceBonus);
|
||||
const weighted = applyCatalogLuck(
|
||||
c.items.map((ci) => ({
|
||||
@@ -195,63 +219,96 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
luckPercent
|
||||
);
|
||||
|
||||
const drops = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const winner = pickWeighted(weighted);
|
||||
const item = winner.caseItem.item;
|
||||
const rolled = rollInstanceValue(item.marketValue, user.prestigeWearBonus ?? 0);
|
||||
rolled.valueCents = applyDropValueBonus(rolled.valueCents, dropValueBonus);
|
||||
|
||||
const inventoryItem = await tx.inventoryItem.create({
|
||||
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,
|
||||
async function createDrops(batchCount) {
|
||||
const drops = [];
|
||||
const createdTxIds = [];
|
||||
for (let i = 0; i < batchCount; i += 1) {
|
||||
const { item, rolled } = rollOneDrop(weighted, user);
|
||||
const inventoryItem = await tx.inventoryItem.create({
|
||||
data: {
|
||||
userId,
|
||||
itemId: item.id,
|
||||
itemName: item.name,
|
||||
inventoryItemId: inventoryItem.id,
|
||||
floatValue: rolled.floatValue,
|
||||
wear: rolled.wear,
|
||||
paintSeed: rolled.paintSeed,
|
||||
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,
|
||||
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(
|
||||
tx,
|
||||
userId,
|
||||
@@ -259,7 +316,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
drops.map((d) => d.id)
|
||||
);
|
||||
|
||||
const applied = applyOpens(progressRow, count, keyOpenReduction);
|
||||
const applied = applyOpens(progressRow, count, 0);
|
||||
const updatedProgress = await tx.caseKeyProgress.update({
|
||||
where: { id: progressRow.id },
|
||||
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({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
balance: { decrement: totalCost },
|
||||
balance: { increment: balanceDelta },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -280,9 +356,14 @@ router.post('/:id/open', requireAuth, async (req, res) => {
|
||||
user: publicUser(updatedUser),
|
||||
caseName: c.name,
|
||||
items: drops,
|
||||
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction, openBonus),
|
||||
keyProgress: serializeKeyProgress(updatedProgress, 0, openBonus),
|
||||
keysGained: applied.keysGained,
|
||||
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,
|
||||
catalog: result.catalog,
|
||||
item: result.items[0],
|
||||
unitPrice: result.unitPrice,
|
||||
totalCost: result.totalCost,
|
||||
refunded: result.refunded,
|
||||
respin: result.respin,
|
||||
animReduction: result.animReduction,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -3,6 +3,12 @@ import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
import { applyAutoSell } from '../autoSell.js';
|
||||
import { applyGainMultiplier } from '../prestige.js';
|
||||
import {
|
||||
maxVaultSlots,
|
||||
summarizeVaultRates,
|
||||
estimatePendingYield,
|
||||
} from '../vault.js';
|
||||
import { isConnected } from '../presence.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -11,6 +17,9 @@ function serializeInv(inv) {
|
||||
id: inv.id,
|
||||
obtainedAt: inv.obtainedAt,
|
||||
locked: inv.locked,
|
||||
staked: Boolean(inv.staked),
|
||||
stakedAt: inv.stakedAt || null,
|
||||
lastYieldAt: inv.lastYieldAt || null,
|
||||
floatValue: inv.floatValue,
|
||||
wear: inv.wear,
|
||||
paintSeed: inv.paintSeed,
|
||||
@@ -22,7 +31,6 @@ function serializeInv(inv) {
|
||||
imageUrl: inv.item.imageUrl,
|
||||
rarity: inv.item.rarity,
|
||||
marketValue: inv.item.marketValue,
|
||||
// Instance price for UI (prefer over catalog FT reference)
|
||||
valueCents: inv.valueCents,
|
||||
floatValue: inv.floatValue,
|
||||
wear: inv.wear,
|
||||
@@ -33,14 +41,29 @@ function serializeInv(inv) {
|
||||
|
||||
router.get('/', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const items = await prisma.inventoryItem.findMany({
|
||||
where: { userId: req.session.userId, locked: false },
|
||||
include: { item: true },
|
||||
orderBy: { obtainedAt: 'desc' },
|
||||
});
|
||||
const userId = req.session.userId;
|
||||
const [user, items] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId } }),
|
||||
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({
|
||||
inventory: items.map(serializeInv),
|
||||
vault: {
|
||||
used: staked.length,
|
||||
slots: maxVaultSlots(user),
|
||||
...rates,
|
||||
pendingYield: estimatePendingYield(staked, user, mode),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -98,6 +121,7 @@ router.post('/auto-sell/apply', requireAuth, async (req, res) => {
|
||||
id: { in: ids },
|
||||
userId: req.session.userId,
|
||||
locked: false,
|
||||
staked: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -150,12 +174,13 @@ router.post('/sell', requireAuth, async (req, res) => {
|
||||
id: { in: ids },
|
||||
userId: req.session.userId,
|
||||
locked: false,
|
||||
staked: false,
|
||||
},
|
||||
include: { item: true },
|
||||
});
|
||||
|
||||
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;
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -41,9 +41,7 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const pageVisible = isPrestigePageVisible(user, totalCents);
|
||||
const canAfford = balance >= COST;
|
||||
const canPrestige = pageVisible && canAfford && lockedCount === 0;
|
||||
const previewPr = canAfford
|
||||
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
|
||||
: 0;
|
||||
const previewPr = canAfford ? estimatePrReward(user.balance) : 0;
|
||||
|
||||
res.json({
|
||||
prestige: serializePrestige(user),
|
||||
@@ -69,8 +67,14 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
|
||||
router.post('/', requireAuth, async (req, res) => {
|
||||
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 user = await tx.user.findUnique({ where: { id: req.session.userId } });
|
||||
const user = await tx.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
const err = new Error('User not found');
|
||||
err.status = 401;
|
||||
@@ -98,10 +102,8 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const prGranted = computePrReward(user.balance, user.prestigePrBonus ?? 0);
|
||||
const prGranted = computePrReward(user.balance);
|
||||
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.caseKeyProgress.deleteMany({ where: { userId: user.id } });
|
||||
@@ -116,8 +118,6 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
prestigeCount,
|
||||
prGranted,
|
||||
previousBalance: Number(user.balance),
|
||||
startBalance,
|
||||
startOsu,
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -134,8 +134,7 @@ router.post('/', requireAuth, async (req, res) => {
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
prestigeCount,
|
||||
balance: startBalance,
|
||||
...(startOsu > 0 ? { osuBalance: { increment: startOsu } } : {}),
|
||||
balance: 0,
|
||||
prBalance: { increment: prGranted },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
||||
import { prisma } from '../db.js';
|
||||
import { requireAuth, publicUser } from '../middleware.js';
|
||||
import { checkAchievements } from '../achievements.js';
|
||||
import { adCooldownMs, applyAdRewardBonus, shopOsuCost } from '../prestige.js';
|
||||
import { adCooldownMs } from '../prestige.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -22,11 +22,10 @@ function adPayload(user, overrides = {}) {
|
||||
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
|
||||
const nextAdAt = lastAdAt + cooldownMs;
|
||||
const adReady = Date.now() >= nextAdAt;
|
||||
const adBonus = user.prestigeAdBonus ?? 0;
|
||||
return {
|
||||
cooldownMs,
|
||||
rewardMin: applyAdRewardBonus(AD_REWARD_MIN, adBonus),
|
||||
rewardMax: applyAdRewardBonus(AD_REWARD_MAX, adBonus),
|
||||
rewardMin: AD_REWARD_MIN,
|
||||
rewardMax: AD_REWARD_MAX,
|
||||
ready: adReady,
|
||||
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
|
||||
lastAdAt: user.lastAdAt,
|
||||
@@ -39,10 +38,8 @@ router.get('/', requireAuth, async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const discount = user.prestigeShopDiscount ?? 0;
|
||||
const packages = SHOP_PACKAGES.map((pack) => ({
|
||||
...pack,
|
||||
osuCost: shopOsuCost(pack.osuCost, discount),
|
||||
baseOsuCost: pack.osuCost,
|
||||
}));
|
||||
|
||||
@@ -72,7 +69,7 @@ router.post('/buy', requireAuth, async (req, res) => {
|
||||
err.status = 404;
|
||||
throw err;
|
||||
}
|
||||
const osuCost = shopOsuCost(pack.osuCost, user.prestigeShopDiscount ?? 0);
|
||||
const osuCost = pack.osuCost;
|
||||
if (user.osuBalance < osuCost) {
|
||||
const err = new Error('Not enough osu');
|
||||
err.status = 400;
|
||||
@@ -95,7 +92,6 @@ router.post('/buy', requireAuth, async (req, res) => {
|
||||
meta: JSON.stringify({
|
||||
packageId: pack.id,
|
||||
osuCost,
|
||||
baseOsuCost: pack.osuCost,
|
||||
credits: pack.credits,
|
||||
}),
|
||||
},
|
||||
@@ -138,9 +134,8 @@ router.post('/ad', requireAuth, async (req, res) => {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const base =
|
||||
const reward =
|
||||
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 updated = await tx.user.update({
|
||||
@@ -156,7 +151,7 @@ router.post('/ad', requireAuth, async (req, res) => {
|
||||
userId: user.id,
|
||||
type: 'ad_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({
|
||||
where: { id: { in: ids }, userId, locked: false },
|
||||
where: { id: { in: ids }, userId, locked: false, staked: false },
|
||||
include: { item: true },
|
||||
});
|
||||
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) {
|
||||
|
||||
@@ -248,11 +248,11 @@ export async function placeJackpotBet(userId, inventoryItemIds) {
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
export function rollInstanceValue(baseMarketValue, wearBonus = 0) {
|
||||
export function rollInstanceValue(baseMarketValue, wearBonus = 0, qualityBonus = 0) {
|
||||
let floatValue = rollFloat();
|
||||
const bias = Math.min(0.55, Math.max(0, Number(wearBonus) || 0) * 0.008);
|
||||
if (bias > 0) {
|
||||
floatValue = clampFloat(floatValue * (1 - bias));
|
||||
}
|
||||
floatValue = applyQualityUpgrade(floatValue, qualityBonus);
|
||||
return {
|
||||
floatValue,
|
||||
wear: wearFromFloat(floatValue),
|
||||
|
||||
Reference in New Issue
Block a user