From 53453bcbd745155a4c3d3c3195b476040057fea7 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 15:32:48 -0400 Subject: [PATCH] feat: add initiative socket handlers --- server/src/routes/initiative.ts | 399 ++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 server/src/routes/initiative.ts diff --git a/server/src/routes/initiative.ts b/server/src/routes/initiative.ts new file mode 100644 index 0000000..dae767b --- /dev/null +++ b/server/src/routes/initiative.ts @@ -0,0 +1,399 @@ +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); + } + }); +}