cool prestige update
This commit is contained in:
@@ -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