darkwatch/server/src/socket.ts

172 lines
5.2 KiB
TypeScript

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";
import { registerInitiativeHandlers } from "./routes/initiative.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<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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<import("mysql2").ResultSetHeader>(
`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<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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
});
registerInitiativeHandlers(io, socket);
});
}
export function broadcastToCampaign(
io: Server,
campaignId: number,
event: string,
data: unknown,
) {
io.to(`campaign:${campaignId}`).emit(event, data);
}