Initial commit

This commit is contained in:
Octoturge
2026-06-09 22:06:56 +02:00
commit fb42c6e8cc
121 changed files with 14976 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
/**
* 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 actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
const id = disciplineId.toLowerCase();
const items = (actor as any).items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'discipline') continue;
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (d === id) return true;
}
// Also accept characters whose details.discipline matches
const detail = String((actor as any).system?.details?.discipline ?? '').toLowerCase();
return detail === id;
}
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)) {
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
}
}
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 ok = 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 };
}