/** * Magia Otchłani - Abyss Magic logic (Klątwa Otchłani Ch. III–VIII). * * The Abyss splits into two disciplines: * - Magia Aspektów ("aspects") - controlled, low-risk, predictable * - Pierwotna Magia ("primal") - chaotic, high-power, requires `Dary Otchłani` rolls * * This module exposes: * - dispatchAbyssCast(spell) - returns 'aspects' | 'primal' from spell.discipline. * - rollAbyssGift(actor) - d100 against a roll table (resolved at runtime * from the `roll-tables-abyss.dary-otchlani` pack * when present; otherwise falls back to a chat * prompt for the GM). * - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII). * - addInsanity(actor, n) - increments actor.system.attributes.insanity; * when crossing a threshold, fires a hook for * the GM to apply a mental condition. */ import type { CastableActor } from './spell-cast'; export type AbyssDiscipline = 'aspects' | 'primal'; const PRIMAL_KEYS = new Set(['primal', 'pierwotna-magia', 'pierwotna_magia', 'magia-otchlani-pierwotna']); interface SpellLikeForAbyss { system?: { school?: string; discipline?: string | string[]; requirements?: { discipline?: string[] }; }; } export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline { const disc = spell.system?.discipline ?? spell.system?.requirements?.discipline ?? []; const list = (Array.isArray(disc) ? disc : [disc]).map((s) => String(s).toLowerCase()); for (const d of list) { if (PRIMAL_KEYS.has(d)) return 'primal'; } return 'aspects'; } /** Insanity thresholds (Klątwa Otchłani VIII). Crossing any triggers a mental-condition roll. */ const INSANITY_THRESHOLDS = [3, 6, 10, 15] as const; export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> { if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] }; const before = ((actor.system.attributes as any).insanity ?? 0) as number; const after = before + n; await actor.update({ 'system.attributes.insanity': after }); const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t); if (crossed.length > 0) { Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, after }); } return { before, after, thresholdsCrossed: [...crossed] }; } /** Roll d100 against a named table inside the abyss roll-tables pack. */ export async function rollAbyssGift(actor: CastableActor, tableName = 'dary-otchlani'): Promise { return rollAgainstAbyssTable(actor, tableName, 'Dary Otchłani'); } export async function rollMistrzLosuPenalty(actor: CastableActor): Promise { return rollAgainstAbyssTable(actor, 'mistrz-losu', 'Mistrz Losu'); } async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor: string): Promise { const roll = new Roll('1d100'); await roll.evaluate(); const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor }); await roll.toMessage({ flavor: `${flavor} - ${actor.name} (${slug})`, speaker, }); Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total); return roll; }