nice update
This commit is contained in:
Generated
+295
-1418
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -18,7 +18,9 @@
|
||||
"bcrypt": "^6.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-session": "^1.18.1"
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^2.2.0",
|
||||
"socket.io": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.11.1"
|
||||
|
||||
+79
-11
@@ -8,14 +8,23 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
balance Int @default(0)
|
||||
role String @default("user")
|
||||
createdAt DateTime @default(now())
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
balance Int @default(0)
|
||||
osuBalance Int @default(0)
|
||||
lastAdAt DateTime?
|
||||
role String @default("user")
|
||||
avatarUrl String @default("")
|
||||
bio String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
inventory InventoryItem[]
|
||||
transactions Transaction[]
|
||||
jackpotWins JackpotRound[] @relation("JackpotWinner")
|
||||
jackpotDeposits JackpotDeposit[]
|
||||
duelRoomsCreated DuelRoom[] @relation("DuelCreator")
|
||||
duelWins DuelRoom[] @relation("DuelWinner")
|
||||
duelDeposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model Case {
|
||||
@@ -51,12 +60,15 @@ model CaseItem {
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
itemId Int
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
itemId Int
|
||||
locked Boolean @default(false)
|
||||
obtainedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
jackpotDeposits JackpotDeposit[]
|
||||
duelDeposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
@@ -68,3 +80,59 @@ model Transaction {
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model JackpotRound {
|
||||
id Int @id @default(autoincrement())
|
||||
status String @default("open")
|
||||
endsAt DateTime
|
||||
winnerUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
resolvedAt DateTime?
|
||||
winner User? @relation("JackpotWinner", fields: [winnerUserId], references: [id])
|
||||
deposits JackpotDeposit[]
|
||||
}
|
||||
|
||||
model JackpotDeposit {
|
||||
id Int @id @default(autoincrement())
|
||||
roundId Int
|
||||
userId Int
|
||||
inventoryItemId Int
|
||||
valueCents Int
|
||||
createdAt DateTime @default(now())
|
||||
round JackpotRound @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roundId, inventoryItemId])
|
||||
}
|
||||
|
||||
model DuelRoom {
|
||||
id Int @id @default(autoincrement())
|
||||
creatorId Int
|
||||
status String @default("open")
|
||||
maxPlayers Int
|
||||
maxItemsPerPlayer Int
|
||||
minItemValue Int @default(0)
|
||||
minPlayersToStart Int @default(2)
|
||||
winnerUserId Int?
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
resolvedAt DateTime?
|
||||
creator User @relation("DuelCreator", fields: [creatorId], references: [id], onDelete: Cascade)
|
||||
winner User? @relation("DuelWinner", fields: [winnerUserId], references: [id])
|
||||
deposits DuelDeposit[]
|
||||
}
|
||||
|
||||
model DuelDeposit {
|
||||
id Int @id @default(autoincrement())
|
||||
roomId Int
|
||||
userId Int
|
||||
inventoryItemId Int
|
||||
valueCents Int
|
||||
createdAt DateTime @default(now())
|
||||
room DuelRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roomId, inventoryItemId])
|
||||
}
|
||||
|
||||
+11
-1
@@ -49,6 +49,7 @@ async function main() {
|
||||
username: 'player',
|
||||
passwordHash: userHash,
|
||||
balance: 10000,
|
||||
osuBalance: 100,
|
||||
role: 'user',
|
||||
},
|
||||
});
|
||||
@@ -62,6 +63,15 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.transaction.create({
|
||||
data: {
|
||||
userId: demo.id,
|
||||
type: 'seed_osu',
|
||||
amount: 100,
|
||||
meta: JSON.stringify({ reason: 'demo_osu' }),
|
||||
},
|
||||
});
|
||||
|
||||
const items = [];
|
||||
for (const data of ITEMS) {
|
||||
items.push(await prisma.item.create({ data }));
|
||||
@@ -135,7 +145,7 @@ async function main() {
|
||||
|
||||
console.log('Seed complete.');
|
||||
console.log(' admin / admin123');
|
||||
console.log(' player / player123 (balance: 100.00)');
|
||||
console.log(' player / player123 (balance: 100.00, osu: 100)');
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user