169 lines
5.1 KiB
TypeScript
169 lines
5.1 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";
|
|
|
|
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
|
|
});
|
|
});
|
|
}
|
|
|
|
export function broadcastToCampaign(
|
|
io: Server,
|
|
campaignId: number,
|
|
event: string,
|
|
data: unknown,
|
|
) {
|
|
io.to(`campaign:${campaignId}`).emit(event, data);
|
|
}
|