This commit is contained in:
gpatruno
2026-07-18 17:25:56 +02:00
parent 2614d679c8
commit 2168998679
26 changed files with 2760 additions and 894 deletions
+12
View File
@@ -13,6 +13,7 @@ model User {
passwordHash String
balance BigInt @default(0)
osuBalance Int @default(0)
prBalance Int @default(0)
lastAdAt DateTime?
role String @default("user")
avatarUrl String @default("")
@@ -23,6 +24,15 @@ model User {
prestigeChanceBonus Int @default(0)
prestigeGainBonus Int @default(0)
prestigeKeyOpenReduction Int @default(0)
prestigeStartBonus Int @default(0)
prestigeAdBonus Int @default(0)
prestigeAdCooldownReduction Int @default(0)
prestigeWearBonus Int @default(0)
prestigePrBonus Int @default(0)
prestigeShopDiscount Int @default(0)
prestigeStartOsu Int @default(0)
prestigeDropValueBonus Int @default(0)
prestigeOpenBonus Int @default(0)
lastConnection DateTime?
playTimeSeconds BigInt @default(0)
createdAt DateTime @default(now())
@@ -56,6 +66,7 @@ model Item {
name String
imageUrl String @default("")
rarity String
category String @default("Misc")
marketValue BigInt
createdAt DateTime @default(now())
cases CaseItem[]
@@ -187,6 +198,7 @@ model PrestigeLog {
id Int @id @default(autoincrement())
userId Int
choice String
prGranted Int @default(0)
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+18 -18
View File
@@ -4,24 +4,24 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const ITEMS = [
{ name: 'Dust Fragment', rarity: 'Consumer', marketValue: 5, imageUrl: '' },
{ name: 'Scrap Token', rarity: 'Consumer', marketValue: 8, imageUrl: '' },
{ name: 'Plain Cap', rarity: 'Consumer', marketValue: 12, imageUrl: '' },
{ name: 'Blue Softpack', rarity: 'Industrial', marketValue: 25, imageUrl: '' },
{ name: 'Steel Badge', rarity: 'Industrial', marketValue: 35, imageUrl: '' },
{ name: 'Signal Chip', rarity: 'Industrial', marketValue: 40, imageUrl: '' },
{ name: 'Azure Coil', rarity: 'MilSpec', marketValue: 80, imageUrl: '' },
{ name: 'Navy Grip', rarity: 'MilSpec', marketValue: 95, imageUrl: '' },
{ name: 'Pulse Meter', rarity: 'MilSpec', marketValue: 110, imageUrl: '' },
{ name: 'Violet Core', rarity: 'Restricted', marketValue: 220, imageUrl: '' },
{ name: 'Phantom Lens', rarity: 'Restricted', marketValue: 280, imageUrl: '' },
{ name: 'Neon Circuit', rarity: 'Restricted', marketValue: 320, imageUrl: '' },
{ name: 'Pink Dynamo', rarity: 'Classified', marketValue: 650, imageUrl: '' },
{ name: 'Ruby Flux', rarity: 'Classified', marketValue: 800, imageUrl: '' },
{ name: 'Crimson Edge', rarity: 'Covert', marketValue: 1800, imageUrl: '' },
{ name: 'Blood Ember', rarity: 'Covert', marketValue: 2400, imageUrl: '' },
{ name: 'Golden Relic', rarity: 'Extraordinary', marketValue: 12000, imageUrl: '' },
{ name: 'Mythic Blade', rarity: 'Extraordinary', marketValue: 25000, imageUrl: '' },
{ name: 'Dust Fragment', rarity: 'Consumer', category: 'Misc', marketValue: 5, imageUrl: '' },
{ name: 'Scrap Token', rarity: 'Consumer', category: 'Misc', marketValue: 8, imageUrl: '' },
{ name: 'Plain Cap', rarity: 'Consumer', category: 'Agent', marketValue: 12, imageUrl: '' },
{ name: 'Blue Softpack', rarity: 'Industrial', category: 'Misc', marketValue: 25, imageUrl: '' },
{ name: 'Steel Badge', rarity: 'Industrial', category: 'Sticker', marketValue: 35, imageUrl: '' },
{ name: 'Signal Chip', rarity: 'Industrial', category: 'Misc', marketValue: 40, imageUrl: '' },
{ name: 'Azure Coil', rarity: 'MilSpec', category: 'SMG', marketValue: 80, imageUrl: '' },
{ name: 'Navy Grip', rarity: 'MilSpec', category: 'Pistol', marketValue: 95, imageUrl: '' },
{ name: 'Pulse Meter', rarity: 'MilSpec', category: 'Rifle', marketValue: 110, imageUrl: '' },
{ name: 'Violet Core', rarity: 'Restricted', category: 'Rifle', marketValue: 220, imageUrl: '' },
{ name: 'Phantom Lens', rarity: 'Restricted', category: 'Sniper', marketValue: 280, imageUrl: '' },
{ name: 'Neon Circuit', rarity: 'Restricted', category: 'SMG', marketValue: 320, imageUrl: '' },
{ name: 'Pink Dynamo', rarity: 'Classified', category: 'Rifle', marketValue: 650, imageUrl: '' },
{ name: 'Ruby Flux', rarity: 'Classified', category: 'Heavy', marketValue: 800, imageUrl: '' },
{ name: 'Crimson Edge', rarity: 'Covert', category: 'Knife', marketValue: 1800, imageUrl: '' },
{ name: 'Blood Ember', rarity: 'Covert', category: 'Knife', marketValue: 2400, imageUrl: '' },
{ name: 'Golden Relic', rarity: 'Extraordinary', category: 'Gloves', marketValue: 12000, imageUrl: '' },
{ name: 'Mythic Blade', rarity: 'Extraordinary', category: 'Knife', marketValue: 25000, imageUrl: '' },
];
async function main() {
+5 -4
View File
@@ -7,9 +7,10 @@ export const OPENS_PER_KEY_EARLY = 100;
/** From key 11 onward: cost = OPENS_PER_KEY_EARLY × (keys - 9). */
export const ESCALATION_KEY_THRESHOLD = 11;
export function maxOpensForKeys(keys) {
export function maxOpensForKeys(keys, openBonus = 0) {
const k = Math.max(1, keys);
return k + (BASE_MAX_OPENS - 1);
const bonus = Math.max(0, Math.floor(Number(openBonus) || 0));
return k + (BASE_MAX_OPENS - 1) + bonus;
}
export function opensRequiredForNextKey(keys, keyOpenReduction = 0) {
@@ -22,14 +23,14 @@ export function opensRequiredForNextKey(keys, keyOpenReduction = 0) {
return Math.max(1, base - reduction);
}
export function serializeKeyProgress(progress, keyOpenReduction = 0) {
export function serializeKeyProgress(progress, keyOpenReduction = 0, openBonus = 0) {
const keys = progress?.keys ?? 1;
const opensSinceLastKey = progress?.opensSinceLastKey ?? 0;
const totalOpens = progress?.totalOpens ?? 0;
const opensRequired = opensRequiredForNextKey(keys, keyOpenReduction);
return {
keys,
maxOpens: maxOpensForKeys(keys),
maxOpens: maxOpensForKeys(keys, openBonus),
opensSinceLastKey,
opensRequired,
totalOpens,
+18
View File
@@ -0,0 +1,18 @@
/** Suggested item categories (admin can still type custom ones). */
export const ITEM_CATEGORIES = [
'Rifle',
'Pistol',
'SMG',
'Heavy',
'Sniper',
'Knife',
'Gloves',
'Sticker',
'Agent',
'Misc',
];
export function normalizeCategory(value) {
const cat = String(value ?? '').trim();
return cat || 'Misc';
}
+1
View File
@@ -21,6 +21,7 @@ export function publicUser(user) {
username: user.username,
balance: user.balance,
osuBalance: user.osuBalance ?? 0,
prBalance: user.prBalance ?? 0,
lastAdAt: user.lastAdAt || null,
role: user.role,
avatarUrl: user.avatarUrl || '',
+12
View File
@@ -16,6 +16,18 @@ export function connectionCount(userId) {
return online.get(Number(userId))?.sockets.size || 0;
}
export function onlineUserCount() {
return online.size;
}
export function totalSocketCount() {
let n = 0;
for (const entry of online.values()) {
n += entry.sockets.size;
}
return n;
}
/**
* Track an authenticated socket. Updates lastConnection on first socket for this user.
* Rejects when the account already has MAX_SOCKETS_PER_USER live windows.
+248 -6
View File
@@ -4,23 +4,264 @@ export const PRESTIGE_COST_CENTS = 100_000_000_000_000n;
/** Page unlock: 10_000_000_000.00 cr total wealth (balance + unlocked inventory). */
export const PRESTIGE_VISIBLE_AT_CENTS = 1_000_000_000_000;
export const PRESTIGE_CHOICES = {
chance: { field: 'prestigeChanceBonus', delta: 1, label: 'chance' },
gain: { field: 'prestigeGainBonus', delta: 2, label: 'gain' },
key: { field: 'prestigeKeyOpenReduction', delta: 10, label: 'key' },
/** Base Pr granted on every prestige. */
export const PR_BASE_REWARD = 1000;
/**
* For every 100_000_000_000.00 cr above the prestige cost, +1000 Pr on average.
* (Stored in cents.)
*/
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).
* level = how many times this branch has been purchased.
*/
export const PRESTIGE_SKILLS = {
chance: {
id: 'chance',
field: 'prestigeChanceBonus',
delta: 1,
title: 'Fortune',
description: '+1% drop luck on case opens (permanent)',
unit: '%',
branch: 'drops',
},
gain: {
id: 'gain',
field: 'prestigeGainBonus',
delta: 2,
title: 'Yield',
description: '+2% credits on sell / auto-sell (permanent)',
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',
delta: 1,
title: 'Polish',
description: 'Better float / wear rolls on case opens',
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',
delta: 1,
title: 'Bulk',
description: '+1 max simultaneous case opens (permanent)',
unit: '',
branch: 'drops',
},
};
/** Cost in Pr for the next purchase of a skill (level = current purchases). */
export function skillUpgradeCost(skillId, level = 0) {
const skill = PRESTIGE_SKILLS[skillId];
const lv = Math.max(0, Number(level) || 0);
const mult = skill?.costMult || 1;
return Math.round((1000 + lv * 500) * mult);
}
export function skillLevelFromUser(user, skillId) {
const skill = PRESTIGE_SKILLS[skillId];
if (!skill) return 0;
const value = Number(user?.[skill.field] ?? 0);
const delta = Math.max(1, Number(skill.delta) || 1);
return Math.floor(value / delta);
}
/**
* Expected Pr (no RNG) for UI preview, including Echo bonus.
*/
export function estimatePrReward(balanceCents, prBonusPct = 0) {
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);
}
/**
* Pr reward: 1000 base + 1000 per full 100B cr excess, plus a fractional
* roll so partial chunks average to the remaining Pr. Then Echo %.
*/
export function computePrReward(balanceCents, prBonusPct = 0) {
const balance = BigInt(balanceCents);
const cost = PRESTIGE_COST_CENTS;
const excess = balance > cost ? balance - cost : 0n;
const chunk = PR_EXCESS_CHUNK_CENTS;
const full = Number(excess / chunk);
const rem = excess % chunk;
const frac = Number(rem) / Number(chunk);
const bonus =
full * PR_PER_EXCESS_CHUNK +
(Math.random() < frac ? PR_PER_EXCESS_CHUNK : 0);
return applyPrBonus(PR_BASE_REWARD + bonus, prBonusPct);
}
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 serializePrestige(user) {
return {
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,
prBalance: user?.prBalance ?? 0,
costCents: Number(PRESTIGE_COST_CENTS),
visibleAtCents: PRESTIGE_VISIBLE_AT_CENTS,
prBaseReward: PR_BASE_REWARD,
prPerExcessChunk: PR_PER_EXCESS_CHUNK,
excessChunkCents: Number(PR_EXCESS_CHUNK_CENTS),
};
}
export function serializeSkillTree(user) {
return Object.values(PRESTIGE_SKILLS).map((skill) => {
const level = skillLevelFromUser(user, skill.id);
const current = Number(user?.[skill.field] ?? 0);
const cost = skillUpgradeCost(skill.id, level);
return {
id: skill.id,
title: skill.title,
description: skill.description,
delta: skill.delta,
unit: skill.unit,
prefix: skill.prefix || (skill.unit === '%' || skill.unit === 'cr-cents' ? '+' : ''),
branch: skill.branch || 'misc',
level,
current,
nextValue: current + skill.delta,
cost,
canAfford: (user?.prBalance ?? 0) >= cost,
};
});
}
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 lifetime gain % to a credit amount (cents). */
export function applyGainMultiplier(cents, gainBonusPct = 0) {
const base = Number(cents) || 0;
@@ -33,13 +274,14 @@ export function totalLuckPercent(catalogLuck = 0, prestigeChanceBonus = 0) {
return (Number(catalogLuck) || 0) + (Number(prestigeChanceBonus) || 0);
}
export function isValidPrestigeChoice(choice) {
return Boolean(PRESTIGE_CHOICES[choice]);
export function isValidSkill(skillId) {
return Boolean(PRESTIGE_SKILLS[skillId]);
}
/** Visible if already prestiged, or total wealth reaches the unlock threshold. */
export function isPrestigePageVisible(user, totalCents) {
if ((user?.prestigeCount ?? 0) > 0) return true;
if ((user?.prBalance ?? 0) > 0) return true;
return (Number(totalCents) || 0) >= PRESTIGE_VISIBLE_AT_CENTS;
}
+198 -2
View File
@@ -4,6 +4,9 @@ import { prisma } from '../db.js';
import { requireAdmin, publicUser } from '../middleware.js';
import { isValidRarity, RARITIES } from '../rarities.js';
import { createUploader } from '../upload.js';
import { onlineUserCount, totalSocketCount } from '../presence.js';
import { ACHIEVEMENT_DEFS } from '../achievements.js';
import { ITEM_CATEGORIES, normalizeCategory } from '../categories.js';
const router = Router();
const upload = createUploader('asset');
@@ -37,6 +40,194 @@ router.get('/rarities', requireAdmin, (_req, res) => {
res.json({ rarities: RARITIES });
});
router.get('/categories', requireAdmin, async (_req, res) => {
try {
const rows = await prisma.item.findMany({
select: { category: true },
distinct: ['category'],
orderBy: { category: 'asc' },
});
const fromDb = rows.map((r) => r.category).filter(Boolean);
const categories = [...new Set([...ITEM_CATEGORIES, ...fromDb])].sort((a, b) =>
a.localeCompare(b)
);
res.json({ categories, suggested: ITEM_CATEGORIES });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load categories' });
}
});
router.get('/stats', requireAdmin, async (_req, res) => {
try {
const [
players,
casesTotal,
casesActive,
items,
inventoryItems,
caseOpens,
achievementsUnlocked,
recentUsers,
] = await Promise.all([
prisma.user.count({ where: { role: 'user' } }),
prisma.case.count(),
prisma.case.count({ where: { active: true } }),
prisma.item.count(),
prisma.inventoryItem.count(),
prisma.transaction.count({ where: { type: 'open_case' } }),
prisma.userAchievement.count(),
prisma.user.findMany({
where: { role: 'user' },
orderBy: { lastConnection: 'desc' },
take: 5,
select: {
id: true,
username: true,
avatarUrl: true,
balance: true,
lastConnection: true,
},
}),
]);
res.json({
stats: {
players,
casesTotal,
casesActive,
items,
inventoryItems,
caseOpens,
achievementsDefined: ACHIEVEMENT_DEFS.length,
achievementsUnlocked,
onlinePlayers: onlineUserCount(),
openWindows: totalSocketCount(),
},
recentPlayers: recentUsers.map((u) => ({
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
lastConnection: u.lastConnection,
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load admin stats' });
}
});
router.get('/users', requireAdmin, async (_req, res) => {
try {
const users = await prisma.user.findMany({
where: { role: 'user' },
include: {
inventory: { select: { valueCents: true } },
_count: {
select: {
achievements: true,
},
},
},
orderBy: { id: 'asc' },
});
const openCounts = await prisma.transaction.groupBy({
by: ['userId'],
where: { type: 'open_case' },
_count: { _all: true },
});
const opensByUser = new Map(openCounts.map((r) => [r.userId, r._count._all]));
res.json({
users: users.map((u) => {
const inventoryValue = u.inventory.reduce((s, inv) => s + inv.valueCents, 0);
return {
id: u.id,
username: u.username,
avatarUrl: u.avatarUrl || '',
balance: u.balance,
osuBalance: u.osuBalance ?? 0,
inventoryValue,
inventoryCount: u.inventory.length,
totalWealth: u.balance + inventoryValue,
casesOpened: opensByUser.get(u.id) || 0,
achievements: u._count.achievements,
prestigeCount: u.prestigeCount ?? 0,
lastConnection: u.lastConnection,
playTimeSeconds: Number(u.playTimeSeconds ?? 0),
createdAt: u.createdAt,
};
}),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load users' });
}
});
router.patch('/users/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const user = await prisma.user.findUnique({ where: { id } });
if (!user || user.role !== 'user') {
return res.status(404).json({ error: 'Player not found' });
}
const data = {};
if (req.body.balance !== undefined) {
const balance = Number(req.body.balance);
if (!Number.isInteger(balance) || balance < 0) {
return res.status(400).json({ error: 'balance must be a non-negative integer (cents)' });
}
data.balance = balance;
}
if (req.body.balanceDelta !== undefined) {
const delta = Number(req.body.balanceDelta);
if (!Number.isInteger(delta)) {
return res.status(400).json({ error: 'balanceDelta must be an integer (cents)' });
}
const next = Number(user.balance) + delta;
if (next < 0) {
return res.status(400).json({ error: 'Resulting balance cannot be negative' });
}
data.balance = next;
}
if (req.body.osuBalance !== undefined) {
const osu = Number(req.body.osuBalance);
if (!Number.isInteger(osu) || osu < 0) {
return res.status(400).json({ error: 'osuBalance must be a non-negative integer' });
}
data.osuBalance = osu;
}
if (!Object.keys(data).length) {
return res.status(400).json({ error: 'No changes provided' });
}
const updated = await prisma.$transaction(async (tx) => {
const next = await tx.user.update({ where: { id }, data });
if (data.balance !== undefined && Number(data.balance) !== Number(user.balance)) {
await tx.transaction.create({
data: {
userId: id,
type: 'admin_adjust',
amount: Number(data.balance) - Number(user.balance),
meta: JSON.stringify({ reason: 'admin_panel', by: req.session.userId }),
},
});
}
return next;
});
res.json({ user: publicUser(updated) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update user' });
}
});
router.post('/upload', requireAdmin, (req, res) => {
upload.single('image')(req, res, (err) => {
if (err) {
@@ -72,6 +263,7 @@ router.get('/cases', requireAdmin, async (_req, res) => {
id: ci.item.id,
name: ci.item.name,
rarity: ci.item.rarity,
category: ci.item.category || 'Misc',
marketValue: ci.item.marketValue,
imageUrl: ci.item.imageUrl,
},
@@ -143,7 +335,7 @@ router.delete('/cases/:id', requireAdmin, async (req, res) => {
// —— Items ——
router.get('/items', requireAdmin, async (_req, res) => {
try {
const items = await prisma.item.findMany({ orderBy: { id: 'asc' } });
const items = await prisma.item.findMany({ orderBy: [{ category: 'asc' }, { id: 'asc' }] });
res.json({ items });
} catch (err) {
console.error(err);
@@ -156,6 +348,7 @@ router.post('/items', requireAdmin, async (req, res) => {
const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || '');
const rarity = String(req.body.rarity || '');
const category = normalizeCategory(req.body.category);
const marketValue = Number(req.body.marketValue);
if (!name) return res.status(400).json({ error: 'Name is required' });
@@ -167,7 +360,7 @@ router.post('/items', requireAdmin, async (req, res) => {
}
const created = await prisma.item.create({
data: { name, imageUrl, rarity, marketValue },
data: { name, imageUrl, rarity, category, marketValue },
});
res.status(201).json({ item: created });
} catch (err) {
@@ -188,6 +381,9 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
}
data.rarity = req.body.rarity;
}
if (req.body.category !== undefined) {
data.category = normalizeCategory(req.body.category);
}
if (req.body.marketValue !== undefined) {
const marketValue = Number(req.body.marketValue);
if (!Number.isInteger(marketValue) || marketValue < 0) {
+17 -9
View File
@@ -15,7 +15,7 @@ import {
fillCatalogSlots,
formatCaseItemsWithLuck,
} from '../catalog.js';
import { totalLuckPercent } from '../prestige.js';
import { totalLuckPercent, applyDropValueBonus } from '../prestige.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
@@ -43,7 +43,7 @@ function formatCase(c, luckPercent = 0) {
async function loadUserLuckAndKeys(userId) {
if (!userId) {
return { luckPercent: 0, keyOpenReduction: 0, user: null };
return { luckPercent: 0, keyOpenReduction: 0, openBonus: 0, dropValueBonus: 0, user: null };
}
const [catalogLuck, user] = await Promise.all([
countCompletedChapters(prisma, userId),
@@ -52,12 +52,17 @@ async function loadUserLuckAndKeys(userId) {
select: {
prestigeChanceBonus: true,
prestigeKeyOpenReduction: true,
prestigeWearBonus: true,
prestigeOpenBonus: true,
prestigeDropValueBonus: true,
},
}),
]);
return {
luckPercent: totalLuckPercent(catalogLuck, user?.prestigeChanceBonus),
keyOpenReduction: user?.prestigeKeyOpenReduction ?? 0,
openBonus: user?.prestigeOpenBonus ?? 0,
dropValueBonus: user?.prestigeDropValueBonus ?? 0,
user,
};
}
@@ -81,7 +86,7 @@ router.get('/', async (req, res) => {
orderBy: { price: 'asc' },
});
const progressMap = await loadKeyProgressMap(userId);
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
res.json({
catalogLuckPercent: luckPercent,
cases: cases.map((c) => {
@@ -90,7 +95,7 @@ router.get('/', async (req, res) => {
const progress = progressMap.get(c.id);
return {
...formatted,
keyProgress: serializeKeyProgress(progress, keyOpenReduction),
keyProgress: serializeKeyProgress(progress, keyOpenReduction, openBonus),
};
}),
});
@@ -113,13 +118,13 @@ router.get('/:id', async (req, res) => {
if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' });
}
const { luckPercent, keyOpenReduction } = await loadUserLuckAndKeys(userId);
const { luckPercent, keyOpenReduction, openBonus } = await loadUserLuckAndKeys(userId);
const formatted = formatCase(c, luckPercent);
if (userId) {
const progress = await prisma.caseKeyProgress.findUnique({
where: { userId_caseId: { userId, caseId: id } },
});
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction);
formatted.keyProgress = serializeKeyProgress(progress, keyOpenReduction, openBonus);
}
res.json({ case: formatted });
} catch (err) {
@@ -163,7 +168,9 @@ router.post('/:id/open', requireAuth, async (req, res) => {
}
const progressRow = await getOrCreateProgress(tx, userId, caseId);
const maxOpens = maxOpensForKeys(progressRow.keys);
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`);
err.status = 400;
@@ -192,7 +199,8 @@ router.post('/:id/open', requireAuth, async (req, res) => {
for (let i = 0; i < count; i += 1) {
const winner = pickWeighted(weighted);
const item = winner.caseItem.item;
const rolled = rollInstanceValue(item.marketValue);
const rolled = rollInstanceValue(item.marketValue, user.prestigeWearBonus ?? 0);
rolled.valueCents = applyDropValueBonus(rolled.valueCents, dropValueBonus);
const inventoryItem = await tx.inventoryItem.create({
data: {
@@ -272,7 +280,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
user: publicUser(updatedUser),
caseName: c.name,
items: drops,
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction),
keyProgress: serializeKeyProgress(updatedProgress, keyOpenReduction, openBonus),
keysGained: applied.keysGained,
catalog,
};
+112 -52
View File
@@ -2,17 +2,22 @@ import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import {
PRESTIGE_CHOICES,
PRESTIGE_COST_CENTS,
PRESTIGE_SKILLS,
computePrReward,
estimatePrReward,
getUnlockedInventoryValueCents,
isPrestigePageVisible,
isValidPrestigeChoice,
isValidSkill,
serializePrestige,
serializeSkillTree,
skillLevelFromUser,
skillUpgradeCost,
} from '../prestige.js';
import { checkAchievements } from '../achievements.js';
const router = Router();
const COST = Number(PRESTIGE_COST_CENTS);
const COST = PRESTIGE_COST_CENTS;
router.get('/', requireAuth, async (req, res) => {
try {
@@ -31,44 +36,28 @@ router.get('/', requireAuth, async (req, res) => {
}),
]);
const totalCents = (Number(user.balance) || 0) + inventoryValue;
const balance = BigInt(user.balance);
const totalCents = Number(user.balance) + inventoryValue;
const pageVisible = isPrestigePageVisible(user, totalCents);
const canAfford = user.balance >= COST;
const canAfford = balance >= COST;
const canPrestige = pageVisible && canAfford && lockedCount === 0;
const previewPr = canAfford
? estimatePrReward(user.balance, user.prestigePrBonus ?? 0)
: 0;
res.json({
prestige: serializePrestige(user),
skills: serializeSkillTree(user),
pageVisible,
totalCents,
canAfford,
lockedItems: lockedCount,
canPrestige,
choices: [
{
id: 'chance',
title: 'Chance',
description: '+1% catalog-style drop luck (permanent, stacks)',
delta: PRESTIGE_CHOICES.chance.delta,
current: user.prestigeChanceBonus ?? 0,
},
{
id: 'gain',
title: 'Gain multiplier',
description: '+2% credits on sell / auto-sell (permanent, stacks)',
delta: PRESTIGE_CHOICES.gain.delta,
current: user.prestigeGainBonus ?? 0,
},
{
id: 'key',
title: 'Key progress',
description: '10 opens required for next key on all cases (permanent, stacks)',
delta: PRESTIGE_CHOICES.key.delta,
current: user.prestigeKeyOpenReduction ?? 0,
},
],
previewPr,
history: logs.map((l) => ({
id: l.id,
choice: l.choice,
prGranted: l.prGranted ?? 0,
createdAt: l.createdAt,
})),
});
@@ -80,11 +69,6 @@ router.get('/', requireAuth, async (req, res) => {
router.post('/', requireAuth, async (req, res) => {
try {
const choice = String(req.body?.choice || '');
if (!isValidPrestigeChoice(choice)) {
return res.status(400).json({ error: 'Choose chance, gain, or key' });
}
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
@@ -97,7 +81,7 @@ router.post('/', requireAuth, async (req, res) => {
err.status = 403;
throw err;
}
if (user.balance < COST) {
if (BigInt(user.balance) < COST) {
const err = new Error('Insufficient balance for prestige');
err.status = 400;
throw err;
@@ -114,12 +98,10 @@ router.post('/', requireAuth, async (req, res) => {
throw err;
}
const boost = PRESTIGE_CHOICES[choice];
const prestigeData = {
prestigeCount: (user.prestigeCount ?? 0) + 1,
[boost.field]: (user[boost.field] ?? 0) + boost.delta,
balance: 0,
};
const prGranted = computePrReward(user.balance, user.prestigePrBonus ?? 0);
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 } });
@@ -129,34 +111,45 @@ router.post('/', requireAuth, async (req, res) => {
data: {
userId: user.id,
type: 'prestige',
amount: -COST,
amount: -Number(COST),
meta: JSON.stringify({
choice,
prestigeCount: prestigeData.prestigeCount,
delta: boost.delta,
field: boost.field,
prestigeCount,
prGranted,
previousBalance: Number(user.balance),
startBalance,
startOsu,
}),
},
});
await tx.prestigeLog.create({
data: { userId: user.id, choice },
data: {
userId: user.id,
choice: 'reset',
prGranted,
},
});
const updated = await tx.user.update({
where: { id: user.id },
data: prestigeData,
data: {
prestigeCount,
balance: startBalance,
...(startOsu > 0 ? { osuBalance: { increment: startOsu } } : {}),
prBalance: { increment: prGranted },
},
});
return updated;
return { user: updated, prGranted };
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({
user: publicUser(result),
prestige: serializePrestige(result),
choice,
user: publicUser(result.user),
prestige: serializePrestige(result.user),
skills: serializeSkillTree(result.user),
prGranted: result.prGranted,
});
} catch (err) {
console.error(err);
@@ -165,4 +158,71 @@ router.post('/', requireAuth, async (req, res) => {
}
});
router.post('/upgrade', requireAuth, async (req, res) => {
try {
const skillId = String(req.body?.skillId || '');
if (!isValidSkill(skillId)) {
return res.status(400).json({ error: 'Unknown skill' });
}
const skill = PRESTIGE_SKILLS[skillId];
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;
}
if (user.role === 'admin') {
const err = new Error('Admins cannot buy prestige skills');
err.status = 403;
throw err;
}
const level = skillLevelFromUser(user, skillId);
const cost = skillUpgradeCost(skillId, level);
if ((user.prBalance ?? 0) < cost) {
const err = new Error('Not enough Pr');
err.status = 400;
throw err;
}
const updated = await tx.user.update({
where: { id: user.id },
data: {
prBalance: { decrement: cost },
[skill.field]: { increment: skill.delta },
},
});
await tx.transaction.create({
data: {
userId: user.id,
type: 'prestige_upgrade',
amount: 0,
meta: JSON.stringify({
skillId,
cost,
delta: skill.delta,
field: skill.field,
}),
},
});
return updated;
});
res.json({
user: publicUser(result),
prestige: serializePrestige(result),
skills: serializeSkillTree(result),
});
} catch (err) {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: err.message || 'Failed to upgrade skill' });
}
});
export default router;
+48 -31
View File
@@ -2,6 +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';
const router = Router();
@@ -13,30 +14,42 @@ export const SHOP_PACKAGES = [
{ id: 'whale', name: 'Whale Pack', osuCost: 100, credits: 80000, blurb: '800.00 cr' },
];
const AD_COOLDOWN_MS = 60 * 1000;
const AD_REWARD_MIN = 10000; // 100.00 cr
const AD_REWARD_MAX = 20000; // 200.00 cr
function adPayload(user, overrides = {}) {
const cooldownMs = adCooldownMs(user);
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),
ready: adReady,
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
lastAdAt: user.lastAdAt,
...overrides,
};
}
router.get('/', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
const nextAdAt = lastAdAt + AD_COOLDOWN_MS;
const adReady = Date.now() >= nextAdAt;
const discount = user.prestigeShopDiscount ?? 0;
const packages = SHOP_PACKAGES.map((pack) => ({
...pack,
osuCost: shopOsuCost(pack.osuCost, discount),
baseOsuCost: pack.osuCost,
}));
res.json({
packages: SHOP_PACKAGES,
packages,
user: publicUser(user),
ad: {
cooldownMs: AD_COOLDOWN_MS,
rewardMin: AD_REWARD_MIN,
rewardMax: AD_REWARD_MAX,
ready: adReady,
nextAdAt: adReady ? null : new Date(nextAdAt).toISOString(),
lastAdAt: user.lastAdAt,
},
ad: adPayload(user),
});
} catch (err) {
console.error(err);
@@ -59,7 +72,8 @@ router.post('/buy', requireAuth, async (req, res) => {
err.status = 404;
throw err;
}
if (user.osuBalance < pack.osuCost) {
const osuCost = shopOsuCost(pack.osuCost, user.prestigeShopDiscount ?? 0);
if (user.osuBalance < osuCost) {
const err = new Error('Not enough osu');
err.status = 400;
throw err;
@@ -68,7 +82,7 @@ router.post('/buy', requireAuth, async (req, res) => {
const updated = await tx.user.update({
where: { id: user.id },
data: {
osuBalance: { decrement: pack.osuCost },
osuBalance: { decrement: osuCost },
balance: { increment: pack.credits },
},
});
@@ -80,18 +94,22 @@ router.post('/buy', requireAuth, async (req, res) => {
amount: pack.credits,
meta: JSON.stringify({
packageId: pack.id,
osuCost: pack.osuCost,
osuCost,
baseOsuCost: pack.osuCost,
credits: pack.credits,
}),
},
});
return updated;
return { user: updated, osuCost };
});
checkAchievements(result.id).catch((err) => console.error('achievements', err));
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
res.json({ user: publicUser(result), package: pack });
res.json({
user: publicUser(result.user),
package: { ...pack, osuCost: result.osuCost },
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
@@ -109,18 +127,20 @@ router.post('/ad', requireAuth, async (req, res) => {
throw err;
}
const cooldownMs = adCooldownMs(user);
const lastAdAt = user.lastAdAt ? new Date(user.lastAdAt).getTime() : 0;
const elapsed = Date.now() - lastAdAt;
if (elapsed < AD_COOLDOWN_MS) {
const waitSec = Math.ceil((AD_COOLDOWN_MS - elapsed) / 1000);
if (elapsed < cooldownMs) {
const waitSec = Math.ceil((cooldownMs - elapsed) / 1000);
const err = new Error(`Ad available in ${waitSec}s`);
err.status = 429;
err.nextAdAt = new Date(lastAdAt + AD_COOLDOWN_MS).toISOString();
err.nextAdAt = new Date(lastAdAt + cooldownMs).toISOString();
throw err;
}
const reward =
const base =
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({
@@ -136,11 +156,11 @@ router.post('/ad', requireAuth, async (req, res) => {
userId: user.id,
type: 'ad_reward',
amount: reward,
meta: JSON.stringify({ reward }),
meta: JSON.stringify({ reward, base, adBonus: user.prestigeAdBonus ?? 0 }),
},
});
return { user: updated, reward };
return { user: updated, reward, cooldownMs };
});
checkAchievements(result.user.id).catch((err) => console.error('achievements', err));
@@ -148,13 +168,10 @@ router.post('/ad', requireAuth, async (req, res) => {
res.json({
user: publicUser(result.user),
reward: result.reward,
ad: {
ad: adPayload(result.user, {
ready: false,
nextAdAt: new Date(Date.now() + AD_COOLDOWN_MS).toISOString(),
cooldownMs: AD_COOLDOWN_MS,
rewardMin: AD_REWARD_MIN,
rewardMax: AD_REWARD_MAX,
},
nextAdAt: new Date(Date.now() + result.cooldownMs).toISOString(),
}),
});
} catch (err) {
const status = err.status || 500;
+6 -2
View File
@@ -111,8 +111,12 @@ export function rollPaintSeed() {
}
/** Full roll for a new inventory instance from catalog marketValue */
export function rollInstanceValue(baseMarketValue) {
const floatValue = rollFloat();
export function rollInstanceValue(baseMarketValue, wearBonus = 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));
}
return {
floatValue,
wear: wearFromFloat(floatValue),