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