feat: spell cast endpoint with mishap auto-apply and roll undo
This commit is contained in:
parent
ff7f22d77b
commit
127d8c8391
2 changed files with 185 additions and 1 deletions
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue