import type { Server, Socket } from "socket.io"; import type { RowDataPacket, ResultSetHeader } from "mysql2"; import { randomUUID } from "crypto"; import db from "../db.js"; import { rollDice } from "../dice.js"; // ── Types ────────────────────────────────────────────────────────────────── export interface CombatEnemy { id: string; name: string; hp_current?: number; hp_max?: number; } export interface CombatState { id: string; label?: string; mode: "team"; round: number; phase: "rolling" | "active"; current_side: "party" | "enemies"; party_roll: number | null; enemy_roll: number | null; party_rolls: Record; character_ids: number[]; enemies: CombatEnemy[]; } // ── Helpers ──────────────────────────────────────────────────────────────── export async function loadCombats(campaignId: number): Promise { const [rows] = await db.execute( "SELECT combat_state FROM campaigns WHERE id = ?", [campaignId] ); if (rows.length === 0 || rows[0].combat_state === null) return []; const raw = rows[0].combat_state; return typeof raw === "string" ? JSON.parse(raw) : raw; } async function saveCombats(campaignId: number, combats: CombatState[]): Promise { await db.execute( "UPDATE campaigns SET combat_state = ? WHERE id = ?", [combats.length > 0 ? JSON.stringify(combats) : null, campaignId] ); } // Returns combats with hp_current and hp_max removed from every enemy. export function stripHp(combats: CombatState[]): CombatState[] { return combats.map((c) => ({ ...c, enemies: c.enemies.map(({ id, name }) => ({ id, name })), })); } // Broadcast full state to sender, HP-stripped to everyone else in the room. function broadcast( io: Server, socket: Socket, campaignId: number, combats: CombatState[] ): void { socket.to(`campaign:${campaignId}`).emit("initiative:updated", stripHp(combats)); socket.emit("initiative:updated", combats); } async function checkDM(socket: Socket, campaignId: number): Promise { const userId = socket.data.user?.userId; const [rows] = await db.execute( "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", [campaignId, userId] ); return rows.length > 0 && rows[0].role === "dm"; } async function insertRollLog( campaignId: number, characterId: number | null, characterName: string, characterColor: string, label: string, rollValue: number, rolls: number[] ): Promise { const [ins] = await db.execute( `INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, subtype, label, dice_expression, rolls, modifier, total, advantage, disadvantage, nat20) VALUES (?, ?, ?, ?, 'custom', 'initiative', ?, '1d20', ?, 0, ?, 0, 0, ?)`, [ campaignId, characterId, characterName, characterColor, label, JSON.stringify(rolls), rollValue, rollValue === 20 ? 1 : 0, ] ); const [saved] = await db.execute( "SELECT * FROM roll_log WHERE id = ?", [ins.insertId] ); if (!saved[0]) throw new Error("insertRollLog: SELECT after INSERT returned no row"); return saved[0]; } // ── Handler registration ─────────────────────────────────────────────────── export function registerInitiativeHandlers(io: Server, socket: Socket): void { // Send state to requesting client (called right after join-campaign). socket.on("initiative:request-state", async (campaignId: number) => { try { const userId = socket.data.user?.userId; const [rows] = await db.execute( "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", [campaignId, userId] ); const dm = rows.length > 0 && rows[0].role === "dm"; const combats = await loadCombats(campaignId); socket.emit("initiative:state", dm ? combats : stripHp(combats)); } catch (err) { console.error("[initiative]", err); } }); // DM: open rolling phase with enemies configured. socket.on("initiative:start", async (data: { campaignId: number; label?: string; character_ids: number[]; enemies: Array<{ name: string; hp_max: number }>; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const newCombat: CombatState = { id: randomUUID(), label: data.label, mode: "team", round: 1, phase: "rolling", current_side: "party", party_roll: null, enemy_roll: null, party_rolls: {}, character_ids: data.character_ids, enemies: data.enemies.map((e) => ({ id: randomUUID(), name: e.name, hp_current: e.hp_max, hp_max: e.hp_max, })), }; const updated = [...combats, newCombat]; await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // Any campaign member: roll d20 for their character's initiative. socket.on("initiative:roll", async (data: { campaignId: number; combatId: string; characterId: number; characterName: string; characterColor: string; }) => { try { const userId = socket.data.user?.userId; const dm = await checkDM(socket, data.campaignId); if (!dm) { const [charRows] = await db.execute( "SELECT user_id FROM characters WHERE id = ?", [data.characterId] ); if (charRows.length === 0 || charRows[0].user_id !== userId) return; } const result = rollDice("1d20", {}); const rollValue = result.total; const saved = await insertRollLog( data.campaignId, data.characterId, data.characterName, data.characterColor, "Initiative", rollValue, result.rolls ); io.to(`campaign:${data.campaignId}`).emit("roll:result", { ...saved, rolls: result.rolls, advantage: false, disadvantage: false, nat20: rollValue === 20, }); const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => { if (c.id !== data.combatId) return c; const newRolls = { ...c.party_rolls, [data.characterId]: rollValue }; return { ...c, party_rolls: newRolls, party_roll: Math.max(...Object.values(newRolls)), }; }); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: roll d20 for enemies. socket.on("initiative:enemy-roll", async (data: { campaignId: number; combatId: string; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const result = rollDice("1d20", {}); const rollValue = result.total; const saved = await insertRollLog( data.campaignId, null, "DM", "#888888", "Enemy Initiative", rollValue, result.rolls ); io.to(`campaign:${data.campaignId}`).emit("roll:result", { ...saved, rolls: result.rolls, advantage: false, disadvantage: false, nat20: rollValue === 20, }); const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => c.id !== data.combatId ? c : { ...c, enemy_roll: rollValue } ); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: end rolling phase, set current_side based on results. Ties go to party. socket.on("initiative:begin", async (data: { campaignId: number; combatId: string; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => { if (c.id !== data.combatId) return c; const partyWins = (c.party_roll ?? 0) >= (c.enemy_roll ?? 0); return { ...c, phase: "active" as const, current_side: (partyWins ? "party" : "enemies") as "party" | "enemies", }; }); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: flip active side; increment round when side flips back to party. socket.on("initiative:next", async (data: { campaignId: number; combatId: string; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => { if (c.id !== data.combatId) return c; const nextSide: "party" | "enemies" = c.current_side === "party" ? "enemies" : "party"; return { ...c, current_side: nextSide, round: nextSide === "party" ? c.round + 1 : c.round, }; }); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: edit an enemy's name or HP. socket.on("initiative:update-enemy", async (data: { campaignId: number; combatId: string; enemyId: string; name?: string; hp_current?: number; hp_max?: number; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => { if (c.id !== data.combatId) return c; return { ...c, enemies: c.enemies.map((e) => e.id !== data.enemyId ? e : { ...e, ...(data.name !== undefined ? { name: data.name } : {}), ...(data.hp_current !== undefined ? { hp_current: data.hp_current } : {}), ...(data.hp_max !== undefined ? { hp_max: data.hp_max } : {}), } ), }; }); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: add a new enemy mid-combat. socket.on("initiative:add-enemy", async (data: { campaignId: number; combatId: string; name: string; hp_max: number; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => c.id !== data.combatId ? c : { ...c, enemies: [...c.enemies, { id: randomUUID(), name: data.name, hp_current: data.hp_max, hp_max: data.hp_max, }], } ); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: remove an enemy (died, fled). socket.on("initiative:remove-enemy", async (data: { campaignId: number; combatId: string; enemyId: string; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const updated = combats.map((c) => c.id !== data.combatId ? c : { ...c, enemies: c.enemies.filter((e) => e.id !== data.enemyId), } ); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); // DM: end combat, remove from array (saves NULL when last combat ends). socket.on("initiative:end", async (data: { campaignId: number; combatId: string; }) => { try { if (!await checkDM(socket, data.campaignId)) return; const combats = await loadCombats(data.campaignId); const updated = combats.filter((c) => c.id !== data.combatId); await saveCombats(data.campaignId, updated); broadcast(io, socket, data.campaignId, updated); } catch (err) { console.error("[initiative]", err); } }); }