- Torch timer: 60min countdown per character, visual warnings at 10m/5m/1m - Fog overlay: CSS radial gradient layers with seamless infinite drift - Fog synced across all clients via socket, adapts to light/dark themes - DM card redesign: compact layout with HP/AC/luck/torch + modifier row - Grid changed to 3-up (from 4) with larger fonts - DiceBear avatars on cards and character sheets with style picker - Campaign name shown in header - Server: JSON.stringify fix for object fields in PATCH handler Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
115 lines
3.2 KiB
TypeScript
115 lines
3.2 KiB
TypeScript
import { Server } from "socket.io";
|
|
import db from "./db.js";
|
|
import { rollDice } from "./dice.js";
|
|
|
|
export function setupSocket(io: Server) {
|
|
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",
|
|
(data: {
|
|
campaignId: number;
|
|
characterId?: number;
|
|
characterName?: string;
|
|
characterColor?: string;
|
|
type: string;
|
|
dice: string;
|
|
label: string;
|
|
modifier?: number;
|
|
advantage?: boolean;
|
|
disadvantage?: boolean;
|
|
}) => {
|
|
const result = rollDice(data.dice, {
|
|
advantage: data.advantage,
|
|
disadvantage: data.disadvantage,
|
|
});
|
|
|
|
if (result.error) {
|
|
socket.emit("roll:error", { error: result.error });
|
|
return;
|
|
}
|
|
|
|
// Detect nat 20
|
|
const isD20Roll = data.dice.match(/d20/i);
|
|
let nat20 = false;
|
|
if (isD20Roll && result.rolls.length > 0) {
|
|
if (data.advantage) {
|
|
// With advantage, nat20 if the chosen (higher) die is 20
|
|
nat20 = Math.max(...result.rolls) === 20;
|
|
} else if (data.disadvantage) {
|
|
// With disadvantage, nat20 if the chosen (lower) die is 20
|
|
nat20 = Math.min(...result.rolls) === 20;
|
|
} else {
|
|
nat20 = result.rolls[0] === 20;
|
|
}
|
|
}
|
|
|
|
const row = db
|
|
.prepare(
|
|
`
|
|
INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`,
|
|
)
|
|
.run(
|
|
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 saved = db
|
|
.prepare("SELECT * FROM roll_log WHERE id = ?")
|
|
.get(row.lastInsertRowid) as Record<string, unknown>;
|
|
|
|
const broadcast = {
|
|
...saved,
|
|
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",
|
|
(data: { campaignId: number; fog: boolean }) => {
|
|
io.to(`campaign:${data.campaignId}`).emit("atmosphere:update", {
|
|
fog: data.fog,
|
|
});
|
|
},
|
|
);
|
|
|
|
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);
|
|
}
|