first commit

This commit is contained in:
2026-07-16 17:36:21 +02:00
commit a0d7163464
45 changed files with 7481 additions and 0 deletions
+2939
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "casegambling-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env src/index.js",
"start": "node --env-file=.env src/index.js",
"db:push": "prisma db push",
"db:seed": "node prisma/seed.js",
"db:setup": "prisma db push && node prisma/seed.js"
},
"prisma": {
"seed": "node prisma/seed.js"
},
"dependencies": {
"@prisma/client": "^6.11.1",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"express-session": "^1.18.1"
},
"devDependencies": {
"prisma": "^6.11.1"
}
}
+70
View File
@@ -0,0 +1,70 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
passwordHash String
balance Int @default(0)
role String @default("user")
createdAt DateTime @default(now())
inventory InventoryItem[]
transactions Transaction[]
}
model Case {
id Int @id @default(autoincrement())
name String
imageUrl String @default("")
price Int
active Boolean @default(true)
createdAt DateTime @default(now())
items CaseItem[]
}
model Item {
id Int @id @default(autoincrement())
name String
imageUrl String @default("")
rarity String
marketValue Int
createdAt DateTime @default(now())
cases CaseItem[]
inventory InventoryItem[]
}
model CaseItem {
id Int @id @default(autoincrement())
caseId Int
itemId Int
dropRate Float
case Case @relation(fields: [caseId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@unique([caseId, itemId])
}
model InventoryItem {
id Int @id @default(autoincrement())
userId Int
itemId Int
obtainedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
}
model Transaction {
id Int @id @default(autoincrement())
userId Int
type String
amount Int
meta String @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+148
View File
@@ -0,0 +1,148 @@
import bcrypt from 'bcrypt';
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: '' },
];
async function main() {
await prisma.transaction.deleteMany();
await prisma.inventoryItem.deleteMany();
await prisma.caseItem.deleteMany();
await prisma.case.deleteMany();
await prisma.item.deleteMany();
await prisma.user.deleteMany();
const adminHash = await bcrypt.hash('admin123', 10);
const userHash = await bcrypt.hash('player123', 10);
await prisma.user.create({
data: {
username: 'admin',
passwordHash: adminHash,
balance: 0,
role: 'admin',
},
});
const demo = await prisma.user.create({
data: {
username: 'player',
passwordHash: userHash,
balance: 10000,
role: 'user',
},
});
await prisma.transaction.create({
data: {
userId: demo.id,
type: 'seed_credit',
amount: 10000,
meta: JSON.stringify({ reason: 'demo_seed' }),
},
});
const items = [];
for (const data of ITEMS) {
items.push(await prisma.item.create({ data }));
}
const byName = Object.fromEntries(items.map((i) => [i.name, i]));
const starter = await prisma.case.create({
data: {
name: 'Starter Crate',
imageUrl: '',
price: 100,
active: true,
},
});
const arsenal = await prisma.case.create({
data: {
name: 'Arsenal Case',
imageUrl: '',
price: 250,
active: true,
},
});
const vault = await prisma.case.create({
data: {
name: 'Gold Vault',
imageUrl: '',
price: 500,
active: true,
},
});
const drops = [
[starter, 'Dust Fragment', 40],
[starter, 'Scrap Token', 30],
[starter, 'Plain Cap', 15],
[starter, 'Blue Softpack', 8],
[starter, 'Azure Coil', 5],
[starter, 'Violet Core', 1.5],
[starter, 'Crimson Edge', 0.4],
[starter, 'Golden Relic', 0.1],
[arsenal, 'Steel Badge', 35],
[arsenal, 'Signal Chip', 25],
[arsenal, 'Navy Grip', 18],
[arsenal, 'Pulse Meter', 12],
[arsenal, 'Phantom Lens', 6],
[arsenal, 'Pink Dynamo', 2.5],
[arsenal, 'Blood Ember', 1.2],
[arsenal, 'Mythic Blade', 0.3],
[vault, 'Neon Circuit', 40],
[vault, 'Ruby Flux', 30],
[vault, 'Crimson Edge', 18],
[vault, 'Blood Ember', 8],
[vault, 'Golden Relic', 3],
[vault, 'Mythic Blade', 1],
];
for (const [c, name, dropRate] of drops) {
await prisma.caseItem.create({
data: {
caseId: c.id,
itemId: byName[name].id,
dropRate,
},
});
}
console.log('Seed complete.');
console.log(' admin / admin123');
console.log(' player / player123 (balance: 100.00)');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+54
View File
@@ -0,0 +1,54 @@
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import authRoutes from './routes/auth.js';
import casesRoutes from './routes/cases.js';
import inventoryRoutes from './routes/inventory.js';
import leaderboardRoutes from './routes/leaderboard.js';
import adminRoutes from './routes/admin.js';
const app = express();
const PORT = Number(process.env.PORT || 3001);
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN || 'http://localhost:5173';
app.use(
cors({
origin: CLIENT_ORIGIN,
credentials: true,
})
);
app.use(express.json());
app.use(
session({
secret: process.env.SESSION_SECRET || 'dev-secret',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: false,
maxAge: 7 * 24 * 60 * 60 * 1000,
},
})
);
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});
app.use('/api/auth', authRoutes);
app.use('/api/cases', casesRoutes);
app.use('/api/inventory', inventoryRoutes);
app.use('/api/leaderboard', leaderboardRoutes);
app.use('/api/admin', adminRoutes);
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(PORT, () => {
console.log(`API listening on http://localhost:${PORT}`);
});
+25
View File
@@ -0,0 +1,25 @@
export function requireAuth(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
next();
}
export function requireAdmin(req, res, next) {
if (!req.session?.userId) {
return res.status(401).json({ error: 'Authentication required' });
}
if (req.session.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
}
export function publicUser(user) {
return {
id: user.id,
username: user.username,
balance: user.balance,
role: user.role,
};
}
+23
View File
@@ -0,0 +1,23 @@
export const RARITIES = [
'Consumer',
'Industrial',
'MilSpec',
'Restricted',
'Classified',
'Covert',
'Extraordinary',
];
export const RARITY_COLORS = {
Consumer: '#b0c3d9',
Industrial: '#5e98d9',
MilSpec: '#4b69ff',
Restricted: '#8847ff',
Classified: '#d32ce6',
Covert: '#eb4b4b',
Extraordinary: '#ffd700',
};
export function isValidRarity(rarity) {
return RARITIES.includes(rarity);
}
+262
View File
@@ -0,0 +1,262 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '../db.js';
import { requireAdmin, publicUser } from '../middleware.js';
import { isValidRarity, RARITIES } from '../rarities.js';
const router = Router();
router.post('/login', async (req, res) => {
try {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const user = await prisma.user.findUnique({ where: { username } });
if (!user || user.role !== 'admin') {
return res.status(401).json({ error: 'Invalid admin credentials' });
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
return res.status(401).json({ error: 'Invalid admin credentials' });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Admin login failed' });
}
});
router.get('/rarities', requireAdmin, (_req, res) => {
res.json({ rarities: RARITIES });
});
// —— Cases ——
router.get('/cases', requireAdmin, async (_req, res) => {
try {
const cases = await prisma.case.findMany({
include: {
items: { include: { item: true } },
},
orderBy: { id: 'asc' },
});
res.json({
cases: cases.map((c) => ({
id: c.id,
name: c.name,
imageUrl: c.imageUrl,
price: c.price,
active: c.active,
items: c.items.map((ci) => ({
id: ci.id,
dropRate: ci.dropRate,
item: {
id: ci.item.id,
name: ci.item.name,
rarity: ci.item.rarity,
marketValue: ci.item.marketValue,
imageUrl: ci.item.imageUrl,
},
})),
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load cases' });
}
});
router.post('/cases', requireAdmin, async (req, res) => {
try {
const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || '');
const price = Number(req.body.price);
const active = req.body.active !== false;
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!Number.isInteger(price) || price < 0) {
return res.status(400).json({ error: 'Price must be a non-negative integer (cents)' });
}
const created = await prisma.case.create({
data: { name, imageUrl, price, active },
});
res.status(201).json({ case: created });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create case' });
}
});
router.put('/cases/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const data = {};
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
if (req.body.price !== undefined) {
const price = Number(req.body.price);
if (!Number.isInteger(price) || price < 0) {
return res.status(400).json({ error: 'Invalid price' });
}
data.price = price;
}
if (req.body.active !== undefined) data.active = Boolean(req.body.active);
const updated = await prisma.case.update({ where: { id }, data });
res.json({ case: updated });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update case' });
}
});
router.delete('/cases/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
await prisma.case.delete({ where: { id } });
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete case' });
}
});
// —— Items ——
router.get('/items', requireAdmin, async (_req, res) => {
try {
const items = await prisma.item.findMany({ orderBy: { id: 'asc' } });
res.json({ items });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load items' });
}
});
router.post('/items', requireAdmin, async (req, res) => {
try {
const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || '');
const rarity = String(req.body.rarity || '');
const marketValue = Number(req.body.marketValue);
if (!name) return res.status(400).json({ error: 'Name is required' });
if (!isValidRarity(rarity)) {
return res.status(400).json({ error: `Invalid rarity. Use: ${RARITIES.join(', ')}` });
}
if (!Number.isInteger(marketValue) || marketValue < 0) {
return res.status(400).json({ error: 'marketValue must be a non-negative integer (cents)' });
}
const created = await prisma.item.create({
data: { name, imageUrl, rarity, marketValue },
});
res.status(201).json({ item: created });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create item' });
}
});
router.put('/items/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const data = {};
if (req.body.name !== undefined) data.name = String(req.body.name).trim();
if (req.body.imageUrl !== undefined) data.imageUrl = String(req.body.imageUrl);
if (req.body.rarity !== undefined) {
if (!isValidRarity(req.body.rarity)) {
return res.status(400).json({ error: 'Invalid rarity' });
}
data.rarity = req.body.rarity;
}
if (req.body.marketValue !== undefined) {
const marketValue = Number(req.body.marketValue);
if (!Number.isInteger(marketValue) || marketValue < 0) {
return res.status(400).json({ error: 'Invalid marketValue' });
}
data.marketValue = marketValue;
}
const updated = await prisma.item.update({ where: { id }, data });
res.json({ item: updated });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update item' });
}
});
router.delete('/items/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
await prisma.item.delete({ where: { id } });
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete item' });
}
});
// —— Case drops ——
router.post('/cases/:caseId/items', requireAdmin, async (req, res) => {
try {
const caseId = Number(req.params.caseId);
const itemId = Number(req.body.itemId);
const dropRate = Number(req.body.dropRate);
if (!Number.isInteger(itemId)) {
return res.status(400).json({ error: 'itemId required' });
}
if (!(dropRate > 0)) {
return res.status(400).json({ error: 'dropRate must be > 0' });
}
const created = await prisma.caseItem.create({
data: { caseId, itemId, dropRate },
include: { item: true },
});
res.status(201).json({ caseItem: created });
} catch (err) {
console.error(err);
if (err.code === 'P2002') {
return res.status(409).json({ error: 'Item already in this case' });
}
res.status(500).json({ error: 'Failed to add case item' });
}
});
router.put('/case-items/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const dropRate = Number(req.body.dropRate);
if (!(dropRate > 0)) {
return res.status(400).json({ error: 'dropRate must be > 0' });
}
const updated = await prisma.caseItem.update({
where: { id },
data: { dropRate },
include: { item: true },
});
res.json({ caseItem: updated });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update drop rate' });
}
});
router.delete('/case-items/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
await prisma.caseItem.delete({ where: { id } });
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to remove case item' });
}
});
export default router;
+106
View File
@@ -0,0 +1,106 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import { prisma } from '../db.js';
import { publicUser, requireAuth } from '../middleware.js';
const router = Router();
const STARTING_BALANCE = Number(process.env.STARTING_BALANCE || 10000);
router.post('/register', async (req, res) => {
try {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
if (username.length < 3 || username.length > 32) {
return res.status(400).json({ error: 'Username must be 332 characters' });
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return res.status(400).json({ error: 'Username may only contain letters, numbers, and underscores' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Password must be at least 6 characters' });
}
const existing = await prisma.user.findUnique({ where: { username } });
if (existing) {
return res.status(409).json({ error: 'Username already taken' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
username,
passwordHash,
balance: STARTING_BALANCE,
role: 'user',
},
});
await prisma.transaction.create({
data: {
userId: user.id,
type: 'seed_credit',
amount: STARTING_BALANCE,
meta: JSON.stringify({ reason: 'starting_balance' }),
},
});
req.session.userId = user.id;
req.session.role = user.role;
res.status(201).json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Registration failed' });
}
});
router.post('/login', async (req, res) => {
try {
const username = String(req.body.username || '').trim();
const password = String(req.body.password || '');
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
return res.status(401).json({ error: 'Invalid username or password' });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Login failed' });
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});
router.get('/me', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
res.json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load user' });
}
});
export default router;
+166
View File
@@ -0,0 +1,166 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { normalizeRates, pickWeighted } from '../weighted.js';
const router = Router();
function formatCase(c, includeRates = false) {
const items = (c.items || []).map((ci) => ({
id: ci.item.id,
name: ci.item.name,
imageUrl: ci.item.imageUrl,
rarity: ci.item.rarity,
marketValue: ci.item.marketValue,
dropRate: ci.dropRate,
...(includeRates ? {} : {}),
}));
const withChance = normalizeRates(
items.map((i) => ({ ...i, dropRate: i.dropRate }))
);
return {
id: c.id,
name: c.name,
imageUrl: c.imageUrl,
price: c.price,
active: c.active,
items: withChance.map(({ dropRate, chance, ...rest }) => ({
...rest,
dropRate,
chance: Number(chance.toFixed(4)),
})),
};
}
router.get('/', async (_req, res) => {
try {
const cases = await prisma.case.findMany({
where: { active: true },
include: {
items: { include: { item: true } },
},
orderBy: { price: 'asc' },
});
res.json({ cases: cases.map((c) => formatCase(c)) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load cases' });
}
});
router.get('/:id', async (req, res) => {
try {
const id = Number(req.params.id);
const c = await prisma.case.findUnique({
where: { id },
include: {
items: { include: { item: true } },
},
});
if (!c || !c.active) {
return res.status(404).json({ error: 'Case not found' });
}
res.json({ case: formatCase(c, true) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load case' });
}
});
router.post('/:id/open', requireAuth, async (req, res) => {
try {
const caseId = Number(req.params.id);
const userId = req.session.userId;
const result = await prisma.$transaction(async (tx) => {
const c = await tx.case.findUnique({
where: { id: caseId },
include: {
items: { include: { item: true } },
},
});
if (!c || !c.active) {
const err = new Error('Case not found');
err.status = 404;
throw err;
}
if (!c.items.length) {
const err = new Error('Case has no items');
err.status = 400;
throw err;
}
const user = await tx.user.findUnique({ where: { id: userId } });
if (!user) {
const err = new Error('User not found');
err.status = 401;
throw err;
}
if (user.balance < c.price) {
const err = new Error('Insufficient balance');
err.status = 400;
throw err;
}
const winner = pickWeighted(
c.items.map((ci) => ({
caseItem: ci,
dropRate: ci.dropRate,
}))
);
const item = winner.caseItem.item;
const updatedUser = await tx.user.update({
where: { id: userId },
data: { balance: { decrement: c.price } },
});
const inventoryItem = await tx.inventoryItem.create({
data: {
userId,
itemId: item.id,
},
include: { item: true },
});
await tx.transaction.create({
data: {
userId,
type: 'open_case',
amount: -c.price,
meta: JSON.stringify({
caseId: c.id,
caseName: c.name,
itemId: item.id,
itemName: item.name,
inventoryItemId: inventoryItem.id,
}),
},
});
return {
user: publicUser(updatedUser),
item: {
inventoryId: inventoryItem.id,
id: item.id,
name: item.name,
imageUrl: item.imageUrl,
rarity: item.rarity,
marketValue: item.marketValue,
},
};
});
res.json(result);
} catch (err) {
console.error(err);
const status = err.status || 500;
res.status(status).json({ error: err.message || 'Failed to open case' });
}
});
export default router;
+34
View File
@@ -0,0 +1,34 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
const router = Router();
router.get('/', requireAuth, async (req, res) => {
try {
const items = await prisma.inventoryItem.findMany({
where: { userId: req.session.userId },
include: { item: true },
orderBy: { obtainedAt: 'desc' },
});
res.json({
inventory: items.map((inv) => ({
id: inv.id,
obtainedAt: inv.obtainedAt,
item: {
id: inv.item.id,
name: inv.item.name,
imageUrl: inv.item.imageUrl,
rarity: inv.item.rarity,
marketValue: inv.item.marketValue,
},
})),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load inventory' });
}
});
export default router;
+43
View File
@@ -0,0 +1,43 @@
import { Router } from 'express';
import { prisma } from '../db.js';
const router = Router();
router.get('/', async (_req, res) => {
try {
const users = await prisma.user.findMany({
where: { role: 'user' },
include: {
inventory: {
include: { item: true },
},
},
});
const ranked = users
.map((u) => {
const inventoryValue = u.inventory.reduce(
(sum, inv) => sum + inv.item.marketValue,
0
);
return {
id: u.id,
username: u.username,
balance: u.balance,
inventoryValue,
totalWealth: u.balance + inventoryValue,
itemCount: u.inventory.length,
};
})
.sort((a, b) => b.totalWealth - a.totalWealth)
.slice(0, 50)
.map((entry, index) => ({ rank: index + 1, ...entry }));
res.json({ leaderboard: ranked });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load leaderboard' });
}
});
export default router;
+24
View File
@@ -0,0 +1,24 @@
/** Pick one entry from a weighted list using dropRate as relative weight. */
export function pickWeighted(entries) {
const total = entries.reduce((sum, e) => sum + e.dropRate, 0);
if (total <= 0) {
throw new Error('No valid drop rates');
}
let roll = Math.random() * total;
for (const entry of entries) {
roll -= entry.dropRate;
if (roll <= 0) {
return entry;
}
}
return entries[entries.length - 1];
}
export function normalizeRates(entries) {
const total = entries.reduce((sum, e) => sum + e.dropRate, 0);
if (total <= 0) return entries.map((e) => ({ ...e, chance: 0 }));
return entries.map((e) => ({
...e,
chance: (e.dropRate / total) * 100,
}));
}