Legal pages, Threads OAuth, live wallet pill, PvP money flow + resume
- Legal: /privacy-policy and /datadeletion (shared LegalPageLayout, footer links, sitemap + integration tests, NEXT_PUBLIC_LEGAL_CONTACT_EMAIL). - Threads OAuth: server-side /api/threads-exchange and /threads-callback page (Suspense + client component, noindex, no leaked secrets). - Username + live wallet pill in top nav. New NavWalletBalance component polls /api/wallet, refreshes on tab focus, and listens to the `wallet:refresh` event bus so cash-outs and refunds update the nav in real time. Flash animation on balance changes. - useLiveWalletBalance hook now broadcasts `wallet:refresh` after every fetch so games, exchange panel, wallet actions, and nav all stay in sync without extra polling. - PvP fund-locking (`POST /api/games/rooms`): creator funds debited atomically with room creation; ledger entry tagged with `gameRoomId`. Joiner debit happens at join. Old double-debit of the creator is gone. - DELETE /api/games/rooms?id=... lets a creator cancel a WAITING room and get an idempotent refund. Coin Flip + Pong waiting screens show a Cancel & Refund button. - Pong/Coin Flip recovery: expiry sweep + boot-time `recoverOrphaned RoomsOnBoot()` (runs before listen()) refund both parties for any ACTIVE/expired rooms so a server restart never strands locked credits. - Schema migration `20260520000000_game_ledger_links` adds optional `gameSessionId` + `gameRoomId` FKs to LedgerEntry (with indexes) and extra indexes on GameSession/GameRoom for resume + sweep queries. - GET /api/games/active returns a user's active solo session + open rooms (sanitized — no mine/bomb positions). Mines and Tower clients rehydrate on mount so a refresh mid-round resumes instead of dropping. - ActiveGamesBanner surfaces unfinished rounds on /wallet and /casino with Resume / Rejoin / Cancel & refund actions. - ExchangePanel unified with useLiveWalletBalance; per-game header gets an "Open wallet →" chip; Dice clears stale result on roll; Blackjack reveals full dealer hand on natural blackjack/push; Mines refund label fixed; Tower final multiplier fixed; GameHistory error path; Prediction "Resolved" tab. - Site audit + redmeFIXES triage notes (REDME-FIXSES-TRIAGE.txt, SITE-AUDIT.txt). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
175
server.ts
175
server.ts
@@ -3,9 +3,15 @@ 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 } from "./src/lib/game-ledger";
|
||||
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";
|
||||
@@ -34,14 +40,25 @@ app.prepare().then(() => {
|
||||
// ── COIN FLIP ──────────────────────────────────────────────────────────────
|
||||
const coinFlipNS = io.of("/coin-flip");
|
||||
|
||||
coinFlipNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
// Reject obviously spoofed ids — user must exist in DB.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
async function resolveSocketUserId(socket: { request: unknown }): Promise<string | null> {
|
||||
try {
|
||||
const token = await getToken({
|
||||
req: socket.request as Parameters<typeof getToken>[0]["req"],
|
||||
secret: process.env.AUTH_SECRET,
|
||||
});
|
||||
if (!userExists) { socket.emit("error", "Invalid session"); return; }
|
||||
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") {
|
||||
@@ -67,37 +84,16 @@ app.prepare().then(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Balance check after claiming to avoid charging then bouncing.
|
||||
const wallet = await prisma.wallet.findUnique({ where: { userId } });
|
||||
if (!wallet || wallet.balanceCredits < room.wageBLW) {
|
||||
// Revert the slot claim.
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { joinerId: null, status: "WAITING" },
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Debit joiner.
|
||||
try {
|
||||
await debitForBet(userId, room.wageBLW, "COIN_FLIP");
|
||||
} catch {
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
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");
|
||||
await prisma.gameRoom.update({ where: { id: roomId }, data: { joinerId: null, status: "WAITING" } });
|
||||
socket.emit("error", "Creator debit failed");
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(roomId);
|
||||
coinFlipNS.to(roomId).emit("game_start", { roomId });
|
||||
|
||||
@@ -109,7 +105,7 @@ app.prepare().then(() => {
|
||||
const payout = Math.floor(pot * (1 - HOUSE_CUT));
|
||||
|
||||
try {
|
||||
await creditForWin(winnerId, payout, "COIN_FLIP");
|
||||
await creditForWin(winnerId, payout, "COIN_FLIP", undefined, { gameRoomId: roomId });
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { result, winnerId, serverSeed, payout } },
|
||||
@@ -143,13 +139,11 @@ app.prepare().then(() => {
|
||||
}>();
|
||||
|
||||
pongNS.on("connection", (socket) => {
|
||||
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
|
||||
// Reject obviously spoofed ids.
|
||||
const userExists = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!userExists) { socket.emit("error", "Invalid session"); return; }
|
||||
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)) {
|
||||
@@ -180,21 +174,24 @@ app.prepare().then(() => {
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
// 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;
|
||||
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.
|
||||
@@ -233,7 +230,7 @@ app.prepare().then(() => {
|
||||
// not silently swallowed.
|
||||
void (async () => {
|
||||
try {
|
||||
await creditForWin(winnerId, payout, "PONG");
|
||||
await creditForWin(winnerId, payout, "PONG", undefined, { gameRoomId: roomId });
|
||||
await prisma.gameRoom.update({
|
||||
where: { id: roomId },
|
||||
data: { status: "RESOLVED", resultData: { winnerId, scores: finalScores, payout } },
|
||||
@@ -247,24 +244,78 @@ app.prepare().then(() => {
|
||||
}, 50);
|
||||
});
|
||||
|
||||
socket.on("paddle_move", ({ roomId, userId, y }: { roomId: string; userId: string; y: number }) => {
|
||||
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 rooms every minute.
|
||||
setInterval(async () => {
|
||||
await prisma.gameRoom.updateMany({
|
||||
// ── 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() } },
|
||||
data: { status: "EXPIRED" },
|
||||
select: { id: true, creatorId: true, wageBLW: true, gameType: true },
|
||||
});
|
||||
}, 60_000);
|
||||
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);
|
||||
|
||||
httpServer.listen(8008, "0.0.0.0", () => {
|
||||
console.log(`> Ready on http://0.0.0.0:8008`);
|
||||
});
|
||||
// ── 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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user