cool prestige update

This commit is contained in:
gpatruno
2026-07-19 00:29:46 +02:00
parent d0f868ffa5
commit 3a8871cbae
27 changed files with 1165 additions and 353 deletions
+11
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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);
}
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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). */
+19 -1
View File
@@ -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
View File
@@ -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);
+32 -7
View File
@@ -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;
}
+10 -11
View File
@@ -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 },
},
});
+6 -11
View File
@@ -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 }),
},
});
+182
View File
@@ -0,0 +1,182 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import {
maxVaultSlots,
settleVaultYield,
summarizeVaultRates,
estimatePendingYield,
} from '../vault.js';
import { isConnected } from '../presence.js';
const router = Router();
router.post('/stake', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.status(400).json({ error: 'Select at least one item to stake' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const maxSlots = maxVaultSlots(user);
const used = await tx.inventoryItem.count({
where: { userId: user.id, staked: true },
});
const free = maxSlots - used;
if (ids.length > free) {
const err = new Error(`Vault full (${used}/${maxSlots} slots)`);
err.status = 400;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: user.id,
locked: false,
staked: false,
},
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid, locked, or already staked items');
err.status = 400;
throw err;
}
const now = new Date();
await tx.inventoryItem.updateMany({
where: { id: { in: ids }, userId: user.id },
data: {
staked: true,
stakedAt: now,
lastYieldAt: now,
},
});
return { user, stakedIds: ids, maxSlots, used: used + ids.length };
});
res.json({
user: publicUser(result.user),
stakedIds: result.stakedIds,
vault: {
used: result.used,
slots: result.maxSlots,
},
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to stake items' });
}
});
router.post('/unstake', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.status(400).json({ error: 'Select at least one item to unstake' });
}
// Settle yield before unstake (online if connected, else offline)
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode);
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: user.id,
staked: true,
},
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid or not-staked items');
err.status = 400;
throw err;
}
await tx.inventoryItem.updateMany({
where: { id: { in: ids }, userId: user.id },
data: {
staked: false,
stakedAt: null,
lastYieldAt: null,
},
});
const used = await tx.inventoryItem.count({
where: { userId: user.id, staked: true },
});
const refreshed = await tx.user.findUnique({ where: { id: user.id } });
return {
user: refreshed,
unstakedIds: ids,
maxSlots: maxVaultSlots(refreshed),
used,
};
});
res.json({
user: publicUser(result.user),
unstakedIds: result.unstakedIds,
yieldCredited: settled.credited,
vault: {
used: result.used,
slots: result.maxSlots,
},
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to unstake items' });
}
});
router.post('/claim', requireAuth, async (req, res) => {
try {
const mode = isConnected(req.session.userId) ? 'online' : 'offline';
const settled = await settleVaultYield(req.session.userId, mode);
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const staked = await prisma.inventoryItem.findMany({
where: { userId: user.id, staked: true },
include: { item: true },
});
const rates = summarizeVaultRates(staked, user);
res.json({
user: publicUser(user),
credited: settled.credited,
ticks: settled.ticks,
vault: {
used: staked.length,
slots: maxVaultSlots(user),
...rates,
pendingYield: estimatePendingYield(staked, user, mode),
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message || 'Failed to claim vault yield' });
}
});
export default router;
+2 -2
View File
@@ -241,11 +241,11 @@ export async function placeDuelBet(userId, roomId, inventoryItemIds) {
}
const invItems = await tx.inventoryItem.findMany({
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) {
+2 -2
View File
@@ -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) {
+157
View File
@@ -0,0 +1,157 @@
import { prisma } from './db.js';
export const BASE_VAULT_SLOTS = 3;
export const VAULT_TICK_MS = 600_000; // 10 minutes
export const ONLINE_BASE_RATE = 0.1; // 10% of value per tick
export const OFFLINE_BASE_RATE = 0.01; // 1% of value per tick
export const WEAR_FLOAT_K = 3;
/** Rarity → yield multiplier (seed rarities + fallbacks). */
export const RARITY_YIELD_MULT = {
Consumer: 0.8,
Industrial: 0.9,
MilSpec: 1.0,
Restricted: 1.15,
Classified: 1.35,
Covert: 1.55,
Extraordinary: 1.8,
};
export function maxVaultSlots(user) {
return BASE_VAULT_SLOTS + Math.max(0, Math.floor(Number(user?.prestigeVaultSlots) || 0));
}
export function wearYieldMult(floatValue) {
const f = Math.min(1, Math.max(0, Number(floatValue) || 0));
return 1 + f * f * WEAR_FLOAT_K;
}
export function rarityYieldMult(rarity) {
const key = String(rarity || '');
return RARITY_YIELD_MULT[key] ?? 1.0;
}
export function skillYieldMult(user, mode) {
const pct =
mode === 'online'
? Number(user?.prestigeOnlineYield) || 0
: Number(user?.prestigeOfflineYield) || 0;
if (pct <= 0) return 1;
return 1 + pct / 100;
}
export function tickPayoutCents(inv, user, mode) {
const value = Math.max(0, Math.floor(Number(inv.valueCents) || 0));
if (value <= 0) return 0;
const baseRate = mode === 'online' ? ONLINE_BASE_RATE : OFFLINE_BASE_RATE;
const wear = wearYieldMult(inv.floatValue);
const rarity = rarityYieldMult(inv.item?.rarity ?? inv.rarity);
const skill = skillYieldMult(user, mode);
const payout = Math.floor(value * baseRate * wear * rarity * skill);
// Soft cap: at most 100% of item value per tick
return Math.min(value, Math.max(0, payout));
}
export function estimateTickYield(inv, user, mode) {
return tickPayoutCents(inv, user, mode);
}
/**
* Settle complete vault ticks for a user.
* @param {'online'|'offline'} mode
* @param {Date|number} [until] settle up to this time (default now)
* @returns {{ credited: number, ticks: number, itemIds: number[] }}
*/
export async function settleVaultYield(userId, mode, until = Date.now()) {
const id = Number(userId);
if (!id) return { credited: 0, ticks: 0, itemIds: [] };
const untilMs = until instanceof Date ? until.getTime() : Number(until) || Date.now();
return prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id } });
if (!user) return { credited: 0, ticks: 0, itemIds: [] };
const items = await tx.inventoryItem.findMany({
where: { userId: id, staked: true },
include: { item: true },
});
if (!items.length) return { credited: 0, ticks: 0, itemIds: [] };
let totalCredit = 0;
let maxTicks = 0;
const itemIds = [];
const now = new Date(untilMs);
for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt || now;
const anchorMs = new Date(anchor).getTime();
const elapsed = Math.max(0, untilMs - anchorMs);
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
if (ticks <= 0) continue;
const perTick = tickPayoutCents(inv, user, mode);
const credit = perTick * ticks;
if (credit > 0) {
totalCredit += credit;
itemIds.push(inv.id);
}
maxTicks = Math.max(maxTicks, ticks);
const advanced = new Date(anchorMs + ticks * VAULT_TICK_MS);
await tx.inventoryItem.update({
where: { id: inv.id },
data: { lastYieldAt: advanced },
});
}
if (totalCredit <= 0) {
return { credited: 0, ticks: maxTicks, itemIds: [] };
}
await tx.user.update({
where: { id },
data: { balance: { increment: totalCredit } },
});
await tx.transaction.create({
data: {
userId: id,
type: 'vault_yield',
amount: totalCredit,
meta: JSON.stringify({
mode,
ticks: maxTicks,
inventoryItemIds: itemIds,
}),
},
});
return { credited: totalCredit, ticks: maxTicks, itemIds };
});
}
/** Estimate pending yield for UI (incomplete ticks not included). */
export function estimatePendingYield(items, user, mode, now = Date.now()) {
let total = 0;
for (const inv of items) {
const anchor = inv.lastYieldAt || inv.stakedAt;
if (!anchor) continue;
const elapsed = Math.max(0, now - new Date(anchor).getTime());
const ticks = Math.floor(elapsed / VAULT_TICK_MS);
if (ticks <= 0) continue;
total += tickPayoutCents(inv, user, mode) * ticks;
}
return total;
}
/** Per-tick rate summary for UI (one tick online / offline). */
export function summarizeVaultRates(items, user) {
let onlinePerTick = 0;
let offlinePerTick = 0;
for (const inv of items) {
onlinePerTick += tickPayoutCents(inv, user, 'online');
offlinePerTick += tickPayoutCents(inv, user, 'offline');
}
return { onlinePerTick, offlinePerTick, tickMs: VAULT_TICK_MS };
}
+14 -1
View File
@@ -110,13 +110,26 @@ export function rollPaintSeed() {
return Math.floor(Math.random() * 1000);
}
/**
* 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),