Adds src/logic/rules.ts, counters, migration 1.4.0, III/IV mana swap, thaumaturgy school, tests, recompiled translations. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
238 lines
9.4 KiB
TypeScript
238 lines
9.4 KiB
TypeScript
/**
|
|
* Pre-cast validation gates. Runs before any resource deduction or roll.
|
|
* Returns structured ValidationResult; caller decides whether to block,
|
|
* warn, or proceed with bypass flags.
|
|
*/
|
|
|
|
import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
|
|
|
|
export interface ValidationIssue {
|
|
code: string;
|
|
message: string;
|
|
i18nKey?: string;
|
|
i18nArgs?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ValidationResult {
|
|
errors: ValidationIssue[]; // hard blocks
|
|
warnings: ValidationIssue[]; // soft, may be bypassed
|
|
ok: boolean; // errors.length === 0
|
|
}
|
|
|
|
function getActorRaceId(actor: CastableActor): string {
|
|
// Prefer embedded race item; fall back to details.raceId / details.race.
|
|
const raceItem = (actor as any).items?.find?.((i: any) => i.type === 'race');
|
|
if (raceItem) {
|
|
return String(raceItem.system?.raceId ?? raceItem.system?.id ?? raceItem.name ?? '').toLowerCase();
|
|
}
|
|
const d = (actor as any).system?.details ?? {};
|
|
return String(d.raceId ?? d.race ?? '').toLowerCase();
|
|
}
|
|
|
|
function actorHasTalent(actor: CastableActor, talentId: string): boolean {
|
|
const id = talentId.toLowerCase();
|
|
const items = (actor as any).items as Iterable<any> | undefined;
|
|
if (!items) return false;
|
|
for (const it of items) {
|
|
if (it.type !== 'talent') continue;
|
|
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
|
|
if (t === id) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function matchDiscipline(disciplineId: string, targetId: string): boolean {
|
|
const d1 = disciplineId.toLowerCase().trim();
|
|
const d2 = targetId.toLowerCase().trim();
|
|
if (d1 === d2) return true;
|
|
|
|
const mappings: Record<string, string[]> = {
|
|
crimson: ['crimson', 'magia szkarłatu', 'magia szkarłatnego kultu', 'crimson magic', 'crimson cult magic'],
|
|
abyssaspects: ['abyssaspects', 'magia aspektów', 'aspects', 'aspect magic', 'abyss - aspects', 'magia otchłani - aspekty'],
|
|
abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'],
|
|
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
|
|
blood: ['blood', 'magia krwi', 'blood magic'],
|
|
};
|
|
|
|
let key1: string | null = null;
|
|
let key2: string | null = null;
|
|
|
|
for (const [k, aliases] of Object.entries(mappings)) {
|
|
if (k === d1 || aliases.includes(d1)) key1 = k;
|
|
if (k === d2 || aliases.includes(d2)) key2 = k;
|
|
}
|
|
|
|
if (key1 && key2 && key1 === key2) return true;
|
|
return false;
|
|
}
|
|
|
|
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
|
|
const items = (actor as any).items as Iterable<any> | undefined;
|
|
if (items) {
|
|
for (const it of items) {
|
|
if (it.type !== 'discipline') continue;
|
|
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '');
|
|
if (matchDiscipline(d, disciplineId)) return true;
|
|
}
|
|
}
|
|
const detail = String((actor as any).system?.details?.discipline ?? '');
|
|
return matchDiscipline(detail, disciplineId);
|
|
}
|
|
|
|
function actorIsInCombat(actor: CastableActor): boolean {
|
|
const combat = (game as any).combat;
|
|
if (!combat?.started) return false;
|
|
return Boolean(combat.combatants?.find?.((c: any) => c.actorId === actor.id));
|
|
}
|
|
|
|
export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): ValidationResult {
|
|
const errors: ValidationIssue[] = [];
|
|
const warnings: ValidationIssue[] = [];
|
|
|
|
const sys = spell.system as any;
|
|
const a = (actor as any).system?.attributes ?? {};
|
|
const mode = sys.castingMode ?? 'standard';
|
|
|
|
// Resource gates
|
|
const baseMana = Math.max(0, Number(sys.manaCost ?? 0));
|
|
const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana));
|
|
const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0)));
|
|
|
|
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) {
|
|
if (manaSpent > Number(a.mana?.value ?? 0)) {
|
|
// Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many).
|
|
warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
|
|
}
|
|
}
|
|
if (opts.castAsPrayer) {
|
|
if (1 > Number(a.zeal?.value ?? 0)) {
|
|
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
|
|
}
|
|
} else if (mode === 'sacred' && opts.hekateMode !== 'witch') {
|
|
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
|
|
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
|
|
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
|
|
}
|
|
}
|
|
if (mode === 'blood') {
|
|
if (bloodSpent > Number(a.blood?.value ?? 0)) {
|
|
errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' });
|
|
}
|
|
}
|
|
|
|
// Race gate
|
|
const raceReq: string[] = Array.isArray(sys.requirements?.race) ? sys.requirements.race : [];
|
|
if (raceReq.length > 0) {
|
|
const actorRace = getActorRaceId(actor);
|
|
const ok = raceReq.some((r) => r.toLowerCase() === actorRace);
|
|
if (!ok) {
|
|
const translatedRaces = raceReq.map(r => {
|
|
const key = r.toLowerCase();
|
|
return game.i18n.has(`HBM.racesList.${key}`) ? game.i18n.localize(`HBM.racesList.${key}`) : r;
|
|
}).join(', ');
|
|
errors.push({
|
|
code: 'race-locked', message: `Spell requires race: ${raceReq.join(', ')}`,
|
|
i18nKey: 'HBM.spellCast.raceLocked', i18nArgs: { races: translatedRaces },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Talent gate
|
|
const talentReq: string[] = Array.isArray(sys.requirements?.talent) ? sys.requirements.talent : [];
|
|
for (const t of talentReq) {
|
|
if (!actorHasTalent(actor, t)) {
|
|
errors.push({
|
|
code: 'talent-missing', message: `Missing required talent: ${t}`,
|
|
i18nKey: 'HBM.spellCast.talentMissing', i18nArgs: { talent: t },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Discipline gate (any-of)
|
|
const discReq: string[] = Array.isArray(sys.requirements?.discipline) ? sys.requirements.discipline : [];
|
|
if (discReq.length > 0) {
|
|
const hasEldritchMagic = actorHasDiscipline(actor, 'abyssPrimal');
|
|
const hasAspectMagic = actorHasDiscipline(actor, 'abyssAspects');
|
|
const hasWitchMagic = actorHasDiscipline(actor, 'witch');
|
|
|
|
// Is the spell an Eldritch Spell?
|
|
const isEldritchSpell = discReq.some((d) => matchDiscipline(d, 'abyssPrimal')) ||
|
|
matchDiscipline(sys.school ?? '', 'abyssPrimal') ||
|
|
matchDiscipline(sys.discipline ?? '', 'abyssPrimal');
|
|
|
|
let bypassDisc = false;
|
|
if (hasEldritchMagic) {
|
|
bypassDisc = true; // Eldritch Magic allows casting ANY spells
|
|
} else if ((hasWitchMagic || hasAspectMagic) && !isEldritchSpell) {
|
|
bypassDisc = true; // Witch/Aspect Magic allows casting any spells EXCEPT Eldritch spells
|
|
}
|
|
|
|
const ok = bypassDisc || discReq.some((d) => actorHasDiscipline(actor, d));
|
|
if (!ok) {
|
|
const translatedDisciplines = discReq.map(d => {
|
|
return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d;
|
|
}).join(', ');
|
|
errors.push({
|
|
code: 'discipline-missing', message: `Requires one of disciplines: ${discReq.join(', ')}`,
|
|
i18nKey: 'HBM.spellCast.disciplineMissing', i18nArgs: { disciplines: translatedDisciplines },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Deity gate (sacred + non-common)
|
|
const deity = String(sys.deity ?? '').trim();
|
|
if ((sys.school === 'sacred' || mode === 'sacred') && deity && deity !== 'common') {
|
|
const actorDeity = String((actor as any).system?.details?.deity ?? '').trim();
|
|
if (actorDeity && actorDeity !== deity) {
|
|
warnings.push({
|
|
code: 'deity-mismatch',
|
|
message: `Spell deity (${deity}) differs from devoted deity (${actorDeity})`,
|
|
i18nKey: 'HBM.spellCast.deityMismatch', i18nArgs: { spell: deity, actor: actorDeity },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Group cast
|
|
if (sys.requiresGroupCast) {
|
|
const min = Math.max(1, Number(sys.minCasters ?? 1));
|
|
const provided = (opts.groupCasters?.length ?? 0) + 1;
|
|
if (provided < min) {
|
|
errors.push({
|
|
code: 'group-cast', message: `Requires ${min} co-casters; have ${provided}`,
|
|
i18nKey: 'HBM.spellCast.groupCastRequired', i18nArgs: { min, provided },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Witch symbol cap
|
|
if (mode === 'witch' || opts.hekateMode === 'witch') {
|
|
const required = Math.max(0, Number(sys.complexityLevel ?? sys.components?.symbols?.length ?? 0));
|
|
const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
|
|
if (required > maxSymbols) {
|
|
errors.push({
|
|
code: 'witch-symbols', message: `Symbols ${required}/${maxSymbols}`,
|
|
i18nKey: 'HBM.spellCast.witchSymbols', i18nArgs: { used: required, max: maxSymbols },
|
|
});
|
|
}
|
|
}
|
|
|
|
// Non-combat-only spells
|
|
if (sys.nonCombatOnly && actorIsInCombat(actor) && !opts.bypassNonCombatBlock) {
|
|
errors.push({
|
|
code: 'non-combat', message: 'Spell cannot be cast in combat',
|
|
i18nKey: 'HBM.spellCast.nonCombatOnly',
|
|
});
|
|
}
|
|
|
|
// Superspell warning (non-blocking)
|
|
if (sys.isSuperspell && actorIsInCombat(actor) && !opts.bypassSuperspellWarning) {
|
|
warnings.push({
|
|
code: 'superspell-combat',
|
|
message: 'Casting superspell during combat is generally inadvisable',
|
|
i18nKey: 'HBM.spellCast.superspellInCombat',
|
|
});
|
|
}
|
|
|
|
return { errors, warnings, ok: errors.length === 0 };
|
|
}
|