darkwatch/server/src/routes/initiative.ts
2026-04-11 15:37:27 -04:00

399 lines
13 KiB
TypeScript

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<number, number>;
character_ids: number[];
enemies: CombatEnemy[];
}
// ── Helpers ────────────────────────────────────────────────────────────────
export async function loadCombats(campaignId: number): Promise<CombatState[]> {
const [rows] = await db.execute<RowDataPacket[]>(
"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<void> {
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<boolean> {
const userId = socket.data.user?.userId;
const [rows] = await db.execute<RowDataPacket[]>(
"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<RowDataPacket> {
const [ins] = await db.execute<ResultSetHeader>(
`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<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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);
}
});
}