darkwatch/server/src/routes/characters.ts
Aaron Wood b88fa0cb3e Add luck token toggle, color picker, and click-to-edit ability scores
- Luck token: star toggle (filled/empty) in header, always clickable
- Color picker: input[type=color] next to name in edit mode
- Ability scores: InlineNumber click-to-edit replaces +/- buttons
- Server: added color and luck_token to allowed update fields
- DB: luck_token column migration (default 1)
- Fix dice color for hex color values (was only handling HSL)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:35:49 -04:00

388 lines
10 KiB
TypeScript

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<string, unknown> {
if (typeof val === "string") {
try {
return JSON.parse(val);
} catch {
return {};
}
}
return (val as Record<string, unknown>) ?? {};
}
function parseGear(rows: Array<Record<string, unknown>>) {
return rows.map((r) => ({
...r,
properties: parseJson(r.properties),
effects: parseJson(r.effects),
}));
}
function parseTalents(rows: Array<Record<string, unknown>>) {
return rows.map((r) => ({ ...r, effect: parseJson(r.effect) }));
}
// GET /api/campaigns/:campaignId/characters — list characters in a campaign
router.get<CampaignParams>("/", (req, res) => {
const { campaignId } = req.params;
const characters = db
.prepare("SELECT * FROM characters WHERE campaign_id = ? ORDER BY name")
.all(campaignId) as Array<Record<string, unknown>>;
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<Record<string, unknown>>),
talents: parseTalents(
stmtTalents.all(char.id) as Array<Record<string, unknown>>,
),
}));
res.json(enriched);
});
// POST /api/campaigns/:campaignId/characters — create a character
router.post<CampaignParams>("/", (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<string, unknown>),
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<string, unknown>;
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<string, unknown> | 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const talent = { ...talentRow, effect: parseJson(talentRow.effect) };
const character = db
.prepare("SELECT campaign_id FROM characters WHERE id = ?")
.get(id) as Record<string, unknown>;
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<string, unknown>;
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;