import "dotenv/config"; import { createServer } from "http"; import { parse } from "url"; import next from "next"; import { getToken } from "next-auth/jwt"; import { Server as SocketIOServer } from "socket.io"; import { prisma } from "./src/lib/prisma"; import { debitForBet, creditForWin, refundBet, refundRoomPartyIfNotRefunded, } 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 // CORS: restrict to configured site URL only — never wildcard in production. const allowedOrigin = process.env.NEXT_PUBLIC_SITE_URL ?? process.env.AUTH_URL ?? "http://127.0.0.1:8008"; 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: allowedOrigin, methods: ["GET", "POST"] }, path: "/api/socket", }); // ── COIN FLIP ────────────────────────────────────────────────────────────── const coinFlipNS = io.of("/coin-flip"); async function resolveSocketUserId(socket: { request: unknown }): Promise { try { const token = await getToken({ req: socket.request as Parameters[0]["req"], secret: process.env.AUTH_SECRET, }); const uid = typeof token?.id === "string" ? token.id : typeof token?.sub === "string" ? token.sub : null; if (!uid) return null; const user = await prisma.user.findUnique({ where: { id: uid }, select: { id: true } }); return user?.id ?? null; } catch { return null; } } coinFlipNS.on("connection", (socket) => { socket.on("join_room", async ({ roomId }: { roomId: string }) => { const userId = await resolveSocketUserId({ request: socket.request }); if (!userId) { socket.emit("error", "Invalid session"); return; } const room = await prisma.gameRoom.findUnique({ where: { id: roomId } }); if (!room || room.status !== "WAITING") { socket.emit("error", "Room not available"); return; } // Creator just reconnects/waits — nothing to debit. if (room.creatorId === userId) { socket.join(roomId); socket.emit("waiting", { roomId }); return; } // Atomically claim the joiner slot — prevents concurrent joiners racing // on the same WAITING room. const claimed = await prisma.gameRoom.updateMany({ where: { id: roomId, status: "WAITING", joinerId: null }, data: { joinerId: userId, status: "ACTIVE" }, }); if (claimed.count !== 1) { socket.emit("error", "Room not available"); return; } // Joiner is debited at join time. Creator was already debited when the // room was created (POST /api/games/rooms) — do NOT debit again here. try { await debitForBet(userId, room.wageBLW, "COIN_FLIP", undefined, { gameRoomId: roomId }); } catch { await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } }); socket.emit("error", "Insufficient balance"); return; } socket.join(roomId); coinFlipNS.to(roomId).emit("game_start", { roomId }); // Resolve — provably fair flip. 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)); try { await creditForWin(winnerId, payout, "COIN_FLIP", undefined, { gameRoomId: roomId }); await prisma.gameRoom.update({ where: { id: roomId }, data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout } }, }); } catch (err) { console.error("[coin-flip] payout/resolve failed for room", roomId, err); // Room stuck as ACTIVE — needs manual reconciliation; we still emit result. } 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 }: { roomId: string }) => { const userId = await resolveSocketUserId({ request: socket.request }); if (!userId) { socket.emit("error", "Invalid session"); return; } socket.data.userId = userId; 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: room.creatorId, // always from DB, not client 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 — in-memory race guard (single-process; DB is source of truth). const state = pongRooms.get(roomId); if (!state || state.joinerId) { socket.emit("error", "Room full"); return; } // Creator is already debited at room creation. Only debit joiner here. try { await debitForBet(userId, room.wageBLW, "PONG", undefined, { gameRoomId: roomId }); } catch { socket.emit("error", "Insufficient balance"); return; } // Claim the joiner slot atomically (DB is source of truth). const claimed = await prisma.gameRoom.updateMany({ where: { id: roomId, status: "WAITING", joinerId: null }, data: { joinerId: userId, status: "ACTIVE" }, }); if (claimed.count !== 1) { await refundBet(userId, room.wageBLW, "PONG", undefined, { gameRoomId: roomId }); socket.emit("error", "Room not available"); return; } state.joinerId = userId; 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; if (s.ball.y <= 0 || s.ball.y >= 400) s.ball.vy *= -1; 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); 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 }); 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)); const finalScores = { ...s.scores }; pongRooms.delete(roomId); // Await payout + DB update before broadcasting — errors are logged, // not silently swallowed. void (async () => { try { await creditForWin(winnerId, payout, "PONG", undefined, { gameRoomId: roomId }); await prisma.gameRoom.update({ where: { id: roomId }, data: { status: "RESOLVED", resultData: { winnerId, scores: finalScores, payout } }, }); } catch (err) { console.error("[pong] payout/resolve failed for room", roomId, err); } pongNS.to(roomId).emit("game_over", { winnerId, scores: finalScores, payout }); })(); } }, 50); }); socket.on("paddle_move", ({ roomId, y }: { roomId: string; y: number }) => { const state = pongRooms.get(roomId); if (!state) return; const userId = typeof socket.data.userId === "string" ? socket.data.userId : null; if (!userId) 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 WAITING rooms and refund the creator's locked stake ── async function sweepExpiredRooms() { const stale = await prisma.gameRoom.findMany({ where: { status: "WAITING", expiresAt: { lt: new Date() } }, select: { id: true, creatorId: true, wageBLW: true, gameType: true }, }); for (const r of stale) { // Move WAITING → EXPIRED atomically; only one sweep per room wins. const flipped = await prisma.gameRoom.updateMany({ where: { id: r.id, status: "WAITING" }, data: { status: "EXPIRED" }, }); if (flipped.count !== 1) continue; try { await refundRoomPartyIfNotRefunded(r.id, r.creatorId, r.wageBLW, r.gameType); } catch (err) { console.error("[room-expiry] refund failed for", r.id, err); } } } setInterval(() => { void sweepExpiredRooms(); }, 60_000); // ── Boot recovery: any room that was ACTIVE/WAITING when the process died // has no in-memory state to resume from. Refund all parties and mark EXPIRED // so players get their BLW back and rooms don't appear as full/playable. async function recoverOrphanedRoomsOnBoot() { const orphans = await prisma.gameRoom.findMany({ where: { OR: [ { status: "ACTIVE", gameType: "PONG" }, // WAITING rooms past expiry caught here too. { status: "WAITING", expiresAt: { lt: new Date() } }, ], }, select: { id: true, creatorId: true, joinerId: true, wageBLW: true, gameType: true, status: true }, }); for (const r of orphans) { const flipped = await prisma.gameRoom.updateMany({ where: { id: r.id, status: r.status }, data: { status: "EXPIRED" }, }); if (flipped.count !== 1) continue; try { await refundRoomPartyIfNotRefunded(r.id, r.creatorId, r.wageBLW, r.gameType); if (r.joinerId) { // Joiner debit also points to gameRoomId — same idempotent guard works. await refundRoomPartyIfNotRefunded(r.id, r.joinerId, r.wageBLW, r.gameType); } console.log(`[boot-recovery] refunded room ${r.id} (${r.gameType})`); } catch (err) { console.error("[boot-recovery] refund failed for", r.id, err); } } } // Run recovery to completion BEFORE we start accepting traffic — otherwise // new clients could race against rooms we're about to mark EXPIRED. recoverOrphanedRoomsOnBoot() .catch((err) => console.error("[boot-recovery] sweep failed:", err)) .finally(() => { httpServer.listen(8008, "0.0.0.0", () => { console.log(`> Ready on http://0.0.0.0:8008`); }); }); });