import { Server } from "socket.io"; import { parse as parseCookie } from "cookie"; import type { RowDataPacket } from "mysql2"; import db from "./db.js"; import { rollDice } from "./dice.js"; import { verifyToken } from "./auth/jwt.js"; interface EffectState { active: boolean; intensity: number; } interface AtmosphereUpdateData { campaignId: number; fog: EffectState; fire: EffectState; rain: EffectState; embers: EffectState; } export function setupSocket(io: Server) { // Verify JWT from cookie on every connection io.use((socket, next) => { try { const cookies = parseCookie(socket.handshake.headers.cookie ?? ""); const token = cookies["darkwatch_token"]; if (!token) throw new Error("No token"); socket.data.user = verifyToken(token); next(); } catch { next(new Error("Unauthorized")); } }); io.on("connection", (socket) => { socket.on("join-campaign", (campaignId: string) => { socket.join(`campaign:${campaignId}`); }); socket.on("leave-campaign", (campaignId: string) => { socket.leave(`campaign:${campaignId}`); }); socket.on( "roll:request", async (data: { campaignId: number; characterId?: number; characterName?: string; characterColor?: string; type: string; dice: string; label: string; modifier?: number; advantage?: boolean; disadvantage?: boolean; }) => { // Verify user is a member of this campaign const userId = socket.data.user?.userId; const [memberRows] = await db.execute( "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", [data.campaignId, userId] ); if (memberRows.length === 0) { socket.emit("roll:error", { error: "Not a campaign member" }); return; } const isDM = memberRows[0].role === "dm"; // If rolling for a specific character, verify ownership (DMs can roll any) if (data.characterId && !isDM) { const [charRows] = await db.execute( "SELECT user_id FROM characters WHERE id = ?", [data.characterId] ); if (charRows.length === 0 || charRows[0].user_id !== userId) { socket.emit("roll:error", { error: "Cannot roll for another player's character" }); return; } } const result = rollDice(data.dice, { advantage: data.advantage, disadvantage: data.disadvantage, }); if (result.error) { socket.emit("roll:error", { error: result.error }); return; } const isD20Roll = data.dice.match(/d20/i); let nat20 = false; if (isD20Roll && result.rolls.length > 0) { if (data.advantage) { nat20 = Math.max(...result.rolls) === 20; } else if (data.disadvantage) { nat20 = Math.min(...result.rolls) === 20; } else { nat20 = result.rolls[0] === 20; } } const [insertResult] = await db.execute( `INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ data.campaignId, data.characterId ?? null, data.characterName ?? "Roll", data.characterColor ?? "", data.type ?? "custom", data.label, data.dice, JSON.stringify(result.rolls), result.modifier, result.total, data.advantage ? 1 : 0, data.disadvantage ? 1 : 0, nat20 ? 1 : 0, ] ); const [savedRows] = await db.execute( "SELECT * FROM roll_log WHERE id = ?", [insertResult.insertId] ); const broadcast = { ...savedRows[0], rolls: result.rolls, advantage: data.advantage ?? false, disadvantage: data.disadvantage ?? false, nat20, }; io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); } ); socket.on("atmosphere:update", async (data: AtmosphereUpdateData) => { // Only DMs can broadcast atmosphere changes const userId = socket.data.user?.userId; const [rows] = await db.execute( "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", [data.campaignId, userId] ); if (rows.length === 0 || rows[0].role !== "dm") return; const { campaignId, ...atmosphere } = data; io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere); }); socket.on("disconnect", () => { // Rooms are cleaned up automatically by Socket.IO }); }); } export function broadcastToCampaign( io: Server, campaignId: number, event: string, data: unknown, ) { io.to(`campaign:${campaignId}`).emit(event, data); }