nice update

This commit is contained in:
gpatruno
2026-07-16 20:23:58 +02:00
parent a0d7163464
commit 9106c8b873
47 changed files with 5183 additions and 1732 deletions
+22
View File
@@ -0,0 +1,22 @@
import { getIO } from './realtime.js';
const MAX_FEED = 40;
const feed = [];
export function getDropFeed() {
return [...feed];
}
export function pushDrop(entry) {
const event = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
at: new Date().toISOString(),
...entry,
};
feed.unshift(event);
if (feed.length > MAX_FEED) feed.length = MAX_FEED;
const io = getIO();
if (io) io.to('feed').emit('feed:drop', event);
return event;
}
+143 -22
View File
@@ -1,38 +1,75 @@
import http from 'http';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import { Server } from 'socket.io';
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';
import jackpotRoutes from './routes/jackpot.js';
import duelsRoutes from './routes/duels.js';
import profileRoutes from './routes/profile.js';
import shopRoutes from './routes/shop.js';
import feedRoutes from './routes/feed.js';
import { setIO } from './realtime.js';
import { getDropFeed } from './feed.js';
import { ensureJackpotRound } from './services/jackpot.js';
import { recoverDuelSpins, getDuelRoom, listDuelRooms } from './services/duel.js';
import { getJackpotState } from './services/jackpot.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const app = express();
const server = http.createServer(app);
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());
function isAllowedOrigin(origin) {
if (!origin) return true;
if (origin === CLIENT_ORIGIN) return true;
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin)) return true;
// Private LAN ranges (dev access from phones / other PCs)
if (/^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) return true;
if (/^http:\/\/172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(origin)) {
return true;
}
return false;
}
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,
},
})
);
const corsOptions = {
origin(origin, callback) {
callback(null, isAllowedOrigin(origin));
},
credentials: true,
};
const sessionMiddleware = 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.use(cors(corsOptions));
app.use(express.json());
app.use(sessionMiddleware);
app.use('/uploads', express.static(uploadsDir));
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
@@ -43,12 +80,96 @@ app.use('/api/cases', casesRoutes);
app.use('/api/inventory', inventoryRoutes);
app.use('/api/leaderboard', leaderboardRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/jackpot', jackpotRoutes);
app.use('/api/duels', duelsRoutes);
app.use('/api/profile', profileRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/feed', feedRoutes);
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}`);
const io = new Server(server, {
cors: corsOptions,
});
setIO(io);
io.use((socket, next) => {
sessionMiddleware(socket.request, {}, next);
});
io.on('connection', (socket) => {
const userId = socket.request.session?.userId || null;
socket.data.userId = userId;
if (userId) {
socket.join(`user:${userId}`);
}
socket.on('jackpot:subscribe', async () => {
socket.join('jackpot');
try {
const state = await getJackpotState();
socket.emit('jackpot:state', state);
} catch (err) {
console.error(err);
}
});
socket.on('jackpot:unsubscribe', () => {
socket.leave('jackpot');
});
socket.on('duels:subscribe', async () => {
socket.join('duels');
try {
const list = await listDuelRooms();
socket.emit('duels:list', list);
} catch (err) {
console.error(err);
}
});
socket.on('duels:unsubscribe', () => {
socket.leave('duels');
});
socket.on('duel:subscribe', async (roomId) => {
const id = Number(roomId);
if (!id) return;
socket.join(`duel:${id}`);
try {
const room = await getDuelRoom(id);
if (room) socket.emit('duel:state', room);
} catch (err) {
console.error(err);
}
});
socket.on('duel:unsubscribe', (roomId) => {
const id = Number(roomId);
if (!id) return;
socket.leave(`duel:${id}`);
});
socket.on('feed:subscribe', () => {
socket.join('feed');
socket.emit('feed:history', getDropFeed());
});
socket.on('feed:unsubscribe', () => {
socket.leave('feed');
});
});
server.listen(PORT, async () => {
console.log(`API listening on http://localhost:${PORT}`);
try {
await ensureJackpotRound();
await recoverDuelSpins();
} catch (err) {
console.error('Failed to bootstrap game services', err);
}
});
+4
View File
@@ -20,6 +20,10 @@ export function publicUser(user) {
id: user.id,
username: user.username,
balance: user.balance,
osuBalance: user.osuBalance ?? 0,
lastAdAt: user.lastAdAt || null,
role: user.role,
avatarUrl: user.avatarUrl || '',
bio: user.bio || '',
};
}
+24
View File
@@ -0,0 +1,24 @@
let io = null;
export function setIO(instance) {
io = instance;
}
export function getIO() {
return io;
}
export function emitJackpot(event, payload) {
if (!io) return;
io.to('jackpot').emit(event, payload);
}
export function emitDuel(roomId, event, payload) {
if (!io) return;
io.to(`duel:${roomId}`).emit(event, payload);
}
export function emitDuelsList(payload) {
if (!io) return;
io.to('duels').emit('duels:list', payload);
}
+14
View File
@@ -3,8 +3,10 @@ import bcrypt from 'bcrypt';
import { prisma } from '../db.js';
import { requireAdmin, publicUser } from '../middleware.js';
import { isValidRarity, RARITIES } from '../rarities.js';
import { createUploader } from '../upload.js';
const router = Router();
const upload = createUploader('asset');
router.post('/login', async (req, res) => {
try {
@@ -35,6 +37,18 @@ router.get('/rarities', requireAdmin, (_req, res) => {
res.json({ rarities: RARITIES });
});
router.post('/upload', requireAdmin, (req, res) => {
upload.single('image')(req, res, (err) => {
if (err) {
return res.status(400).json({ error: err.message || 'Upload failed' });
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
res.status(201).json({ url: `/uploads/${req.file.filename}` });
});
});
// —— Cases ——
router.get('/cases', requireAdmin, async (_req, res) => {
try {
+11
View File
@@ -5,6 +5,7 @@ import { publicUser, requireAuth } from '../middleware.js';
const router = Router();
const STARTING_BALANCE = Number(process.env.STARTING_BALANCE || 10000);
const STARTING_OSU = Number(process.env.STARTING_OSU || 100);
router.post('/register', async (req, res) => {
try {
@@ -32,6 +33,7 @@ router.post('/register', async (req, res) => {
username,
passwordHash,
balance: STARTING_BALANCE,
osuBalance: STARTING_OSU,
role: 'user',
},
});
@@ -45,6 +47,15 @@ router.post('/register', async (req, res) => {
},
});
await prisma.transaction.create({
data: {
userId: user.id,
type: 'seed_osu',
amount: STARTING_OSU,
meta: JSON.stringify({ reason: 'starting_osu' }),
},
});
req.session.userId = user.id;
req.session.role = user.role;
+11 -1
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
import { normalizeRates, pickWeighted } from '../weighted.js';
import { pushDrop } from '../feed.js';
const router = Router();
@@ -144,6 +145,7 @@ router.post('/:id/open', requireAuth, async (req, res) => {
return {
user: publicUser(updatedUser),
caseName: c.name,
item: {
inventoryId: inventoryItem.id,
id: item.id,
@@ -155,7 +157,15 @@ router.post('/:id/open', requireAuth, async (req, res) => {
};
});
res.json(result);
pushDrop({
source: 'case',
caseName: result.caseName,
username: result.user.username,
avatarUrl: result.user.avatarUrl || '',
item: result.item,
});
res.json({ user: result.user, item: result.item });
} catch (err) {
console.error(err);
const status = err.status || 500;
+89
View File
@@ -0,0 +1,89 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import {
listDuelRooms,
getDuelRoom,
createDuelRoom,
placeDuelBet,
leaveDuelRoom,
cancelDuelRoom,
startDuelRoom,
} from '../services/duel.js';
const router = Router();
function handleError(res, err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Request failed' });
}
router.get('/', async (_req, res) => {
try {
const list = await listDuelRooms();
res.json(list);
} catch (err) {
handleError(res, err);
}
});
router.post('/', requireAuth, async (req, res) => {
try {
const room = await createDuelRoom(req.session.userId, req.body);
res.status(201).json({ room });
} catch (err) {
handleError(res, err);
}
});
router.get('/:id', async (req, res) => {
try {
const room = await getDuelRoom(req.params.id);
if (!room) return res.status(404).json({ error: 'Room not found' });
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/bet', requireAuth, async (req, res) => {
try {
const room = await placeDuelBet(
req.session.userId,
req.params.id,
req.body.inventoryItemIds
);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/leave', requireAuth, async (req, res) => {
try {
const room = await leaveDuelRoom(req.session.userId, req.params.id);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/cancel', requireAuth, async (req, res) => {
try {
const room = await cancelDuelRoom(req.session.userId, req.params.id);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
router.post('/:id/start', requireAuth, async (req, res) => {
try {
const room = await startDuelRoom(req.session.userId, req.params.id);
res.json({ room });
} catch (err) {
handleError(res, err);
}
});
export default router;
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { getDropFeed } from '../feed.js';
const router = Router();
router.get('/', (_req, res) => {
res.json({ drops: getDropFeed() });
});
export default router;
+65 -2
View File
@@ -1,13 +1,13 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth } from '../middleware.js';
import { requireAuth, publicUser } from '../middleware.js';
const router = Router();
router.get('/', requireAuth, async (req, res) => {
try {
const items = await prisma.inventoryItem.findMany({
where: { userId: req.session.userId },
where: { userId: req.session.userId, locked: false },
include: { item: true },
orderBy: { obtainedAt: 'desc' },
});
@@ -16,6 +16,7 @@ router.get('/', requireAuth, async (req, res) => {
inventory: items.map((inv) => ({
id: inv.id,
obtainedAt: inv.obtainedAt,
locked: inv.locked,
item: {
id: inv.item.id,
name: inv.item.name,
@@ -31,4 +32,66 @@ router.get('/', requireAuth, async (req, res) => {
}
});
router.post('/sell', requireAuth, async (req, res) => {
try {
const ids = [...new Set((req.body.inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
return res.status(400).json({ error: 'Select at least one item to sell' });
}
const result = await prisma.$transaction(async (tx) => {
const invItems = await tx.inventoryItem.findMany({
where: {
id: { in: ids },
userId: req.session.userId,
locked: false,
},
include: { item: true },
});
if (invItems.length !== ids.length) {
const err = new Error('Invalid or locked inventory items');
err.status = 400;
throw err;
}
const total = invItems.reduce((sum, inv) => sum + inv.item.marketValue, 0);
for (const inv of invItems) {
await tx.inventoryItem.delete({ where: { id: inv.id } });
await tx.transaction.create({
data: {
userId: req.session.userId,
type: 'sell_item',
amount: inv.item.marketValue,
meta: JSON.stringify({
inventoryItemId: inv.id,
itemId: inv.itemId,
itemName: inv.item.name,
valueCents: inv.item.marketValue,
}),
},
});
}
const user = await tx.user.update({
where: { id: req.session.userId },
data: { balance: { increment: total } },
});
return { user, total, soldCount: invItems.length };
});
res.json({
user: publicUser(result.user),
total: result.total,
soldCount: result.soldCount,
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Failed to sell items' });
}
});
export default router;
+44
View File
@@ -0,0 +1,44 @@
import { Router } from 'express';
import { requireAuth } from '../middleware.js';
import {
getJackpotState,
placeJackpotBet,
withdrawJackpotBet,
} from '../services/jackpot.js';
const router = Router();
function handleError(res, err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Request failed' });
}
router.get('/', async (_req, res) => {
try {
const state = await getJackpotState();
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
router.post('/bet', requireAuth, async (req, res) => {
try {
const state = await placeJackpotBet(req.session.userId, req.body.inventoryItemIds);
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
router.post('/withdraw', requireAuth, async (req, res) => {
try {
const state = await withdrawJackpotBet(req.session.userId, req.body.inventoryItemIds);
res.json({ round: state });
} catch (err) {
handleError(res, err);
}
});
export default router;
+148
View File
@@ -0,0 +1,148 @@
import { Router } from 'express';
import bcrypt from 'bcrypt';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
const router = Router();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '../../uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png';
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
cb(null, `avatar-${req.session.userId}-${Date.now()}${safe}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new Error('Only images are allowed'));
}
cb(null, true);
},
});
router.get('/', requireAuth, async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.session.userId },
include: {
inventory: { include: { item: true } },
},
});
if (!user) return res.status(404).json({ error: 'User not found' });
const inventoryValue = user.inventory.reduce(
(sum, inv) => sum + (inv.locked ? 0 : inv.item.marketValue),
0
);
const [jackpotWins, duelWins, casesOpened] = await Promise.all([
prisma.transaction.count({
where: { userId: user.id, type: 'jackpot_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'duel_win' },
}),
prisma.transaction.count({
where: { userId: user.id, type: 'open_case' },
}),
]);
res.json({
user: publicUser(user),
profile: {
createdAt: user.createdAt,
inventoryValue,
inventoryCount: user.inventory.filter((i) => !i.locked).length,
jackpotWins,
duelWins,
casesOpened,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load profile' });
}
});
router.patch('/', requireAuth, async (req, res) => {
try {
const bio = String(req.body.bio ?? '').slice(0, 280);
const user = await prisma.user.update({
where: { id: req.session.userId },
data: { bio },
});
res.json({ user: publicUser(user) });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update profile' });
}
});
router.post('/password', requireAuth, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body || {};
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Current and new password required' });
}
if (String(newPassword).length < 6) {
return res.status(400).json({ error: 'New password must be at least 6 characters' });
}
const user = await prisma.user.findUnique({ where: { id: req.session.userId } });
if (!user) return res.status(404).json({ error: 'User not found' });
const ok = await bcrypt.compare(String(currentPassword), user.passwordHash);
if (!ok) return res.status(400).json({ error: 'Current password is incorrect' });
const passwordHash = await bcrypt.hash(String(newPassword), 10);
await prisma.user.update({
where: { id: user.id },
data: { passwordHash },
});
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to change password' });
}
});
router.post('/avatar', requireAuth, (req, res) => {
upload.single('avatar')(req, res, async (err) => {
if (err) {
return res.status(400).json({ error: err.message || 'Upload failed' });
}
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
try {
const avatarUrl = `/uploads/${req.file.filename}`;
const user = await prisma.user.update({
where: { id: req.session.userId },
data: { avatarUrl },
});
res.json({ user: publicUser(user) });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Failed to save avatar' });
}
});
});
export default router;
+164
View File
@@ -0,0 +1,164 @@
import { Router } from 'express';
import { prisma } from '../db.js';
import { requireAuth, publicUser } from '../middleware.js';
const router = Router();
/** Packages: spend osu → receive credits (cents). */
export const SHOP_PACKAGES = [
{ id: 'starter', name: 'Starter Pack', osuCost: 10, credits: 5000, blurb: '50.00 cr' },
{ id: 'boost', name: 'Boost Pack', osuCost: 25, credits: 15000, blurb: '150.00 cr' },
{ id: 'pro', name: 'Pro Pack', osuCost: 50, credits: 35000, blurb: '350.00 cr' },
{ 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
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;
res.json({
packages: SHOP_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,
},
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to load shop' });
}
});
router.post('/buy', requireAuth, async (req, res) => {
try {
const packageId = String(req.body.packageId || '');
const pack = SHOP_PACKAGES.find((p) => p.id === packageId);
if (!pack) {
return res.status(400).json({ error: 'Unknown package' });
}
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 = 404;
throw err;
}
if (user.osuBalance < pack.osuCost) {
const err = new Error('Not enough osu');
err.status = 400;
throw err;
}
const updated = await tx.user.update({
where: { id: user.id },
data: {
osuBalance: { decrement: pack.osuCost },
balance: { increment: pack.credits },
},
});
await tx.transaction.create({
data: {
userId: user.id,
type: 'shop_purchase',
amount: pack.credits,
meta: JSON.stringify({
packageId: pack.id,
osuCost: pack.osuCost,
credits: pack.credits,
}),
},
});
return updated;
});
res.json({ user: publicUser(result), package: pack });
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({ error: err.message || 'Purchase failed' });
}
});
router.post('/ad', requireAuth, async (req, res) => {
try {
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 = 404;
throw err;
}
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);
const err = new Error(`Ad available in ${waitSec}s`);
err.status = 429;
err.nextAdAt = new Date(lastAdAt + AD_COOLDOWN_MS).toISOString();
throw err;
}
const reward =
AD_REWARD_MIN + Math.floor(Math.random() * (AD_REWARD_MAX - AD_REWARD_MIN + 1));
const now = new Date();
const updated = await tx.user.update({
where: { id: user.id },
data: {
balance: { increment: reward },
lastAdAt: now,
},
});
await tx.transaction.create({
data: {
userId: user.id,
type: 'ad_reward',
amount: reward,
meta: JSON.stringify({ reward }),
},
});
return { user: updated, reward };
});
res.json({
user: publicUser(result.user),
reward: result.reward,
ad: {
ready: false,
nextAdAt: new Date(Date.now() + AD_COOLDOWN_MS).toISOString(),
cooldownMs: AD_COOLDOWN_MS,
rewardMin: AD_REWARD_MIN,
rewardMax: AD_REWARD_MAX,
},
});
} catch (err) {
const status = err.status || 500;
if (status >= 500) console.error(err);
res.status(status).json({
error: err.message || 'Ad reward failed',
nextAdAt: err.nextAdAt || null,
});
}
});
export default router;
+455
View File
@@ -0,0 +1,455 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { emitDuel, emitDuelsList } from '../realtime.js';
const SPIN_MS = 5500;
const pendingSpins = new Map();
function depositInclude() {
return {
user: { select: { id: true, username: true, avatarUrl: true } },
inventoryItem: { include: { item: true } },
};
}
function roomInclude() {
return {
creator: { select: { id: true, username: true, avatarUrl: true } },
winner: { select: { id: true, username: true, avatarUrl: true } },
deposits: { include: depositInclude() },
};
}
export function serializeDuelRoom(room) {
if (!room) return null;
const deposits = (room.deposits || []).map((d) => ({
id: d.id,
userId: d.userId,
username: d.user?.username || '',
avatarUrl: d.user?.avatarUrl || '',
valueCents: d.valueCents,
inventoryItemId: d.inventoryItemId,
item: d.inventoryItem
? {
id: d.inventoryItem.item.id,
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.inventoryItem.item.marketValue,
}
: null,
}));
const byUser = new Map();
let totalValue = 0;
for (const d of deposits) {
totalValue += d.valueCents;
const prev = byUser.get(d.userId) || {
userId: d.userId,
username: d.username,
avatarUrl: d.avatarUrl,
valueCents: 0,
itemCount: 0,
};
prev.valueCents += d.valueCents;
prev.itemCount += 1;
byUser.set(d.userId, prev);
}
const players = [...byUser.values()].map((p) => ({
...p,
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
}));
return {
id: room.id,
status: room.status,
creatorId: room.creatorId,
creatorUsername: room.creator?.username || '',
maxPlayers: room.maxPlayers,
maxItemsPerPlayer: room.maxItemsPerPlayer,
minItemValue: room.minItemValue,
minPlayersToStart: room.minPlayersToStart,
winnerUserId: room.winnerUserId || null,
winnerUsername: room.winner?.username || null,
createdAt: room.createdAt,
startedAt: room.startedAt || null,
resolvedAt: room.resolvedAt || null,
totalValue,
playerCount: players.length,
players,
deposits,
};
}
async function loadRoom(id) {
return prisma.duelRoom.findUnique({
where: { id: Number(id) },
include: roomInclude(),
});
}
export async function getDuelRoom(id) {
return serializeDuelRoom(await loadRoom(id));
}
export async function listDuelRooms() {
const rooms = await prisma.duelRoom.findMany({
where: { status: { in: ['open', 'in_progress', 'spinning'] } },
include: roomInclude(),
orderBy: { createdAt: 'desc' },
});
const serialized = rooms.map(serializeDuelRoom);
return {
open: serialized.filter((r) => r.status === 'open'),
inProgress: serialized.filter((r) => r.status === 'in_progress' || r.status === 'spinning'),
};
}
export async function broadcastDuelsList() {
const list = await listDuelRooms();
emitDuelsList(list);
return list;
}
function broadcastRoom(room, extra = {}) {
const state = serializeDuelRoom(room);
emitDuel(room.id, 'duel:state', { ...state, ...extra });
return state;
}
export async function createDuelRoom(userId, opts = {}) {
const maxPlayers = Math.min(10, Math.max(2, Number(opts.maxPlayers) || 2));
const maxItemsPerPlayer = Math.min(10, Math.max(1, Number(opts.maxItemsPerPlayer) || 3));
const minItemValue = Math.max(0, Number(opts.minItemValue) || 0);
const minPlayersToStart = Math.min(
maxPlayers,
Math.max(2, Number(opts.minPlayersToStart) || 2)
);
const room = await prisma.duelRoom.create({
data: {
creatorId: userId,
status: 'open',
maxPlayers,
maxItemsPerPlayer,
minItemValue,
minPlayersToStart,
},
include: roomInclude(),
});
await broadcastDuelsList();
return serializeDuelRoom(room);
}
export async function placeDuelBet(userId, roomId, inventoryItemIds) {
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select at least one item'), { status: 400 });
}
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current || current.status !== 'open') {
throw Object.assign(new Error('Room is not open for bets'), { status: 409 });
}
if (ids.length > current.maxItemsPerPlayer) {
throw Object.assign(
new Error(`Max ${current.maxItemsPerPlayer} items per player`),
{ status: 400 }
);
}
const existingCount = current.deposits.filter((d) => d.userId === userId).length;
if (existingCount + ids.length > current.maxItemsPerPlayer) {
throw Object.assign(
new Error(`You can bet at most ${current.maxItemsPerPlayer} items`),
{ status: 400 }
);
}
const uniquePlayers = new Set(current.deposits.map((d) => d.userId));
const isNewPlayer = !uniquePlayers.has(userId);
if (isNewPlayer && uniquePlayers.size >= current.maxPlayers) {
throw Object.assign(new Error('Room is full'), { status: 409 });
}
const invItems = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId, locked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid or locked inventory items'), { status: 400 });
}
for (const inv of invItems) {
if (inv.item.marketValue < current.minItemValue) {
throw Object.assign(
new Error(`Items must be worth at least ${current.minItemValue} cents`),
{ status: 400 }
);
}
await tx.inventoryItem.update({
where: { id: inv.id },
data: { locked: true },
});
await tx.duelDeposit.create({
data: {
roomId: current.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.item.marketValue,
},
});
await tx.transaction.create({
data: {
userId,
type: 'duel_bet',
amount: 0,
meta: JSON.stringify({
roomId: current.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.item.marketValue,
}),
},
});
}
const refreshed = await tx.duelRoom.findUnique({
where: { id: current.id },
include: roomInclude(),
});
const players = new Set(refreshed.deposits.map((d) => d.userId));
const allHaveItems = [...players].every(
(pid) => refreshed.deposits.some((d) => d.userId === pid)
);
if (players.size >= refreshed.maxPlayers && allHaveItems) {
return startDuelInTx(tx, refreshed.id);
}
return refreshed;
});
const state = broadcastRoom(room);
await broadcastDuelsList();
if (room.status === 'spinning') {
scheduleDuelFinish(room.id);
const reelItemId =
room.deposits.find((d) => d.userId === room.winnerUserId)?.id || null;
emitDuel(room.id, 'duel:spin', { ...state, reelWinnerItemId: reelItemId });
}
return state;
}
export async function leaveDuelRoom(userId, roomId) {
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current || current.status !== 'open') {
throw Object.assign(new Error('Cannot leave this room'), { status: 409 });
}
const deposits = current.deposits.filter((d) => d.userId === userId);
for (const d of deposits) {
await tx.duelDeposit.delete({ where: { id: d.id } });
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { locked: false },
});
}
return tx.duelRoom.findUnique({
where: { id: current.id },
include: roomInclude(),
});
});
const state = broadcastRoom(room);
await broadcastDuelsList();
return state;
}
export async function cancelDuelRoom(userId, roomId) {
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current) {
throw Object.assign(new Error('Room not found'), { status: 404 });
}
if (current.creatorId !== userId) {
throw Object.assign(new Error('Only the creator can cancel'), { status: 403 });
}
if (current.status !== 'open') {
throw Object.assign(new Error('Room cannot be cancelled'), { status: 409 });
}
for (const d of current.deposits) {
await tx.duelDeposit.delete({ where: { id: d.id } });
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { locked: false },
});
}
return tx.duelRoom.update({
where: { id: current.id },
data: { status: 'cancelled', resolvedAt: new Date() },
include: roomInclude(),
});
});
const state = broadcastRoom(room);
await broadcastDuelsList();
return state;
}
export async function startDuelRoom(userId, roomId) {
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: roomInclude(),
});
if (!current) {
throw Object.assign(new Error('Room not found'), { status: 404 });
}
if (current.creatorId !== userId) {
throw Object.assign(new Error('Only the creator can start'), { status: 403 });
}
if (current.status !== 'open') {
throw Object.assign(new Error('Room is not open'), { status: 409 });
}
const players = new Set(current.deposits.map((d) => d.userId));
if (players.size < current.minPlayersToStart) {
throw Object.assign(
new Error(`Need at least ${current.minPlayersToStart} players`),
{ status: 400 }
);
}
return startDuelInTx(tx, current.id);
});
const state = broadcastRoom(room);
await broadcastDuelsList();
if (room.status === 'spinning') {
scheduleDuelFinish(room.id);
const reelItemId =
room.deposits.find((d) => d.userId === room.winnerUserId)?.id || null;
emitDuel(room.id, 'duel:spin', { ...state, reelWinnerItemId: reelItemId });
}
return state;
}
async function startDuelInTx(tx, roomId) {
const room = await tx.duelRoom.findUnique({
where: { id: roomId },
include: roomInclude(),
});
if (!room || room.status !== 'open') return room;
const byUser = new Map();
for (const d of room.deposits) {
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
}
if (byUser.size < 2) {
throw Object.assign(new Error('Need at least 2 players with items'), { status: 400 });
}
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
userId,
dropRate,
}));
const winner = pickWeighted(entries);
return tx.duelRoom.update({
where: { id: roomId },
data: {
status: 'spinning',
winnerUserId: winner.userId,
startedAt: new Date(),
},
include: roomInclude(),
});
}
function scheduleDuelFinish(roomId) {
if (pendingSpins.has(roomId)) {
clearTimeout(pendingSpins.get(roomId));
}
const t = setTimeout(() => {
finishDuelSpin(roomId).catch((err) => console.error('duel finish', err));
}, SPIN_MS + 200);
pendingSpins.set(roomId, t);
}
async function finishDuelSpin(roomId) {
pendingSpins.delete(roomId);
const room = await prisma.$transaction(async (tx) => {
const current = await tx.duelRoom.findUnique({
where: { id: Number(roomId) },
include: { deposits: true },
});
if (!current || current.status !== 'spinning') return null;
const winnerId = current.winnerUserId;
for (const d of current.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { userId: winnerId, locked: false },
});
}
const totalValue = current.deposits.reduce((s, d) => s + d.valueCents, 0);
await tx.transaction.create({
data: {
userId: winnerId,
type: 'duel_win',
amount: totalValue,
meta: JSON.stringify({
roomId: current.id,
depositIds: current.deposits.map((d) => d.id),
}),
},
});
return tx.duelRoom.update({
where: { id: current.id },
data: { status: 'resolved', resolvedAt: new Date() },
include: roomInclude(),
});
});
if (!room) return;
const state = serializeDuelRoom(room);
emitDuel(room.id, 'duel:result', state);
emitDuel(room.id, 'duel:state', state);
await broadcastDuelsList();
}
export async function recoverDuelSpins() {
const spinning = await prisma.duelRoom.findMany({
where: { status: 'spinning' },
});
for (const room of spinning) {
scheduleDuelFinish(room.id);
}
}
+430
View File
@@ -0,0 +1,430 @@
import { prisma } from '../db.js';
import { pickWeighted } from '../weighted.js';
import { emitJackpot } from '../realtime.js';
const ROUND_SECONDS = Number(process.env.JACKPOT_ROUND_SECONDS || 60);
const MAX_ITEMS = Number(process.env.JACKPOT_MAX_ITEMS || 3);
const SPIN_MS = 5500;
let resolveTimer = null;
let spinFollowUp = null;
function depositInclude() {
return {
user: { select: { id: true, username: true, avatarUrl: true } },
inventoryItem: { include: { item: true } },
};
}
export function serializeJackpotRound(round) {
if (!round) return null;
const deposits = (round.deposits || []).map((d) => ({
id: d.id,
userId: d.userId,
username: d.user?.username || '',
avatarUrl: d.user?.avatarUrl || '',
valueCents: d.valueCents,
inventoryItemId: d.inventoryItemId,
item: d.inventoryItem
? {
id: d.inventoryItem.item.id,
name: d.inventoryItem.item.name,
imageUrl: d.inventoryItem.item.imageUrl,
rarity: d.inventoryItem.item.rarity,
marketValue: d.inventoryItem.item.marketValue,
}
: null,
}));
const byUser = new Map();
let totalValue = 0;
for (const d of deposits) {
totalValue += d.valueCents;
const prev = byUser.get(d.userId) || {
userId: d.userId,
username: d.username,
avatarUrl: d.avatarUrl,
valueCents: 0,
itemCount: 0,
};
prev.valueCents += d.valueCents;
prev.itemCount += 1;
byUser.set(d.userId, prev);
}
const players = [...byUser.values()].map((p) => ({
...p,
chance: totalValue > 0 ? (p.valueCents / totalValue) * 100 : 0,
}));
return {
id: round.id,
status: round.status,
endsAt: round.endsAt,
createdAt: round.createdAt,
resolvedAt: round.resolvedAt || null,
winnerUserId: round.winnerUserId || null,
winnerUsername: round.winner?.username || null,
totalValue,
maxItemsPerPlayer: MAX_ITEMS,
players,
deposits,
};
}
async function loadOpenOrLatest() {
const open = await prisma.jackpotRound.findFirst({
where: { status: { in: ['open', 'spinning'] } },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
orderBy: { id: 'desc' },
});
if (open) return open;
return prisma.jackpotRound.findFirst({
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
orderBy: { id: 'desc' },
});
}
export async function getJackpotState() {
const round = await loadOpenOrLatest();
return serializeJackpotRound(round);
}
export async function broadcastJackpot(extra = {}) {
const state = await getJackpotState();
emitJackpot('jackpot:state', { ...state, ...extra });
return state;
}
async function createOpenRound() {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
return prisma.jackpotRound.create({
data: { status: 'open', endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
function clearTimers() {
if (resolveTimer) {
clearTimeout(resolveTimer);
resolveTimer = null;
}
if (spinFollowUp) {
clearTimeout(spinFollowUp);
spinFollowUp = null;
}
}
function scheduleResolve(endsAt) {
clearTimers();
const delay = Math.max(0, new Date(endsAt).getTime() - Date.now());
resolveTimer = setTimeout(() => {
resolveJackpotRound().catch((err) => console.error('jackpot resolve', err));
}, delay);
}
export async function ensureJackpotRound() {
let round = await prisma.jackpotRound.findFirst({
where: { status: 'open' },
orderBy: { id: 'desc' },
});
if (!round) {
const spinning = await prisma.jackpotRound.findFirst({
where: { status: 'spinning' },
orderBy: { id: 'desc' },
});
if (spinning) {
// Server restarted mid-spin — finish after a short delay
spinFollowUp = setTimeout(() => {
finishJackpotSpin(spinning.id).catch((err) => console.error(err));
}, 1000);
return getJackpotState();
}
round = await createOpenRound();
} else if (new Date(round.endsAt).getTime() <= Date.now()) {
await resolveJackpotRound();
return getJackpotState();
}
scheduleResolve(round.endsAt);
return getJackpotState();
}
export async function placeJackpotBet(userId, inventoryItemIds) {
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select at least one item'), { status: 400 });
}
if (ids.length > MAX_ITEMS) {
throw Object.assign(new Error(`Max ${MAX_ITEMS} items per bet`), { status: 400 });
}
const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
orderBy: { id: 'desc' },
});
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
}
if (new Date(open.endsAt).getTime() <= Date.now()) {
throw Object.assign(new Error('Round is closing'), { status: 409 });
}
const existingCount = await tx.jackpotDeposit.count({
where: { roundId: open.id, userId },
});
if (existingCount + ids.length > MAX_ITEMS) {
throw Object.assign(
new Error(`You can bet at most ${MAX_ITEMS} items this round`),
{ status: 400 }
);
}
const invItems = await tx.inventoryItem.findMany({
where: { id: { in: ids }, userId, locked: false },
include: { item: true },
});
if (invItems.length !== ids.length) {
throw Object.assign(new Error('Invalid or locked inventory items'), { status: 400 });
}
for (const inv of invItems) {
await tx.inventoryItem.update({
where: { id: inv.id },
data: { locked: true },
});
await tx.jackpotDeposit.create({
data: {
roundId: open.id,
userId,
inventoryItemId: inv.id,
valueCents: inv.item.marketValue,
},
});
await tx.transaction.create({
data: {
userId,
type: 'jackpot_bet',
amount: 0,
meta: JSON.stringify({
roundId: open.id,
inventoryItemId: inv.id,
itemId: inv.itemId,
valueCents: inv.item.marketValue,
}),
},
});
}
return tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
});
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
return state;
}
export async function withdrawJackpotBet(userId, inventoryItemIds) {
const ids = [...new Set((inventoryItemIds || []).map(Number).filter(Boolean))];
if (!ids.length) {
throw Object.assign(new Error('Select items to withdraw'), { status: 400 });
}
const round = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
orderBy: { id: 'desc' },
});
if (!open) {
throw Object.assign(new Error('No open jackpot round'), { status: 409 });
}
if (new Date(open.endsAt).getTime() - Date.now() < 3000) {
throw Object.assign(new Error('Too late to withdraw'), { status: 409 });
}
const deposits = await tx.jackpotDeposit.findMany({
where: {
roundId: open.id,
userId,
inventoryItemId: { in: ids },
},
});
if (deposits.length !== ids.length) {
throw Object.assign(new Error('Deposit not found'), { status: 400 });
}
for (const d of deposits) {
await tx.jackpotDeposit.delete({ where: { id: d.id } });
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { locked: false },
});
}
return tx.jackpotRound.findUnique({
where: { id: open.id },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
});
const state = serializeJackpotRound(round);
emitJackpot('jackpot:state', state);
return state;
}
async function finishJackpotSpin(roundId) {
const result = await prisma.$transaction(async (tx) => {
const round = await tx.jackpotRound.findUnique({
where: { id: roundId },
include: {
deposits: { include: depositInclude() },
},
});
if (!round || round.status !== 'spinning') return null;
const winnerId = round.winnerUserId;
for (const d of round.deposits) {
await tx.inventoryItem.update({
where: { id: d.inventoryItemId },
data: { userId: winnerId, locked: false },
});
}
const totalValue = round.deposits.reduce((s, d) => s + d.valueCents, 0);
await tx.transaction.create({
data: {
userId: winnerId,
type: 'jackpot_win',
amount: totalValue,
meta: JSON.stringify({
roundId: round.id,
depositIds: round.deposits.map((d) => d.id),
}),
},
});
await tx.jackpotRound.update({
where: { id: round.id },
data: { status: 'resolved', resolvedAt: new Date() },
});
return createOpenRoundInTx(tx);
});
if (!result) return;
const state = serializeJackpotRound(result);
scheduleResolve(result.endsAt);
emitJackpot('jackpot:result', {
previousRoundId: roundId,
...state,
});
emitJackpot('jackpot:state', state);
}
async function createOpenRoundInTx(tx) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
return tx.jackpotRound.create({
data: { status: 'open', endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
}
export async function resolveJackpotRound() {
clearTimers();
const spun = await prisma.$transaction(async (tx) => {
const open = await tx.jackpotRound.findFirst({
where: { status: 'open' },
include: {
deposits: { include: depositInclude() },
},
orderBy: { id: 'desc' },
});
if (!open) return null;
if (!open.deposits.length) {
const endsAt = new Date(Date.now() + ROUND_SECONDS * 1000);
const extended = await tx.jackpotRound.update({
where: { id: open.id },
data: { endsAt },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
return { kind: 'extend', updated: extended };
}
const byUser = new Map();
for (const d of open.deposits) {
byUser.set(d.userId, (byUser.get(d.userId) || 0) + d.valueCents);
}
const entries = [...byUser.entries()].map(([userId, dropRate]) => ({
userId,
dropRate,
}));
const winner = pickWeighted(entries);
const winnerDeposits = open.deposits.filter((d) => d.userId === winner.userId);
const reelDepositId = winnerDeposits[0]?.id || null;
const updated = await tx.jackpotRound.update({
where: { id: open.id },
data: { status: 'spinning', winnerUserId: winner.userId },
include: {
deposits: { include: depositInclude() },
winner: { select: { id: true, username: true } },
},
});
return { kind: 'spin', updated, reelItemId: reelDepositId };
});
if (!spun) return;
if (spun.kind === 'extend') {
const state = serializeJackpotRound(spun.updated);
scheduleResolve(state.endsAt);
emitJackpot('jackpot:state', state);
return state;
}
const { updated, reelItemId } = spun;
const state = serializeJackpotRound(updated);
emitJackpot('jackpot:spin', {
...state,
reelWinnerItemId: reelItemId,
});
spinFollowUp = setTimeout(() => {
finishJackpotSpin(updated.id).catch((err) => console.error('jackpot finish', err));
}, SPIN_MS + 200);
return state;
}
+33
View File
@@ -0,0 +1,33 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const uploadsDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
export function createUploader(prefix) {
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.png';
const safe = ['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(ext) ? ext : '.png';
cb(null, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${safe}`);
},
});
return multer({
storage,
limits: { fileSize: 3 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!file.mimetype.startsWith('image/')) {
return cb(new Error('Only images are allowed'));
}
cb(null, true);
},
});
}