import "dotenv/config"; import { createServer } from "http"; import { parse } from "url"; import next from "next"; import { Server as SocketIOServer } from "socket.io"; import { prisma } from "./src/lib/prisma"; import { debitForBet, creditForWin, refundBet } from "./src/lib/game-ledger"; import { generateServerSeed, deriveInt } from "./src/lib/provably-fair"; const dev = process.env.NODE_ENV !== "production"; const app = next({ dev, hostname: "0.0.0.0", port: 8008 }); const handle = app.getRequestHandler(); const HOUSE_CUT = 0.05; // 5% on PvP app.prepare().then(() => { const httpServer = createServer((req, res) => { const parsedUrl = parse(req.url ?? "/", true); handle(req, res, parsedUrl); }); const io = new SocketIOServer(httpServer, { cors: { origin: "*" }, path: "/api/socket", }); // ── COIN FLIP ────────────────────────────────────────────────────────────── const coinFlipNS = io.of("/coin-flip"); coinFlipNS.on("connection", (socket) => { socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => { const room = await prisma.gameRoom.findUnique({ where: { id: roomId } }); if (!room || room.status !== "WAITING") { socket.emit("error", "Room not available"); return; } if (room.creatorId === userId) { socket.join(roomId); socket.emit("waiting", { roomId }); return; } // Check balance const wallet = await prisma.wallet.findUnique({ where: { userId } }); if (!wallet || wallet.balanceCredits < room.wageBLW) { socket.emit("error", "Insufficient balance"); return; } // Debit joiner try { await debitForBet(userId, room.wageBLW, "COIN_FLIP"); } catch { socket.emit("error", "Debit failed"); return; } // Debit creator try { await debitForBet(room.creatorId, room.wageBLW, "COIN_FLIP"); } catch { await refundBet(userId, room.wageBLW, "COIN_FLIP"); socket.emit("error", "Creator debit failed"); return; } await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: userId, status: "ACTIVE" }, }); socket.join(roomId); coinFlipNS.to(roomId).emit("game_start", { roomId }); // Resolve const serverSeed = generateServerSeed(); const result = deriveInt(serverSeed, roomId, 0, 0, 1); // 0 = heads, 1 = tails const winnerId = result === 0 ? room.creatorId : userId; const pot = room.wageBLW * 2; const payout = Math.floor(pot * (1 - HOUSE_CUT)); await creditForWin(winnerId, payout, "COIN_FLIP"); await prisma.gameRoom.update({ where: { id: roomId }, data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout }, }, }); coinFlipNS.to(roomId).emit("result", { result, resultLabel: result === 0 ? "heads" : "tails", winnerId, payout, serverSeed, }); }); }); // ── PONG ─────────────────────────────────────────────────────────────────── const pongNS = io.of("/pong"); const pongRooms = new Map | null; }>(); pongNS.on("connection", (socket) => { socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => { const room = await prisma.gameRoom.findUnique({ where: { id: roomId } }); if (!room || !["WAITING", "ACTIVE"].includes(room.status)) { socket.emit("error", "Room not available"); return; } socket.join(roomId); if (room.creatorId === userId) { if (!pongRooms.has(roomId)) { pongRooms.set(roomId, { roomId, creatorId: userId, joinerId: null, wageBLW: room.wageBLW, ball: { x: 400, y: 200, vx: 3, vy: 2 }, paddles: { creator: 180, joiner: 180 }, scores: { creator: 0, joiner: 0 }, interval: null, }); } socket.emit("waiting", { roomId }); return; } // Joiner const state = pongRooms.get(roomId); if (!state || state.joinerId) { socket.emit("error", "Room full"); return; } const wallet = await prisma.wallet.findUnique({ where: { userId } }); if (!wallet || wallet.balanceCredits < room.wageBLW) { socket.emit("error", "Insufficient balance"); return; } try { await debitForBet(userId, room.wageBLW, "PONG"); await debitForBet(room.creatorId, room.wageBLW, "PONG"); } catch { await refundBet(userId, room.wageBLW, "PONG"); socket.emit("error", "Debit failed"); return; } state.joinerId = userId; await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: userId, status: "ACTIVE" } }); pongNS.to(roomId).emit("game_start", { roomId, creatorId: room.creatorId, joinerId: userId }); // Game loop — 20 ticks/sec state.interval = setInterval(() => { const s = pongRooms.get(roomId); if (!s) return; s.ball.x += s.ball.vx; s.ball.y += s.ball.vy; // Wall bounce if (s.ball.y <= 0 || s.ball.y >= 400) s.ball.vy *= -1; // Paddle bounce if (s.ball.x <= 20 && Math.abs(s.ball.y - s.paddles.creator) < 50) s.ball.vx = Math.abs(s.ball.vx); if (s.ball.x >= 780 && Math.abs(s.ball.y - s.paddles.joiner) < 50) s.ball.vx = -Math.abs(s.ball.vx); // Score if (s.ball.x <= 0) { s.scores.joiner++; s.ball = { x: 400, y: 200, vx: 3, vy: 2 }; } if (s.ball.x >= 800) { s.scores.creator++; s.ball = { x: 400, y: 200, vx: -3, vy: 2 }; } pongNS.to(roomId).emit("tick", { ball: s.ball, paddles: s.paddles, scores: s.scores }); // Win condition: first to 5 if (s.scores.creator >= 5 || s.scores.joiner >= 5) { clearInterval(s.interval!); s.interval = null; const winnerId = s.scores.creator >= 5 ? s.creatorId : s.joinerId!; const pot = s.wageBLW * 2; const payout = Math.floor(pot * (1 - HOUSE_CUT)); creditForWin(winnerId, payout, "PONG").then(() => { prisma.gameRoom.update({ where: { id: roomId }, data: { status: "RESOLVED", resultData: { winnerId, scores: s.scores, payout } }, }).catch(() => {}); }).catch(() => {}); pongNS.to(roomId).emit("game_over", { winnerId, scores: s.scores, payout }); pongRooms.delete(roomId); } }, 50); }); socket.on("paddle_move", ({ roomId, userId, y }: { roomId: string; userId: string; y: number }) => { const state = pongRooms.get(roomId); if (!state) return; const clampedY = Math.max(0, Math.min(360, y)); if (state.creatorId === userId) state.paddles.creator = clampedY; else if (state.joinerId === userId) state.paddles.joiner = clampedY; }); }); // Expire abandoned rooms every minute setInterval(async () => { await prisma.gameRoom.updateMany({ where: { status: "WAITING", expiresAt: { lt: new Date() } }, data: { status: "EXPIRED" }, }); }, 60_000); httpServer.listen(8008, "0.0.0.0", () => { console.log(`> Ready on http://0.0.0.0:8008`); }); });