feat: add initiative socket handlers
This commit is contained in:
parent
333ac1e24f
commit
53453bcbd7
1 changed files with 399 additions and 0 deletions
399
server/src/routes/initiative.ts
Normal file
399
server/src/routes/initiative.ts
Normal file
|
|
@ -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<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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue