1fba0892b9
- talent-parser.ts, discipline-parser.ts, race-parser.ts: rebuilt to read from the vault's post-bab5a62 per-topic-file layout (rulebook monoliths were split into per-chapter subfolders; discipline/race writeups moved to their canonical disciplines/ and races/ folders). All three parsers were previously producing zero or broken output for their packs. - constants.ts, actor-character.ts, actor-npc.ts, rest.ts: mana is now a daily pool (Poziom Mocy x3) restored via rest tiers, not a per-round budget; Zywotnosc is 2x(Cialo+Umysl+Dusza), not 3x; rest recovery amounts updated to match Odetchniecie/Krotki Odpoczynek/Dlugi Odpoczynek. - combat.ts: removed the now-obsolete per-round mana reset hook. - actor-npc.ts: Initiative now includes Refleks/Spostrzegawczosc skill bonuses (was missing entirely); Magia attribute constrained to valid 1k10 character-creation values. - fields.ts: added `choices` support to makeIntField for the above. - spell-cast.ts: overcast test now uses the correct T-threshold tiers and triangular S = E*(E+1)/2 required-successes formula (Zasada Nadczarowywania Zaklec). Not implemented (flagged as new features, out of scope for a sync fix): mana-exhaustion/overcast-failure penalty tables, overcast bonus taper past step 2, shield-breaking damage mechanic, elixir-weapon-coating system. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
368 lines
14 KiB
TypeScript
368 lines
14 KiB
TypeScript
/**
|
|
* Spell casting workflow - handles standard, sacred (Magia Sakralna),
|
|
* witch (Wiedźmia Magia), and blood (Magia Krwi) modes.
|
|
*
|
|
* Pipeline per cast:
|
|
* 1. validateCast() - gate on resources, race, talent, discipline,
|
|
* deity, group-cast, in-combat, witch symbols
|
|
* 2. Resource deduction - mana / zeal / blood
|
|
* 3. TS test roll - pool depends on mode
|
|
* 4. (success) damage roll - parses spell.damageBase
|
|
* 5. Status effect auto-apply - from spell.statusEffects[]
|
|
* 6. Trigger registration - from spell.triggers[]
|
|
*
|
|
* Bypasses for GM workflows: opts.bypassSuperspellWarning, opts.bypassNonCombatBlock.
|
|
*/
|
|
|
|
import { HbmTSRoll } from '../dice/ts-roll';
|
|
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
|
|
import { validateCast } from './spell-validation';
|
|
import type { ValidationResult } from './spell-validation';
|
|
import { rollSpellDamage } from './spell-damage';
|
|
import { registerSpellTriggers } from './spell-triggers';
|
|
import { CONDITIONS } from './conditions';
|
|
|
|
export interface CastableActor {
|
|
id: string;
|
|
name: string;
|
|
type: 'character' | 'npc';
|
|
system: {
|
|
attributes: {
|
|
magic: { value: number; actual?: number; dicePool?: number };
|
|
soul: { value: number };
|
|
mana: { value: number; max: number; maxPerSpell: number };
|
|
zeal: { value: number; max: number };
|
|
blood?: { value: number; max: number };
|
|
};
|
|
skills?: Record<string, { value: number }>;
|
|
};
|
|
update: (changes: Record<string, unknown>) => Promise<unknown>;
|
|
}
|
|
|
|
export interface SpellLike {
|
|
id?: string;
|
|
name: string;
|
|
system: {
|
|
castingMode: 'standard' | 'sacred' | 'witch' | 'blood';
|
|
school?: string;
|
|
deity?: string;
|
|
manaCost: number;
|
|
bloodCost?: number;
|
|
complexityLevel: number;
|
|
isSuperspell?: boolean;
|
|
requiresGroupCast?: boolean;
|
|
minCasters?: number;
|
|
nonCombatOnly?: boolean;
|
|
damageBase?: string;
|
|
damageType?: string;
|
|
ignoresArmor?: boolean;
|
|
statusEffects?: string[];
|
|
saveAttribute?: string;
|
|
saveSkill?: string;
|
|
triggers?: Array<{ event: string; effect: string }>;
|
|
components?: { symbols?: string[] };
|
|
requirements?: { race?: string[]; talent?: string[]; discipline?: string[] };
|
|
difficulty: { threshold: number; successes: number };
|
|
};
|
|
}
|
|
|
|
export interface CastOptions {
|
|
manaSpent?: number;
|
|
zealSpent?: number;
|
|
bloodSpent?: number;
|
|
hekateMode?: 'sacred' | 'witch';
|
|
castAsPrayer?: boolean;
|
|
bypassSuperspellWarning?: boolean;
|
|
bypassNonCombatBlock?: boolean;
|
|
groupCasters?: string[];
|
|
/** Override required successes (used by variableSuccesses summons). */
|
|
requiredOverride?: number;
|
|
speaker?: ChatMessage.SpeakerData;
|
|
}
|
|
|
|
export interface CastResult {
|
|
validation: ValidationResult;
|
|
baseRoll: HbmTSRoll | null;
|
|
damageRoll: Roll | null;
|
|
triggersRegistered: number;
|
|
}
|
|
|
|
function notify(text: string, severity: 'warn' | 'error' | 'info' = 'info'): void {
|
|
const u = ui as unknown as { notifications?: { warn: (s: string) => void; error: (s: string) => void; info: (s: string) => void } };
|
|
u.notifications?.[severity](text);
|
|
}
|
|
|
|
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
|
|
// 1. Validate
|
|
const validation = validateCast(actor, spell, opts);
|
|
if (!validation.ok) {
|
|
for (const err of validation.errors) {
|
|
notify(err.i18nKey ? game.i18n.format(err.i18nKey, (err.i18nArgs ?? {}) as Record<string, string>) : err.message, 'warn');
|
|
}
|
|
return { validation, baseRoll: null, damageRoll: null, triggersRegistered: 0 };
|
|
}
|
|
for (const w of validation.warnings) {
|
|
notify(w.i18nKey ? game.i18n.format(w.i18nKey, (w.i18nArgs ?? {}) as Record<string, string>) : w.message, 'info');
|
|
}
|
|
|
|
// 2-3. Mode dispatch
|
|
const mode = spell.system.castingMode ?? 'standard';
|
|
let baseRoll: HbmTSRoll | null = null;
|
|
if (mode === 'sacred') {
|
|
baseRoll = opts.hekateMode === 'witch'
|
|
? await castWitch(actor, spell, opts)
|
|
: await castSacred(actor, spell, opts);
|
|
} else if (mode === 'witch') {
|
|
baseRoll = await castWitch(actor, spell, opts);
|
|
} else if (mode === 'blood') {
|
|
baseRoll = await castBlood(actor, spell, opts);
|
|
} else {
|
|
baseRoll = await castStandard(actor, spell, opts);
|
|
}
|
|
|
|
let damageRoll: Roll | null = null;
|
|
let triggersRegistered = 0;
|
|
|
|
if (baseRoll?.ts?.isSuccess) {
|
|
// 4. Damage
|
|
if (spell.system.damageBase) {
|
|
damageRoll = await rollSpellDamage(spell.system.damageBase, { actor, spellName: spell.name });
|
|
}
|
|
|
|
// 6. Triggers
|
|
triggersRegistered = await registerSpellTriggers(actor as any, spell);
|
|
}
|
|
|
|
// Render unified rich chat card (Phase 1.6)
|
|
await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered);
|
|
|
|
return { validation, baseRoll, damageRoll, triggersRegistered };
|
|
}
|
|
|
|
async function renderCastCard(
|
|
actor: CastableActor,
|
|
spell: SpellLike,
|
|
opts: CastOptions,
|
|
baseRoll: HbmTSRoll | null,
|
|
damageRoll: Roll | null,
|
|
triggersRegistered: number,
|
|
): Promise<void> {
|
|
const success = baseRoll?.ts?.isSuccess ?? false;
|
|
const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard');
|
|
|
|
// Costs ledger
|
|
const costs: Array<{ label: string; amount: number }> = [];
|
|
if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! });
|
|
if (opts.castAsPrayer) {
|
|
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: 1 });
|
|
} else if ((opts.zealSpent ?? 0) > 0) {
|
|
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
|
|
}
|
|
if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! });
|
|
|
|
// Damage block
|
|
let damage: Record<string, unknown> | null = null;
|
|
if (success && damageRoll) {
|
|
const targets: Array<{ uuid: string; name: string }> = [];
|
|
const userTargets = (game.user as any)?.targets;
|
|
if (userTargets && typeof userTargets[Symbol.iterator] === 'function') {
|
|
for (const t of userTargets) {
|
|
if (t?.actor?.uuid) targets.push({ uuid: t.actor.uuid, name: t.actor.name ?? t.name });
|
|
}
|
|
}
|
|
damage = {
|
|
total: damageRoll.total,
|
|
formula: damageRoll.formula,
|
|
type: spell.system.damageType ?? 'magical',
|
|
ignoresArmor: !!spell.system.ignoresArmor,
|
|
targets: targets.length > 0 ? targets : null,
|
|
};
|
|
}
|
|
|
|
// Status effects (with apply buttons)
|
|
const statusEffects: Array<{ id: string; label: string }> = [];
|
|
if (success && Array.isArray(spell.system.statusEffects)) {
|
|
for (const id of spell.system.statusEffects) {
|
|
const def = CONDITIONS.find((c) => c.id === id);
|
|
const label = def ? game.i18n.localize(def.i18nKey) : id;
|
|
statusEffects.push({ id, label });
|
|
}
|
|
}
|
|
|
|
// Save line
|
|
const save = success && spell.system.saveAttribute
|
|
? { attribute: spell.system.saveAttribute, skill: spell.system.saveSkill ?? '' }
|
|
: null;
|
|
|
|
const triggers = success && triggersRegistered > 0 && Array.isArray(spell.system.triggers)
|
|
? spell.system.triggers
|
|
: [];
|
|
|
|
const data = {
|
|
spell: { name: spell.name },
|
|
mode,
|
|
school: spell.system.school ?? '',
|
|
circle: (spell.system as any).circle ?? 0,
|
|
outcome: { success },
|
|
costs,
|
|
damage,
|
|
save,
|
|
statusEffects,
|
|
triggers,
|
|
description: (spell.system as any).description ?? '',
|
|
};
|
|
|
|
const html = await renderTemplate('systems/hbm-rpg-v3/templates/chat/spell-cast.hbs', data);
|
|
await ChatMessage.create({
|
|
content: html,
|
|
speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }),
|
|
flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } },
|
|
});
|
|
}
|
|
|
|
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
|
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
|
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
|
|
const a = actor.system.attributes;
|
|
|
|
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - manaSpent };
|
|
if (opts.castAsPrayer) {
|
|
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
|
|
}
|
|
await actor.update(updates);
|
|
|
|
const magicDice = typeof a.magic.dicePool === 'number'
|
|
? a.magic.dicePool
|
|
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
|
|
const pool = opts.castAsPrayer
|
|
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
|
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
|
const flavor = opts.castAsPrayer
|
|
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
|
|
: `${spell.name}`;
|
|
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
|
const baseRoll = HbmTSRoll.fromParams({
|
|
pool,
|
|
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
|
required,
|
|
flavor,
|
|
});
|
|
await baseRoll.evaluate();
|
|
await baseRoll.toMessage({ flavor, speaker: opts.speaker });
|
|
|
|
// Overcast beyond Maksymalny Koszt Zaklęcia - extra TS test.
|
|
// T depends on the spell's BASE cost; S grows triangularly with the excess
|
|
// (E) over maxPerSpell: S = E * (E + 1) / 2 (Zasada Nadczarowywania Zaklęć).
|
|
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
|
|
const excess = manaSpent - a.mana.maxPerSpell;
|
|
const tThreshold = baseCost <= 2 ? 4 : baseCost <= 4 ? 5 : 6;
|
|
const ySuccesses = (excess * (excess + 1)) / 2;
|
|
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
|
|
const overcastRoll = HbmTSRoll.fromParams({
|
|
pool,
|
|
threshold: tThreshold,
|
|
required: ySuccesses,
|
|
flavor: overcastFlavor,
|
|
});
|
|
await overcastRoll.evaluate();
|
|
await overcastRoll.toMessage({ flavor: overcastFlavor, speaker: opts.speaker });
|
|
}
|
|
|
|
return baseRoll;
|
|
}
|
|
|
|
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
|
const a = actor.system.attributes;
|
|
const zealCost = Math.max(1, opts.zealSpent ?? 1);
|
|
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
|
|
|
|
const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
|
|
const flavor = `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
|
|
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
|
const roll = HbmTSRoll.fromParams({
|
|
pool,
|
|
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
|
required,
|
|
flavor,
|
|
});
|
|
await roll.evaluate();
|
|
await roll.toMessage({ flavor, speaker: opts.speaker });
|
|
return roll;
|
|
}
|
|
|
|
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
|
const a = actor.system.attributes;
|
|
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
|
|
const maxSymbols = Math.ceil(a.magic.actual / 2);
|
|
|
|
// Witch magic still has a mana cost.
|
|
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
|
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
|
|
if (opts.castAsPrayer) {
|
|
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
|
|
}
|
|
await actor.update(updates);
|
|
|
|
const magicDice = typeof (a.magic as any).dicePool === 'number'
|
|
? (a.magic as any).dicePool
|
|
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
|
|
const pool = opts.castAsPrayer
|
|
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
|
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
|
const flavor = opts.castAsPrayer
|
|
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
|
|
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
|
|
const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
|
const roll = HbmTSRoll.fromParams({
|
|
pool,
|
|
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
|
required: reqSuccesses,
|
|
flavor,
|
|
});
|
|
await roll.evaluate();
|
|
await roll.toMessage({ flavor, speaker: opts.speaker });
|
|
return roll;
|
|
}
|
|
|
|
async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
|
|
const a = actor.system.attributes;
|
|
const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
|
|
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
|
|
const blood = a.blood;
|
|
|
|
const updates: Record<string, any> = {};
|
|
if (blood) {
|
|
updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
|
|
}
|
|
|
|
// Mana cost (some blood spells may also have one)
|
|
const manaCost = Math.max(0, spell.system.manaCost ?? 0);
|
|
if (manaCost > 0) {
|
|
updates['system.attributes.mana.value'] = a.mana.value - manaCost;
|
|
}
|
|
if (opts.castAsPrayer) {
|
|
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
|
|
}
|
|
await actor.update(updates);
|
|
|
|
const magicDice = typeof (a.magic as any).dicePool === 'number'
|
|
? (a.magic as any).dicePool
|
|
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
|
|
const pool = opts.castAsPrayer
|
|
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
|
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
|
const flavor = opts.castAsPrayer
|
|
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
|
|
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
|
|
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
|
const roll = HbmTSRoll.fromParams({
|
|
pool,
|
|
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
|
required,
|
|
flavor,
|
|
});
|
|
await roll.evaluate();
|
|
await roll.toMessage({ flavor, speaker: opts.speaker });
|
|
return roll;
|
|
}
|