import { Router } from "express"; import type { ParamsDictionary } from "express-serve-static-core"; import type { Server } from "socket.io"; import db from "../db.js"; import { broadcastToCampaign } from "../socket.js"; type CampaignParams = ParamsDictionary & { campaignId: string }; const router = Router({ mergeParams: true }); const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; function generateCharacterColor(): string { const hue = Math.floor(Math.random() * 360); return `hsl(${hue}, 60%, 65%)`; } function parseJson(val: unknown): Record { if (typeof val === "string") { try { return JSON.parse(val); } catch { return {}; } } return (val as Record) ?? {}; } function parseGear(rows: Array>) { return rows.map((r) => ({ ...r, properties: parseJson(r.properties), effects: parseJson(r.effects), })); } function parseTalents(rows: Array>) { return rows.map((r) => ({ ...r, effect: parseJson(r.effect) })); } // GET /api/campaigns/:campaignId/characters — list characters in a campaign router.get("/", (req, res) => { const { campaignId } = req.params; const characters = db .prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name") .all(campaignId) as Array>; const stmtStats = db.prepare( "SELECT stat_name, value FROM character_stats WHERE character_id = ?", ); const stmtGear = db.prepare( "SELECT * FROM character_gear WHERE character_id = ?", ); const stmtTalents = db.prepare( "SELECT * FROM character_talents WHERE character_id = ?", ); const enriched = characters.map((char) => ({ ...char, overrides: parseJson(char.overrides), stats: stmtStats.all(char.id), gear: parseGear(stmtGear.all(char.id) as Array>), talents: parseTalents( stmtTalents.all(char.id) as Array>, ), })); res.json(enriched); }); // POST /api/campaigns/:campaignId/characters — create a character router.post("/", (req, res) => { const { campaignId } = req.params; const { name, class: charClass, ancestry, hp_max } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Character name is required" }); return; } const insertChar = db.prepare(` INSERT INTO characters (campaign_id, name, class, ancestry, hp_current, hp_max, color) VALUES (?, ?, ?, ?, ?, ?, ?) `); const insertStat = db.prepare( "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)", ); const result = insertChar.run( campaignId, name.trim(), charClass || "Fighter", ancestry || "Human", hp_max || 0, hp_max || 0, generateCharacterColor(), ); const characterId = result.lastInsertRowid; for (const stat of DEFAULT_STATS) { insertStat.run(characterId, stat); } const character = db .prepare("SELECT * FROM characters WHERE id = ?") .get(characterId); const stats = db .prepare( "SELECT stat_name, value FROM character_stats WHERE character_id = ?", ) .all(characterId); const enriched = { ...(character as Record), overrides: {}, stats, gear: [], talents: [], }; const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(campaignId), "character:created", enriched); res.status(201).json(enriched); }); // PATCH /api/characters/:id — update character fields router.patch("/:id", (req, res) => { const { id } = req.params; const allowedFields = [ "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max", "ac", "alignment", "title", "notes", "background", "deity", "languages", "gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token", ]; const updates: string[] = []; const values: unknown[] = []; for (const field of allowedFields) { if (req.body[field] !== undefined) { updates.push(`${field} = ?`); values.push(req.body[field]); } } if (updates.length === 0) { res.status(400).json({ error: "No valid fields to update" }); return; } values.push(id); db.prepare(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`).run( ...values, ); const character = db .prepare("SELECT * FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "character:updated", { id: Number(id), ...req.body, }); res.json(character); }); // DELETE /api/characters/:id — delete a character router.delete("/:id", (req, res) => { const character = db .prepare("SELECT * FROM characters WHERE id = ?") .get(req.params.id) as Record | undefined; if (!character) { res.status(404).json({ error: "Character not found" }); return; } db.prepare("DELETE FROM characters WHERE id = ?").run(req.params.id); const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "character:deleted", { id: Number(req.params.id), }); res.status(204).end(); }); // PATCH /api/characters/:id/stats/:statName — update a single stat router.patch("/:id/stats/:statName", (req, res) => { const { id, statName } = req.params; const { value } = req.body; const upper = statName.toUpperCase(); if (!DEFAULT_STATS.includes(upper)) { res.status(400).json({ error: "Invalid stat name" }); return; } db.prepare( "UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?", ).run(value, id, upper); const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "stat:updated", { characterId: Number(id), statName: upper, value, }); res.json({ characterId: Number(id), statName: upper, value }); }); // POST /api/characters/:id/gear — add gear router.post("/:id/gear", (req, res) => { const { id } = req.params; const { name, type, slot_count, properties, effects, game_item_id } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Gear name is required" }); return; } const result = db .prepare( "INSERT INTO character_gear (character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?)", ) .run( id, name.trim(), type || "gear", slot_count ?? 1, JSON.stringify(properties || {}), JSON.stringify(effects || {}), game_item_id ?? null, ); const gearRow = db .prepare("SELECT * FROM character_gear WHERE id = ?") .get(result.lastInsertRowid) as Record; const gear = { ...gearRow, properties: parseJson(gearRow.properties), effects: parseJson(gearRow.effects), }; const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "gear:added", { characterId: Number(id), gear, }); res.status(201).json(gear); }); // DELETE /api/characters/:id/gear/:gearId — remove gear router.delete("/:id/gear/:gearId", (req, res) => { const { id, gearId } = req.params; const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const result = db .prepare("DELETE FROM character_gear WHERE id = ? AND character_id = ?") .run(gearId, id); if (result.changes === 0) { res.status(404).json({ error: "Gear not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "gear:removed", { characterId: Number(id), gearId: Number(gearId), }); res.status(204).end(); }); // POST /api/characters/:id/talents — add talent router.post("/:id/talents", (req, res) => { const { id } = req.params; const { name, description, effect, game_talent_id } = req.body; if (!name || !name.trim()) { res.status(400).json({ error: "Talent name is required" }); return; } const result = db .prepare( "INSERT INTO character_talents (character_id, name, description, effect, game_talent_id) VALUES (?, ?, ?, ?, ?)", ) .run( id, name.trim(), description || "", JSON.stringify(effect || {}), game_talent_id ?? null, ); const talentRow = db .prepare("SELECT * FROM character_talents WHERE id = ?") .get(result.lastInsertRowid) as Record; const talent = { ...talentRow, effect: parseJson(talentRow.effect) }; const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "talent:added", { characterId: Number(id), talent, }); res.status(201).json(talent); }); // DELETE /api/characters/:id/talents/:talentId — remove talent router.delete("/:id/talents/:talentId", (req, res) => { const { id, talentId } = req.params; const character = db .prepare("SELECT campaign_id FROM characters WHERE id = ?") .get(id) as Record; if (!character) { res.status(404).json({ error: "Character not found" }); return; } const result = db .prepare("DELETE FROM character_talents WHERE id = ? AND character_id = ?") .run(talentId, id); if (result.changes === 0) { res.status(404).json({ error: "Talent not found" }); return; } const io: Server = req.app.get("io"); broadcastToCampaign(io, Number(character.campaign_id), "talent:removed", { characterId: Number(id), talentId: Number(talentId), }); res.status(204).end(); }); export default router;