first commit
This commit is contained in:
Generated
+2939
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 3–32 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user