feat: spell cast endpoint with mishap auto-apply and roll undo

This commit is contained in:
Aaron Wood 2026-04-11 11:36:02 -04:00
parent ff7f22d77b
commit 127d8c8391
2 changed files with 185 additions and 1 deletions

View file

@ -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<object> {
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<RowDataPacket[]>("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<RowDataPacket[]>("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<RowDataPacket[]>("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<import("mysql2").ResultSetHeader>(
"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<RowDataPacket[]>(
"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<RowDataPacket[]>(
"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<import("mysql2").ResultSetHeader>(
"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 }> { async function getCharacterCampaignId(id: number): Promise<{ campaign_id: number }> {
const [rows] = await db.execute<RowDataPacket[]>("SELECT campaign_id FROM characters WHERE id = ?", [id]); const [rows] = await db.execute<RowDataPacket[]>("SELECT campaign_id FROM characters WHERE id = ?", [id]);
return rows[0] as { campaign_id: number }; return rows[0] as { campaign_id: number };

View file

@ -1,8 +1,9 @@
import { Router } from "express"; import { Router } from "express";
import type { ParamsDictionary } from "express-serve-static-core"; 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 db from "../db.js";
import { parseJson } from "../utils/parseJson.js"; import { parseJson } from "../utils/parseJson.js";
import { requireAuth } from "../auth/middleware.js";
type CampaignParams = ParamsDictionary & { campaignId: string }; type CampaignParams = ParamsDictionary & { campaignId: string };
@ -30,4 +31,44 @@ router.get<CampaignParams>("/", 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<RowDataPacket[]>(
"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<string, unknown>;
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; export default router;