From 127d8c839173159b21c42f0636113570a16fc31f Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 11:36:02 -0400 Subject: [PATCH] feat: spell cast endpoint with mishap auto-apply and roll undo --- server/src/routes/characters.ts | 143 ++++++++++++++++++++++++++++++++ server/src/routes/rolls.ts | 43 +++++++++- 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/server/src/routes/characters.ts b/server/src/routes/characters.ts index bd83589..d1653f0 100644 --- a/server/src/routes/characters.ts +++ b/server/src/routes/characters.ts @@ -437,6 +437,149 @@ router.delete("/:id/talents/:talentId", async (req, res) => { } }); +const MISHAP_TABLE = [ + null, + { id: 1, description: "Devastation! Roll twice, combine both effects." }, + { id: 2, description: "Explosion! Take 1d8 damage.", mechanic: "damage_1d8" }, + { id: 3, description: "Refraction! The spell targets you instead.", mechanic: "narrative" }, + { id: 4, description: "Your hand slipped! The spell hits a random ally.", mechanic: "narrative" }, + { id: 5, description: "Mind wound! You cannot cast this spell again for a week.", mechanic: "lock_week" }, + { id: 6, description: "Discorporation! One random piece of gear disappears forever.", mechanic: "remove_gear" }, + { id: 7, description: "Spell worm! Lose a random spell each turn until you pass a DC 12 CON check.", mechanic: "condition", condition: "Spell Worm" }, + { id: 8, description: "Harmonic failure! Lose a random known spell until rest.", mechanic: "exhaust_random" }, + { id: 9, description: "Poof! All light suppressed within 30ft for 10 rounds.", mechanic: "condition", condition: "Light Suppressed" }, + { id: 10, description: "The horror! You scream uncontrollably for 3 rounds, drawing attention.", mechanic: "condition", condition: "Screaming" }, + { id: 11, description: "Energy surge! You glow bright purple for 10 rounds. Enemies have advantage on attacks.", mechanic: "condition", condition: "Glowing Purple" }, + { id: 12, description: "Unstable conduit! Disadvantage on casting same-tier spells for 10 rounds.", mechanic: "condition", condition: "Unstable Conduit" }, +] as const; + +async function applyWizardMishap(characterId: number, campaignId: number, changes: object[]): Promise { + const roll = Math.floor(Math.random() * 12) + 1; + const mishap = MISHAP_TABLE[roll]; + if (!mishap) return { roll, description: "Unknown mishap" }; + + if (roll === 1) { + const r1 = await applyWizardMishap(characterId, campaignId, changes); + const r2 = await applyWizardMishap(characterId, campaignId, changes); + return { roll: 1, description: (MISHAP_TABLE[1] as { description: string }).description, combined: [r1, r2] }; + } + + if ((mishap as { mechanic?: string }).mechanic === "damage_1d8") { + const dmg = Math.floor(Math.random() * 8) + 1; + const [charRows] = await db.execute("SELECT hp_current FROM characters WHERE id = ?", [characterId]); + const prevHp = (charRows[0] as { hp_current: number }).hp_current; + const newHp = Math.max(0, prevHp - dmg); + await db.execute("UPDATE characters SET hp_current = ? WHERE id = ?", [newHp, characterId]); + changes.push({ type: "hp_change", characterId, delta: -dmg, previous: prevHp }); + return { roll, description: `${(mishap as {description:string}).description} (${dmg} damage)`, damage: dmg }; + } + + if ((mishap as { mechanic?: string }).mechanic === "remove_gear") { + const [gearRows] = await db.execute("SELECT * FROM character_gear WHERE character_id = ? ORDER BY RAND() LIMIT 1", [characterId]); + if (gearRows.length > 0) { + const gear = gearRows[0]; + await db.execute("DELETE FROM character_gear WHERE id = ?", [gear.id]); + changes.push({ type: "gear_removed", gear }); + } + return { roll, description: (mishap as {description:string}).description }; + } + + if ((mishap as { mechanic?: string }).mechanic === "exhaust_random") { + const [spellRows] = await db.execute("SELECT id FROM character_spells WHERE character_id = ? AND exhausted = 0 ORDER BY RAND() LIMIT 1", [characterId]); + if (spellRows.length > 0) { + await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spellRows[0].id]); + changes.push({ type: "spell_exhausted", characterSpellId: spellRows[0].id }); + } + return { roll, description: (mishap as {description:string}).description }; + } + + if ((mishap as { mechanic?: string }).mechanic === "condition" && (mishap as { condition?: string }).condition) { + const [condResult] = await db.execute( + "INSERT INTO character_conditions (character_id, name, description) VALUES (?, ?, ?)", + [characterId, (mishap as {condition:string}).condition, (mishap as {description:string}).description] + ); + changes.push({ type: "condition_added", conditionId: condResult.insertId, name: (mishap as {condition:string}).condition }); + return { roll, description: (mishap as {description:string}).description }; + } + + return { roll, description: (mishap as {description:string}).description }; +} + +// POST /api/characters/:id/spells/:spellId/cast +router.post("/:id/spells/:spellId/cast", requireAuth, async (req, res, next) => { + try { + const characterId = Number(req.params.id); + const spellId = Number(req.params.spellId); + + const [spellRows] = await db.execute( + "SELECT s.*, cs.id as cs_id, cs.exhausted FROM spells s JOIN character_spells cs ON cs.spell_id = s.id WHERE s.id = ? AND cs.character_id = ?", + [spellId, characterId] + ); + if (spellRows.length === 0) return void res.status(404).json({ error: "Spell not known" }); + const spell = spellRows[0]; + if (spell.exhausted) return void res.status(400).json({ error: "Spell is exhausted" }); + + const [charRows] = await db.execute( + "SELECT c.id, c.campaign_id, c.name, c.class, c.color, cs2.value as stat_value FROM characters c JOIN character_stats cs2 ON cs2.character_id = c.id WHERE c.id = ? AND cs2.stat_name = ?", + [characterId, spell.casting_stat] + ); + if (charRows.length === 0) return void res.status(404).json({ error: "Character not found" }); + const character = charRows[0]; + const statMod = Math.floor((character.stat_value - 10) / 2); + + const roll = Math.floor(Math.random() * 20) + 1; + const total = roll + statMod; + const dc = 10 + spell.tier; + const isCritSuccess = roll === 20; + const isCritFail = roll === 1; + const isSuccess = isCritSuccess || (!isCritFail && total >= dc); + + let result: "success" | "failure" | "crit_success" | "crit_fail" = "success"; + if (isCritSuccess) result = "crit_success"; + else if (isCritFail) result = "crit_fail"; + else if (!isSuccess) result = "failure"; + + const changes: object[] = []; + + if (!isSuccess) { + await db.execute("UPDATE character_spells SET exhausted = 1 WHERE id = ?", [spell.cs_id]); + changes.push({ type: "spell_exhausted", characterSpellId: spell.cs_id }); + } + + let mishapResult: object | null = null; + if (isCritFail && character.class === "Wizard") { + mishapResult = await applyWizardMishap(characterId, character.campaign_id, changes); + } + + if (isCritFail && character.class === "Priest") { + changes.push({ type: "priest_penance", characterSpellId: spell.cs_id, tier: spell.tier }); + } + + const metadata = JSON.stringify({ spellId, spellName: spell.name, result, mishapResult, changes }); + const [logResult] = await db.execute( + "INSERT INTO roll_log (campaign_id, character_id, character_name, character_color, type, subtype, label, dice_expression, rolls, modifier, total, nat20, metadata) VALUES (?, ?, ?, ?, 'custom', 'spell_cast', ?, '1d20', ?, ?, ?, ?, ?)", + [character.campaign_id, characterId, character.name, character.color, `${spell.name} (Tier ${spell.tier})`, JSON.stringify([roll]), statMod, total, isCritSuccess ? 1 : 0, metadata] + ); + + const io = req.app.get("io"); + io.to(String(character.campaign_id)).emit("spell:cast", { + rollId: logResult.insertId, + characterId, + spellId, + spellName: spell.name, + roll, + modifier: statMod, + total, + dc, + result, + mishapResult, + changes, + }); + + res.json({ rollId: logResult.insertId, roll, modifier: statMod, total, dc, result, mishapResult }); + } catch (err) { next(err); } +}); + async function getCharacterCampaignId(id: number): Promise<{ campaign_id: number }> { const [rows] = await db.execute("SELECT campaign_id FROM characters WHERE id = ?", [id]); return rows[0] as { campaign_id: number }; diff --git a/server/src/routes/rolls.ts b/server/src/routes/rolls.ts index add2df7..7339c01 100644 --- a/server/src/routes/rolls.ts +++ b/server/src/routes/rolls.ts @@ -1,8 +1,9 @@ import { Router } from "express"; import type { ParamsDictionary } from "express-serve-static-core"; -import type { RowDataPacket } from "mysql2"; +import type { RowDataPacket, ExecuteValues } from "mysql2"; import db from "../db.js"; import { parseJson } from "../utils/parseJson.js"; +import { requireAuth } from "../auth/middleware.js"; type CampaignParams = ParamsDictionary & { campaignId: string }; @@ -30,4 +31,44 @@ router.get("/", async (req, res) => { } }); +// POST /api/campaigns/:campaignId/rolls/:rollId/undo +router.post("/:rollId/undo", requireAuth, async (req, res, next) => { + try { + const [rows] = await db.execute( + "SELECT * FROM roll_log WHERE id = ? AND campaign_id = ?", + [req.params.rollId, req.params.campaignId] + ); + if (rows.length === 0) return void res.status(404).json({ error: "Roll not found" }); + const entry = rows[0]; + if (entry.undone) return void res.status(400).json({ error: "Already undone" }); + if (!entry.metadata) return void res.status(400).json({ error: "Not undoable" }); + + const meta = JSON.parse(entry.metadata as string); + const changes: { type: string; [k: string]: unknown }[] = meta.changes ?? []; + + for (const change of [...changes].reverse()) { + if (change.type === "hp_change") { + await db.execute("UPDATE characters SET hp_current = ? WHERE id = ?", [change.previous, change.characterId] as ExecuteValues); + } else if (change.type === "gear_removed") { + const g = change.gear as Record; + await db.execute( + "INSERT INTO character_gear (id, character_id, name, type, slot_count, properties, effects, game_item_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [g.id, g.character_id, g.name, g.type, g.slot_count, g.properties, g.effects, g.game_item_id] as ExecuteValues + ); + } else if (change.type === "spell_exhausted") { + await db.execute("UPDATE character_spells SET exhausted = 0 WHERE id = ?", [change.characterSpellId] as ExecuteValues); + } else if (change.type === "condition_added") { + await db.execute("DELETE FROM character_conditions WHERE id = ?", [change.conditionId] as ExecuteValues); + } + } + + await db.execute("UPDATE roll_log SET undone = 1 WHERE id = ?", [entry.id]); + + const io = req.app.get("io"); + io.to(req.params.campaignId).emit("roll:undone", { rollId: entry.id, campaignId: req.params.campaignId }); + + res.json({ ok: true }); + } catch (err) { next(err); } +}); + export default router;