From 3a8871cbae0b27c0f9f9d239d676c46929538b68 Mon Sep 17 00:00:00 2001 From: gpatruno Date: Sun, 19 Jul 2026 00:29:46 +0200 Subject: [PATCH] cool prestige update --- README.md | 8 +- client/src/admin/AdminDrops.jsx | 16 +- client/src/api.js | 67 +++++- client/src/components/CaseReel.jsx | 15 +- client/src/components/ItemTile.jsx | 21 +- client/src/components/Layout.jsx | 15 +- .../src/components/ProfilePrestigeStats.jsx | 112 +++++---- client/src/index.css | 42 ++++ client/src/pages/CasePage.jsx | 19 +- client/src/pages/Dashboard.jsx | 160 ++++++++++++- client/src/pages/PrestigePage.jsx | 72 +++--- server/prisma/schema.prisma | 11 + server/src/autoSell.js | 2 +- server/src/index.js | 11 +- server/src/middleware.js | 11 +- server/src/presence.js | 42 +++- server/src/prestige.js | 217 ++++++++--------- server/src/routes/auth.js | 20 +- server/src/routes/cases.js | 218 ++++++++++++------ server/src/routes/inventory.js | 39 +++- server/src/routes/prestige.js | 21 +- server/src/routes/shop.js | 17 +- server/src/routes/vault.js | 182 +++++++++++++++ server/src/services/duel.js | 4 +- server/src/services/jackpot.js | 4 +- server/src/vault.js | 157 +++++++++++++ server/src/wear.js | 15 +- 27 files changed, 1165 insertions(+), 353 deletions(-) create mode 100644 server/src/routes/vault.js create mode 100644 server/src/vault.js diff --git a/README.md b/README.md index 5aa3139..923473d 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/client/src/admin/AdminDrops.jsx b/client/src/admin/AdminDrops.jsx index e17feb0..f15afb7 100644 --- a/client/src/admin/AdminDrops.jsx +++ b/client/src/admin/AdminDrops.jsx @@ -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 (
@@ -116,12 +118,12 @@ export default function AdminDrops() {
-

Pool ({selected.items.length})

- {selected.items.length === 0 ? ( +

Pool ({poolItems.length})

+ {poolItems.length === 0 ? (
No drops yet.
) : (
- {selected.items.map((ci, idx) => ( + {poolItems.map((ci, idx) => (
{ci.item.imageUrl ? ( diff --git a/client/src/api.js b/client/src/api.js index 5d9f996..9cd6a1d 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -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)}%`; } diff --git a/client/src/components/CaseReel.jsx b/client/src/components/CaseReel.jsx index 75f7820..0210151 100644 --- a/client/src/components/CaseReel.jsx +++ b/client/src/components/CaseReel.jsx @@ -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 (
diff --git a/client/src/components/ItemTile.jsx b/client/src/components/ItemTile.jsx index 280ef9f..904e5b3 100644 --- a/client/src/components/ItemTile.jsx +++ b/client/src/components/ItemTile.jsx @@ -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 (
{ @@ -60,6 +68,7 @@ export default function ItemTile({ >
{favorite ? : null} + {staked ? V : null} {hasImage ? ( ) : ( @@ -76,7 +85,9 @@ export default function ItemTile({
) : null} {showValue && ( -
{formatCredits(value)} cr
+
+ {formatCredits(value)} cr +
)}
); diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index c0c3c2c..3cc4010 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -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' && Admin} {user && ( isPlayer ? ( - + {formatCredits(user.balance)} cr ) : ( - {formatCredits(user.balance)} cr + + {formatCredits(user.balance)} cr + ) )} {user ? ( diff --git a/client/src/components/ProfilePrestigeStats.jsx b/client/src/components/ProfilePrestigeStats.jsx index 65ea850..6ac167a 100644 --- a/client/src/components/ProfilePrestigeStats.jsx +++ b/client/src/components/ProfilePrestigeStats.jsx @@ -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: {prestige.prBalance} } + : null, + { label: 'Fortune', value: +{prestige.chanceBonus}% }, + { label: 'Yield', value: +{prestige.gainBonus}% }, + (prestige.wearBonus || 0) > 0 && { + label: 'Polish', + value: +{prestige.wearBonus}, + }, + (prestige.openBonus || 0) > 0 && { + label: 'Bulk', + value: +{prestige.openBonus} opens, + }, + (prestige.caseDiscount || 0) > 0 && { + label: 'Discount', + value: −{prestige.caseDiscount}%, + }, + (prestige.qualityBonus || 0) > 0 && { + label: 'Quality', + value: +{prestige.qualityBonus}, + }, + (prestige.animReduction || 0) > 0 && { + label: 'noTime', + value: −{prestige.animReduction}%, + }, + (prestige.refundChance || 0) > 0 && { + label: 'Refund', + value: {prestige.refundChance}%, + }, + (prestige.respinChance || 0) > 0 && { + label: 'Respin', + value: {prestige.respinChance}%, + }, + (prestige.vaultSlots || 0) > 0 && { + label: 'Vault Capacity', + value: +{prestige.vaultSlots}, + }, + (prestige.offlineYield || 0) > 0 && { + label: 'Offline Yield', + value: +{prestige.offlineYield}%, + }, + (prestige.onlineYield || 0) > 0 && { + label: 'Online Yield', + value: +{prestige.onlineYield}%, + }, + ].filter(Boolean); + return (
@@ -10,65 +56,11 @@ export default function ProfilePrestigeStats({ prestige }) { ×{prestige.count}
    - {typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && ( -
  • - Pr {prestige.prBalance} + {rows.map((row) => ( +
  • + {row.label} {row.value}
  • - )} -
  • - Chance +{prestige.chanceBonus}% -
  • -
  • - Sell gains +{prestige.gainBonus}% -
  • -
  • - Key opens −{prestige.keyOpenReduction} -
  • - {(prestige.startBonus || 0) > 0 && ( -
  • - Vault {formatCredits(prestige.startBonus)} cr -
  • - )} - {(prestige.adBonus || 0) > 0 && ( -
  • - Ad reward +{prestige.adBonus}% -
  • - )} - {(prestige.adCooldownReduction || 0) > 0 && ( -
  • - Ad cooldown −{prestige.adCooldownReduction}s -
  • - )} - {(prestige.wearBonus || 0) > 0 && ( -
  • - Polish +{prestige.wearBonus} -
  • - )} - {(prestige.prBonus || 0) > 0 && ( -
  • - Pr echo +{prestige.prBonus}% -
  • - )} - {(prestige.shopDiscount || 0) > 0 && ( -
  • - Haggle −{prestige.shopDiscount} osu -
  • - )} - {(prestige.startOsu || 0) > 0 && ( -
  • - Purse +{prestige.startOsu} osu -
  • - )} - {(prestige.dropValueBonus || 0) > 0 && ( -
  • - Magnet +{prestige.dropValueBonus}% -
  • - )} - {(prestige.openBonus || 0) > 0 && ( -
  • - Bulk +{prestige.openBonus} opens -
  • - )} + ))}
); diff --git a/client/src/index.css b/client/src/index.css index 9119dd0..7a231e0 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -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; diff --git a/client/src/pages/CasePage.jsx b/client/src/pages/CasePage.jsx index 8c1ca2c..0c4ff60 100644 --- a/client/src/pages/CasePage.jsx +++ b/client/src/pages/CasePage.jsx @@ -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 &&
{error}
} {keyMessage &&
{keyMessage}
} {catalogMessage &&
{catalogMessage}
} + {openBonusMessage &&
{openBonusMessage}
} {crate && ( <>

{crate.name}

- 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 && (

{ 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 + {canStake && ( + + )} + {canUnstake && ( + + )} )}
@@ -381,6 +483,57 @@ export default function Dashboard() {
{error &&
{error}
} + {vaultMessage &&
{vaultMessage}
} + +
+
+
+

Vault

+

+ Stake up to {vault?.slots ?? 3} items. Online: 10% / 10 min · Offline: 1% / 10 min + (scales with wear & rarity). +

+
+ +
+
+ + Slots {vault?.used ?? 0}/{vault?.slots ?? 3} + + + Online / tick{' '} + {formatCredits(vault?.onlinePerTick ?? 0)} cr + + + Offline / tick{' '} + {formatCredits(vault?.offlinePerTick ?? 0)} cr + +
+ {stakedItems.length > 0 ? ( +
+ {stakedItems.map((inv) => ( + toggle(inv.id)} + /> + ))} +
+ ) : ( +

No items staked — select items below and Stake.

+ )} +
+ {inventory.length === 0 ? (
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)} /> diff --git a/client/src/pages/PrestigePage.jsx b/client/src/pages/PrestigePage.jsx index 79d81d5..4e2e291 100644 --- a/client/src/pages/PrestigePage.jsx +++ b/client/src/pages/PrestigePage.jsx @@ -162,53 +162,53 @@ export default function PrestigePage() { {p && (
- Chance + Fortune +{p.chanceBonus}%
- Gains + Yield +{p.gainBonus}%
-
- Key opens - −{p.keyOpenReduction} -
-
- Vault - {formatCredits(p.startBonus || 0)} cr -
-
- Ad reward - +{p.adBonus || 0}% -
-
- Ad cooldown - −{p.adCooldownReduction || 0}s -
Polish +{p.wearBonus || 0}
-
- Pr echo - +{p.prBonus || 0}% -
-
- Haggle - −{p.shopDiscount || 0} osu -
-
- Purse - +{p.startOsu || 0} osu -
-
- Magnet - +{p.dropValueBonus || 0}% -
Bulk +{p.openBonus || 0}
+
+ Discount + −{p.caseDiscount || 0}% +
+
+ Quality + +{p.qualityBonus || 0} +
+
+ noTime + −{p.animReduction || 0}% +
+
+ Refund + {p.refundChance || 0}% +
+
+ Respin + {p.respinChance || 0}% +
+
+ Vault slots + +{p.vaultSlots || 0} +
+
+ Offline Yield + +{p.offlineYield || 0}% +
+
+ Online Yield + +{p.onlineYield || 0}% +
)} @@ -256,8 +256,8 @@ export default function PrestigePage() {

You lose

    -
  • Balance (minus Vault / Purse)
  • -
  • Inventory
  • +
  • Balance
  • +
  • Inventory & vault stakes
  • Case keys & catalog progress
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f647c1b..aefd37a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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) diff --git a/server/src/autoSell.js b/server/src/autoSell.js index 1575a8b..e412a86 100644 --- a/server/src/autoSell.js +++ b/server/src/autoSell.js @@ -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); diff --git a/server/src/index.js b/server/src/index.js index 6463184..32f52a9 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -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); } diff --git a/server/src/middleware.js b/server/src/middleware.js index 24a09fd..1d8fc26 100644 --- a/server/src/middleware.js +++ b/server/src/middleware.js @@ -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), }; diff --git a/server/src/presence.js b/server/src/presence.js index 5067180..d416503 100644 --- a/server/src/presence.js +++ b/server/src/presence.js @@ -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, sessionStartedAt: number }>} */ +/** @type {Map, 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; +} diff --git a/server/src/prestige.js b/server/src/prestige.js index 75604f7..3e2de47 100644 --- a/server/src/prestige.js +++ b/server/src/prestige.js @@ -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). */ diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 2fdca7e..b098147 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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' }); diff --git a/server/src/routes/cases.js b/server/src/routes/cases.js index f31140d..62c45ed 100644 --- a/server/src/routes/cases.js +++ b/server/src/routes/cases.js @@ -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); diff --git a/server/src/routes/inventory.js b/server/src/routes/inventory.js index 54fde9c..04e1369 100644 --- a/server/src/routes/inventory.js +++ b/server/src/routes/inventory.js @@ -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; } diff --git a/server/src/routes/prestige.js b/server/src/routes/prestige.js index 4d070fc..33b86c0 100644 --- a/server/src/routes/prestige.js +++ b/server/src/routes/prestige.js @@ -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 }, }, }); diff --git a/server/src/routes/shop.js b/server/src/routes/shop.js index 8a25477..377a981 100644 --- a/server/src/routes/shop.js +++ b/server/src/routes/shop.js @@ -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 }), }, }); diff --git a/server/src/routes/vault.js b/server/src/routes/vault.js new file mode 100644 index 0000000..aff13d6 --- /dev/null +++ b/server/src/routes/vault.js @@ -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; diff --git a/server/src/services/duel.js b/server/src/services/duel.js index 597de83..e3fdd17 100644 --- a/server/src/services/duel.js +++ b/server/src/services/duel.js @@ -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) { diff --git a/server/src/services/jackpot.js b/server/src/services/jackpot.js index 953a9a0..f7c20a1 100644 --- a/server/src/services/jackpot.js +++ b/server/src/services/jackpot.js @@ -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) { diff --git a/server/src/vault.js b/server/src/vault.js new file mode 100644 index 0000000..fe465fb --- /dev/null +++ b/server/src/vault.js @@ -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 }; +} diff --git a/server/src/wear.js b/server/src/wear.js index f779c25..a944e8c 100644 --- a/server/src/wear.js +++ b/server/src/wear.js @@ -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),