Initial commit
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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<Roll> {
|
||||
return rollAgainstAbyssTable(actor, tableName, 'Dary Otchłani');
|
||||
}
|
||||
|
||||
export async function rollMistrzLosuPenalty(actor: CastableActor): Promise<Roll> {
|
||||
return rollAgainstAbyssTable(actor, 'mistrz-losu', 'Mistrz Losu');
|
||||
}
|
||||
|
||||
async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor: string): Promise<Roll> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Magia Krwi — Blood Magic logic (Arcanum Sanguinis Ch. III).
|
||||
*
|
||||
* Three primitives:
|
||||
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
|
||||
* - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood).
|
||||
* - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell,
|
||||
* restore blood = floor(damage / 4) (capped at max).
|
||||
*
|
||||
* Future hook: `Szacunek do Życia` talent triples self-harm cost (6 HP → 1 Blood).
|
||||
*
|
||||
* The cast pipeline (logic/spell-cast.ts) already deducts `bloodCost` directly;
|
||||
* this module is the reusable API surface for UI dialogs and the damage hook.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
const SELF_HARM_RATIO = 2; // HP per 1 Blood
|
||||
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present
|
||||
const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored
|
||||
|
||||
interface ActorWithItems extends CastableActor {
|
||||
items?: Iterable<{ type: string; system?: { slug?: string } }>;
|
||||
}
|
||||
|
||||
function hasRespectForLife(actor: ActorWithItems): boolean {
|
||||
if (!actor.items) return false;
|
||||
for (const it of actor.items) {
|
||||
if (it.type !== 'talent') continue;
|
||||
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug;
|
||||
if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface BloodSpendResult {
|
||||
ok: boolean;
|
||||
spent: number;
|
||||
remaining: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Deduct `amount` from `actor.system.attributes.blood.value`. */
|
||||
export async function spendBlood(actor: CastableActor, amount: number): Promise<BloodSpendResult> {
|
||||
const blood = actor.system.attributes.blood;
|
||||
if (!blood) return { ok: false, spent: 0, remaining: 0, reason: 'no-blood-pool' };
|
||||
if (amount <= 0) return { ok: true, spent: 0, remaining: blood.value };
|
||||
if (blood.value < amount) {
|
||||
return { ok: false, spent: 0, remaining: blood.value, reason: 'insufficient-blood' };
|
||||
}
|
||||
await actor.update({ 'system.attributes.blood.value': blood.value - amount });
|
||||
return { ok: true, spent: amount, remaining: blood.value - amount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-Harm: convert health into blood. Returns blood gained.
|
||||
* Default ratio 2 HP → 1 Blood; with `Szacunek do Życia` talent → 6 HP → 1 Blood.
|
||||
*/
|
||||
export async function selfHarm(actor: CastableActor, hpSpent: number): Promise<{ ok: boolean; bloodGained: number; reason?: string }> {
|
||||
if (hpSpent <= 0) return { ok: true, bloodGained: 0 };
|
||||
const a = actor.system.attributes;
|
||||
const blood = a.blood;
|
||||
if (!blood) return { ok: false, bloodGained: 0, reason: 'no-blood-pool' };
|
||||
const health = (a as any).health as { value: number; max: number } | undefined;
|
||||
if (!health || health.value < hpSpent) {
|
||||
return { ok: false, bloodGained: 0, reason: 'insufficient-health' };
|
||||
}
|
||||
const ratio = hasRespectForLife(actor as ActorWithItems) ? SELF_HARM_RATIO * RESPECT_FOR_LIFE_PENALTY : SELF_HARM_RATIO;
|
||||
const gained = Math.floor(hpSpent / ratio);
|
||||
if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' };
|
||||
const newBlood = Math.min(blood.max, blood.value + gained);
|
||||
await actor.update({
|
||||
'system.attributes.health.value': health.value - hpSpent,
|
||||
'system.attributes.blood.value': newBlood,
|
||||
});
|
||||
return { ok: true, bloodGained: newBlood - blood.value };
|
||||
}
|
||||
|
||||
/** Life Steal: invoked from the damage-application hook when the source is a blood spell. */
|
||||
export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise<number> {
|
||||
if (damageDealt <= 0) return 0;
|
||||
const blood = actor.system.attributes.blood;
|
||||
if (!blood) return 0;
|
||||
const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR);
|
||||
if (restore <= 0) return 0;
|
||||
const newBlood = Math.min(blood.max, blood.value + restore);
|
||||
await actor.update({ 'system.attributes.blood.value': newBlood });
|
||||
return newBlood - blood.value;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Warzenie Eliksirów — Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
|
||||
*
|
||||
* Each character has `attributes.elixirTolerance` (default 0). Soft cap = body+1.
|
||||
* Drinking a potion increments the counter; exceeding the cap triggers a
|
||||
* poisoning hook (`hbm.elixirOverdose`). Long rest restores 1 tolerance
|
||||
* (handled by rest.ts); the `Nadzwyczajna Odporność` discipline-passive lets
|
||||
* a Short Rest restore 1 instead.
|
||||
*
|
||||
* Brewing a potion: TS test `Magic + Brewing skill` against the recipe's
|
||||
* difficulty. Caller passes the recipe (`{ name, difficulty, ingredients[] }`).
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface ElixirRecipe {
|
||||
name: string;
|
||||
/** TS test target — { threshold, successes }. */
|
||||
difficulty: { threshold: number; successes: number };
|
||||
/** Ingredient names (free-form). */
|
||||
ingredients: string[];
|
||||
/** Discipline skill key used for the brew test (default: alchemyBrewing). */
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
export function elixirToleranceCap(actor: CastableActor): number {
|
||||
const body = actor.system.attributes.body?.value ?? 1;
|
||||
return body + 1;
|
||||
}
|
||||
|
||||
/** Drink a potion: increment tolerance, fire `hbm.elixirOverdose` on overflow. */
|
||||
export async function consumeElixir(actor: CastableActor, recipe: Pick<ElixirRecipe, 'name'>): Promise<{ tolerance: number; overdose: boolean }> {
|
||||
const cur = ((actor.system.attributes as any).elixirTolerance ?? 0) as number;
|
||||
const next = cur + 1;
|
||||
await actor.update({ 'system.attributes.elixirTolerance': next });
|
||||
const overdose = next > elixirToleranceCap(actor);
|
||||
if (overdose) Hooks.callAll('hbm.elixirOverdose', actor, recipe.name, next);
|
||||
return { tolerance: next, overdose };
|
||||
}
|
||||
|
||||
/** Brewing TS test: pool = magic + skill (default `alchemyBrewing`). */
|
||||
export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Promise<{ roll: HbmTSRoll; success: boolean }> {
|
||||
const skillKey = recipe.skill ?? 'alchemyBrewing';
|
||||
const skillValue = actor.system.skills?.[skillKey]?.value ?? 0;
|
||||
const pool = actor.system.attributes.magic.actual + skillValue;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: recipe.difficulty.successes ?? TS_DEFAULT_REQUIRED,
|
||||
flavor: `Warzenie: ${recipe.name}`,
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({
|
||||
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '—'})`,
|
||||
});
|
||||
return { roll, success: !!roll.ts?.isSuccess };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Click handlers for the rich spell-cast chat card.
|
||||
*
|
||||
* Wires:
|
||||
* - `data-action="hbm-apply-status"` → toggle/add a CONFIG.statusEffects entry
|
||||
* on every selected/targeted token of the current user.
|
||||
* - `data-action="hbm-apply-damage"` → run the system damage pipeline against
|
||||
* the actor identified by `data-target-uuid`.
|
||||
*/
|
||||
|
||||
import { applyDamage } from './damage';
|
||||
import { CONDITIONS } from './conditions';
|
||||
|
||||
declare const Hooks: any;
|
||||
declare const fromUuid: <T = unknown>(uuid: string) => Promise<T | null>;
|
||||
declare const game: any;
|
||||
declare const canvas: any;
|
||||
declare const ui: any;
|
||||
|
||||
export function registerChatCardHooks(): void {
|
||||
Hooks.on('renderChatMessageHTML', (_msg: unknown, html: HTMLElement | JQuery) => {
|
||||
const root = (html as any)?.jquery ? (html as any)[0] : (html as HTMLElement);
|
||||
if (!root) return;
|
||||
root.querySelectorAll<HTMLElement>('button.hbm-action[data-action="hbm-apply-status"]').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
void onApplyStatus(btn);
|
||||
});
|
||||
});
|
||||
root.querySelectorAll<HTMLElement>('button.hbm-action[data-action="hbm-apply-damage"]').forEach((btn) => {
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
void onApplyDamage(btn);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onApplyStatus(btn: HTMLElement): Promise<void> {
|
||||
const id = btn.dataset.effectId;
|
||||
if (!id) return;
|
||||
const def = CONDITIONS.find((c) => c.id === id);
|
||||
if (!def) {
|
||||
ui.notifications?.warn(`Unknown condition: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = collectTargetTokens();
|
||||
if (tokens.length === 0) {
|
||||
ui.notifications?.warn(game.i18n?.localize?.('HBM.spellCast.noTargets') ?? 'No targets selected.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
const actor = token.actor;
|
||||
if (!actor) continue;
|
||||
if (typeof actor.toggleStatusEffect === 'function') {
|
||||
await actor.toggleStatusEffect(id, { active: true });
|
||||
}
|
||||
}
|
||||
ui.notifications?.info(`${game.i18n?.localize?.(def.i18nKey) ?? id} → ${tokens.length}`);
|
||||
}
|
||||
|
||||
async function onApplyDamage(btn: HTMLElement): Promise<void> {
|
||||
const uuid = btn.dataset.targetUuid;
|
||||
const amount = Number(btn.dataset.amount ?? 0);
|
||||
if (!uuid || !Number.isFinite(amount) || amount <= 0) return;
|
||||
const target = await fromUuid<any>(uuid);
|
||||
if (!target) {
|
||||
ui.notifications?.warn(`Target not found: ${uuid}`);
|
||||
return;
|
||||
}
|
||||
const ignoresArmor = btn.dataset.ignoresArmor === 'true';
|
||||
await applyDamage(target, {
|
||||
amount,
|
||||
type: (btn.dataset.type as any) ?? 'magical',
|
||||
ignoreMagicalArmor: ignoresArmor,
|
||||
ignoreMagicalShield: ignoresArmor,
|
||||
ignorePhysicalArmor: ignoresArmor,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function collectTargetTokens(): any[] {
|
||||
const tokens: any[] = [];
|
||||
const targets = game.user?.targets;
|
||||
if (targets && typeof targets[Symbol.iterator] === 'function') {
|
||||
for (const t of targets) tokens.push(t);
|
||||
}
|
||||
if (tokens.length === 0 && canvas?.tokens?.controlled?.length) {
|
||||
tokens.push(...canvas.tokens.controlled);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Combat hooks — handle per-round Mana reset, per-turn Zeal regen,
|
||||
* and condition-driven turn behavior (skip / damage tick / death save).
|
||||
*/
|
||||
|
||||
import { applyDamage } from './damage';
|
||||
|
||||
function hasStatus(actor: any, id: string): boolean {
|
||||
const effects = actor?.effects ?? [];
|
||||
for (const ef of effects) {
|
||||
if (ef?.disabled) continue;
|
||||
const statuses = ef?.statuses;
|
||||
if (statuses && typeof statuses.has === 'function' ? statuses.has(id) : Array.isArray(statuses) && statuses.includes(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function registerCombatHooks(): void {
|
||||
Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
|
||||
for (const combatant of combat.combatants) {
|
||||
const actor = combatant.actor;
|
||||
if (!actor || actor.type !== 'character') continue;
|
||||
const sys = actor.system as { attributes: { mana: { max: number; value: number } } };
|
||||
await actor.update({ 'system.attributes.mana.value': sys.attributes.mana.max });
|
||||
}
|
||||
ChatMessage.create({
|
||||
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
|
||||
whisper: ChatMessage.getWhisperRecipients('GM'),
|
||||
});
|
||||
});
|
||||
|
||||
Hooks.on('combatTurn', async (combat: Combat) => {
|
||||
const combatant = combat.combatant;
|
||||
const actor = combatant?.actor;
|
||||
if (!actor) return;
|
||||
|
||||
// Skip turn for unconscious / restrained-to-incapacitation
|
||||
if (hasStatus(actor, 'nieprzytomny') || hasStatus(actor, 'obezwladniony')) {
|
||||
ChatMessage.create({
|
||||
content: `<em>${actor.name}: ${game.i18n.localize('HBM.combat.turnSkipped')}</em>`,
|
||||
});
|
||||
try { await (combat as any).nextTurn?.(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Burning: tick environmental damage
|
||||
if (hasStatus(actor, 'podpalony')) {
|
||||
await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true });
|
||||
}
|
||||
|
||||
// Dying: prompt death save (simplified — posts a chat reminder)
|
||||
if (hasStatus(actor, 'umierajacy')) {
|
||||
ChatMessage.create({
|
||||
content: `<strong>${actor.name}</strong>: ${game.i18n.localize('HBM.combat.deathSavePrompt')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-turn Zeal regen (characters) — base 1 + talent bonus from flag.
|
||||
if (actor.type === 'character') {
|
||||
const sys = actor.system as { attributes: { zeal: { value: number; max: number } } };
|
||||
const regen = getZealRegen(actor);
|
||||
const next = Math.min(sys.attributes.zeal.max, sys.attributes.zeal.value + regen);
|
||||
if (next !== sys.attributes.zeal.value) {
|
||||
await actor.update({ 'system.attributes.zeal.value': next });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeal regenerated at the start of an actor's turn.
|
||||
* Defaults to 1; talents may add bonus via `flags['hbm-rpg-v3'].zealRegenBonus`
|
||||
* (typically set by an ActiveEffect with mode ADD targeting that flag path).
|
||||
*/
|
||||
export function getZealRegen(actor: any): number {
|
||||
const base = 1;
|
||||
const bonus = Number(actor?.getFlag?.('hbm-rpg-v3', 'zealRegenBonus') ?? 0) || 0;
|
||||
return Math.max(0, base + bonus);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* HbM canonical status conditions (Aneks A — Podręcznik Gry).
|
||||
* 15 entries with Active-Effect change ops and `flags.hbm.*` metadata
|
||||
* consumed by the roll pipeline and combat hooks.
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
|
||||
export interface ConditionDef {
|
||||
id: string;
|
||||
i18nKey: string;
|
||||
icon: string;
|
||||
changes?: Array<{ key: string; mode: number; value: string; priority?: number }>;
|
||||
flags?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CHANGE_MODE_ADD = 2;
|
||||
|
||||
export const CONDITIONS: ConditionDef[] = [
|
||||
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true } } },
|
||||
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } },
|
||||
{ id: 'obezwladniony', i18nKey: 'HBM.conditions.obezwladniony', icon: 'icons/svg/blood.svg', flags: { hbm: { blocksTags: ['attack'], defenseT: 2 } } },
|
||||
{ id: 'nieprzytomny', i18nKey: 'HBM.conditions.nieprzytomny', icon: 'icons/svg/unconscious.svg', flags: { hbm: { skipTurn: true, blocksAllActions: true } } },
|
||||
{ id: 'umierajacy', i18nKey: 'HBM.conditions.umierajacy', icon: 'icons/svg/skull.svg', flags: { hbm: { dyingState: { failures: 0, hits: 0, mortalDamage: 0 } } } },
|
||||
{ id: 'ogluszony', i18nKey: 'HBM.conditions.ogluszony', icon: 'icons/svg/deaf.svg', flags: { hbm: { blocksTags: ['hearing'] } } },
|
||||
{ id: 'oslepiony', i18nKey: 'HBM.conditions.oslepiony', icon: 'icons/svg/blind.svg', flags: { hbm: { blocksTags: ['sight'] } } },
|
||||
{ id: 'oszolomiony', i18nKey: 'HBM.conditions.oszolomiony', icon: 'icons/svg/daze.svg', flags: { hbm: { maxActions: 1, blockZeal: true, halveSpeed: true } } },
|
||||
{
|
||||
id: 'zatruty',
|
||||
i18nKey: 'HBM.conditions.zatruty',
|
||||
icon: 'icons/svg/poison.svg',
|
||||
changes: [
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
|
||||
],
|
||||
},
|
||||
{ id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } },
|
||||
{ id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } },
|
||||
{
|
||||
id: 'przeklety',
|
||||
i18nKey: 'HBM.conditions.przeklety',
|
||||
icon: 'icons/svg/sun.svg',
|
||||
changes: [
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
|
||||
],
|
||||
flags: { hbm: { blocksRegen: true } },
|
||||
},
|
||||
{ id: 'zauroczony', i18nKey: 'HBM.conditions.zauroczony', icon: 'icons/svg/heal.svg', flags: { hbm: { thresholdSteps: { social: 1 }, charmedBy: '' } } },
|
||||
{ id: 'koncentracja', i18nKey: 'HBM.conditions.koncentracja', icon: 'icons/svg/aura.svg', flags: { hbm: { concentration: true, persistent: true } } },
|
||||
// Provisional: book description incomplete for Przerażony (Aneks A placeholder).
|
||||
{ id: 'przerazony', i18nKey: 'HBM.conditions.przerazony', icon: 'icons/svg/terror.svg', flags: { hbm: { thresholdSteps: { all: 1 }, todoBookGap: true } } },
|
||||
|
||||
// Mental illnesses (Klątwa Otchłani Ch. VIII — book chapter currently a placeholder;
|
||||
// these are stub registrations with `todoBookGap: true` until full mechanics drop).
|
||||
{ id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
];
|
||||
|
||||
export function registerHbmConditions(): void {
|
||||
CONFIG.statusEffects = CONDITIONS.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.i18nKey,
|
||||
img: c.icon,
|
||||
statuses: [c.id],
|
||||
changes: c.changes ?? [],
|
||||
flags: c.flags ?? {},
|
||||
}));
|
||||
console.log(`${SYSTEM_ID} | Registered ${CONDITIONS.length} canonical status effects`);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Damage application engine — flows through 4 layers:
|
||||
* 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter)
|
||||
* 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
|
||||
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
|
||||
* 4. Health (the leftover hits HP)
|
||||
*
|
||||
* Bypass flags allow effects to skip individual layers.
|
||||
*
|
||||
* After resolving, if HP loss meets the Oszołomiony threshold ⌈(C+U+D)/3⌉
|
||||
* the effect is auto-applied to character actors.
|
||||
*/
|
||||
|
||||
import { DamageType } from '../constants';
|
||||
|
||||
export interface ApplyDamageOptions {
|
||||
amount: number;
|
||||
type?: DamageType;
|
||||
ignoreMagicalArmor?: boolean;
|
||||
ignoreMagicalShield?: boolean;
|
||||
ignorePhysicalArmor?: boolean;
|
||||
damageArmor?: boolean;
|
||||
postChat?: boolean;
|
||||
}
|
||||
|
||||
export interface DamageReport {
|
||||
amount: number;
|
||||
absorbed: { magicalArmor: number; magicalShield: number; physicalArmor: number };
|
||||
hpDamage: number;
|
||||
shieldDropped: boolean;
|
||||
oszolomionyApplied: boolean;
|
||||
}
|
||||
|
||||
interface ActorLike {
|
||||
name: string;
|
||||
type: string;
|
||||
system: any;
|
||||
update: (changes: Record<string, unknown>) => Promise<unknown>;
|
||||
toggleStatusEffect?: (id: string, options?: any) => Promise<any> | any;
|
||||
effects?: { find: (fn: (e: any) => boolean) => any };
|
||||
}
|
||||
|
||||
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
|
||||
const total = Math.max(0, Math.floor(opts.amount));
|
||||
const a = actor.system.attributes;
|
||||
const updates: Record<string, unknown> = {};
|
||||
const report: DamageReport = {
|
||||
amount: total,
|
||||
absorbed: { magicalArmor: 0, magicalShield: 0, physicalArmor: 0 },
|
||||
hpDamage: 0,
|
||||
shieldDropped: false,
|
||||
oszolomionyApplied: false,
|
||||
};
|
||||
|
||||
let remaining = total;
|
||||
|
||||
// Layer 1: Magical Armor — DR-based; runicCounter accumulates incoming damage.
|
||||
if (!opts.ignoreMagicalArmor && a.magicalArmor) {
|
||||
const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
|
||||
const absorbed = Math.min(dr, remaining);
|
||||
if (absorbed > 0) {
|
||||
const incomingDmg = remaining;
|
||||
report.absorbed.magicalArmor = absorbed;
|
||||
remaining -= absorbed;
|
||||
const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg;
|
||||
const decrements = Math.floor(newCounter / 5);
|
||||
const newValue = Math.max(0, dr - decrements);
|
||||
updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5;
|
||||
updates['system.attributes.magicalArmor.value'] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Magical Shield — pure temp HP (raw subtraction).
|
||||
if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
|
||||
const shield = Math.max(0, Number(a.magicalShield.value) || 0);
|
||||
const taken = Math.min(shield, remaining);
|
||||
if (taken > 0) {
|
||||
report.absorbed.magicalShield = taken;
|
||||
remaining -= taken;
|
||||
const newShield = shield - taken;
|
||||
updates['system.attributes.magicalShield.value'] = newShield;
|
||||
if (newShield === 0) report.shieldDropped = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Physical Armor — flat DR; optionally degrade condition.
|
||||
if (remaining > 0 && !opts.ignorePhysicalArmor && a.physicalArmor) {
|
||||
const dr = Math.max(0, Number(a.physicalArmor.value) || 0);
|
||||
const absorbed = Math.min(dr, remaining);
|
||||
if (absorbed > 0) {
|
||||
report.absorbed.physicalArmor = absorbed;
|
||||
remaining -= absorbed;
|
||||
}
|
||||
if (opts.damageArmor && actor.type === 'character' && a.physicalArmor.condition != null) {
|
||||
// Decrement durability of the first equipped armor piece (character only)
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
if (items) {
|
||||
for (const it of items) {
|
||||
if (it.type === 'gear' && it.system?.equipped && it.system?.category === 'armor') {
|
||||
const cond = Math.max(0, Number(it.system.armor?.condition ?? 0) - 1);
|
||||
await it.update({ 'system.armor.condition': cond });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 4: Health
|
||||
if (remaining > 0 && a.health) {
|
||||
const hp = Math.max(0, Number(a.health.value) || 0);
|
||||
const newHp = Math.max(0, hp - remaining);
|
||||
report.hpDamage = hp - newHp;
|
||||
updates['system.attributes.health.value'] = newHp;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) await actor.update(updates);
|
||||
|
||||
if (opts.postChat ?? true) {
|
||||
const lines: string[] = [
|
||||
`<strong>${actor.name}</strong> — ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
|
||||
];
|
||||
if (report.absorbed.magicalArmor) lines.push(`${game.i18n.localize('HBM.resources.magicalArmor')}: −${report.absorbed.magicalArmor}`);
|
||||
if (report.absorbed.magicalShield) lines.push(`${game.i18n.localize('HBM.resources.magicalShield')}: −${report.absorbed.magicalShield}${report.shieldDropped ? ' ✦' : ''}`);
|
||||
if (report.absorbed.physicalArmor) lines.push(`${game.i18n.localize('HBM.resources.physicalArmor')}: −${report.absorbed.physicalArmor}`);
|
||||
if (report.hpDamage) lines.push(`${game.i18n.localize('HBM.resources.health')}: −${report.hpDamage}`);
|
||||
await ChatMessage.create({ content: `<div class="hbm-damage-report">${lines.join('<br/>')}</div>` });
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Rest logic.
|
||||
*
|
||||
* Short Rest (Krótki Odpoczynek):
|
||||
* - Restore HP equal to actor.body.value (capped at max).
|
||||
* - Restore mana to max-per-spell? — no: short rest does NOT restore mana.
|
||||
* - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
|
||||
*
|
||||
* Long Rest (Długi Odpoczynek):
|
||||
* - Restore HP, mana, zeal to max.
|
||||
* - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? — book says
|
||||
* elixir tolerance recovers per long rest by default; tracked here as -1).
|
||||
* - Reset blood pool to max (assumption — refine when AS spell list lands).
|
||||
* - Clear runic counter on magical armor.
|
||||
*
|
||||
* Hooks `hbm.beforeRest` and `hbm.afterRest` fire for module integration.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
import { CONDITIONS } from './conditions';
|
||||
|
||||
export type RestKind = 'breather' | 'short' | 'long';
|
||||
|
||||
declare const Hooks: any;
|
||||
declare const Roll: any;
|
||||
|
||||
interface ActorWithItems extends CastableActor {
|
||||
items?: Iterable<{ type: string; system?: { slug?: string }; flags?: any }>;
|
||||
}
|
||||
|
||||
function hasExtraordinaryResilience(actor: ActorWithItems): boolean {
|
||||
if (!actor.items) return false;
|
||||
for (const it of actor.items) {
|
||||
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug ?? '';
|
||||
if (slug === 'extraordinary-resilience' || slug === 'nadzwyczajna-odpornosc') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface RestResult {
|
||||
kind: RestKind;
|
||||
hpRestored: number;
|
||||
manaRestored: number;
|
||||
zealRestored: number;
|
||||
bloodRestored: number;
|
||||
toleranceRecovered: number;
|
||||
effectsCleared: number;
|
||||
}
|
||||
|
||||
export async function rest(actor: CastableActor, kind: RestKind): Promise<RestResult> {
|
||||
Hooks.callAll('hbm.beforeRest', actor, kind);
|
||||
const a = actor.system.attributes as any;
|
||||
const update: Record<string, unknown> = {};
|
||||
|
||||
const hpBefore = a.health?.value ?? 0;
|
||||
const manaBefore = a.mana?.value ?? 0;
|
||||
const zealBefore = a.zeal?.value ?? 0;
|
||||
const bloodBefore = a.blood?.value ?? 0;
|
||||
const toleranceBefore = a.elixirTolerance ?? 0;
|
||||
|
||||
let hpRestored = 0;
|
||||
let manaRestored = 0;
|
||||
let zealRestored = 0;
|
||||
let bloodRestored = 0;
|
||||
let toleranceRecovered = 0;
|
||||
|
||||
if (kind === 'breather') {
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
}
|
||||
const endurance = actor.system.skills?.endurance?.value ?? 0;
|
||||
const roll = new Roll('1d6 + @endurance', { endurance });
|
||||
await roll.evaluate();
|
||||
const rollTotal = roll.total;
|
||||
const heal = Math.min(rollTotal, (a.health?.max ?? 0) - hpBefore);
|
||||
if (heal > 0) {
|
||||
update['system.attributes.health.value'] = hpBefore + heal;
|
||||
hpRestored = heal;
|
||||
}
|
||||
} else if (kind === 'short') {
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
}
|
||||
const healAmount = Math.ceil((a.health?.max ?? 0) / 3);
|
||||
const heal = Math.min(healAmount, (a.health?.max ?? 0) - hpBefore);
|
||||
if (heal > 0) {
|
||||
update['system.attributes.health.value'] = hpBefore + heal;
|
||||
hpRestored = heal;
|
||||
}
|
||||
if (hasExtraordinaryResilience(actor as ActorWithItems) && toleranceBefore > 0) {
|
||||
update['system.attributes.elixirTolerance'] = toleranceBefore - 1;
|
||||
toleranceRecovered = 1;
|
||||
}
|
||||
} else {
|
||||
if (a.health?.max != null && hpBefore < a.health.max) {
|
||||
update['system.attributes.health.value'] = a.health.max;
|
||||
hpRestored = a.health.max - hpBefore;
|
||||
}
|
||||
if (a.mana?.max != null && manaBefore < a.mana.max) {
|
||||
update['system.attributes.mana.value'] = a.mana.max;
|
||||
manaRestored = a.mana.max - manaBefore;
|
||||
}
|
||||
if (a.zeal?.max != null && zealBefore < a.zeal.max) {
|
||||
update['system.attributes.zeal.value'] = a.zeal.max;
|
||||
zealRestored = a.zeal.max - zealBefore;
|
||||
}
|
||||
if (a.blood?.max != null && bloodBefore < a.blood.max) {
|
||||
update['system.attributes.blood.value'] = a.blood.max;
|
||||
bloodRestored = a.blood.max - bloodBefore;
|
||||
}
|
||||
if (toleranceBefore > 0) {
|
||||
update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1);
|
||||
toleranceRecovered = 1;
|
||||
}
|
||||
if (a.magicalArmor?.runicCounter && a.magicalArmor.runicCounter > 0) {
|
||||
update['system.attributes.magicalArmor.runicCounter'] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) await actor.update(update);
|
||||
|
||||
let effectsCleared = 0;
|
||||
const actorAny = actor as unknown as { effects?: any; deleteEmbeddedDocuments?: (t: string, ids: string[]) => Promise<unknown> };
|
||||
const ids: string[] = [];
|
||||
|
||||
if (kind === 'short') {
|
||||
for (const ef of actorAny.effects ?? []) {
|
||||
const isUnconscious = ef.statuses?.has('nieprzytomny') || ef.flags?.core?.statusId === 'nieprzytomny' || ef.id === 'nieprzytomny';
|
||||
if (isUnconscious) ids.push(ef.id);
|
||||
}
|
||||
} else if (kind === 'long') {
|
||||
const conditionIds = new Set(CONDITIONS.map(c => c.id));
|
||||
for (const ef of actorAny.effects ?? []) {
|
||||
const isState = conditionIds.has(ef.id) ||
|
||||
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
|
||||
conditionIds.has(ef.flags?.core?.statusId);
|
||||
|
||||
if (isState) {
|
||||
ids.push(ef.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ef.origin) continue;
|
||||
const dur = ef.duration ?? {};
|
||||
const isTemporary = (dur.seconds ?? 0) > 0 || (dur.rounds ?? 0) > 0 || (dur.turns ?? 0) > 0 || ef.flags?.['hbm-rpg-v3']?.untilLongRest === true;
|
||||
if (isTemporary) ids.push(ef.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (ids.length > 0 && actorAny.deleteEmbeddedDocuments) {
|
||||
await actorAny.deleteEmbeddedDocuments('ActiveEffect', ids);
|
||||
effectsCleared = ids.length;
|
||||
}
|
||||
|
||||
const result: RestResult = { kind, hpRestored, manaRestored, zealRestored, bloodRestored, toleranceRecovered, effectsCleared };
|
||||
Hooks.callAll('hbm.afterRest', actor, result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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 } 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 };
|
||||
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';
|
||||
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 = 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.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;
|
||||
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaSpent });
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${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 — extra TS test if manaSpent exceeds maxPerSpell.
|
||||
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
|
||||
const excess = manaSpent - a.mana.maxPerSpell;
|
||||
const tThreshold = Math.min(6, Math.max(2, baseCost));
|
||||
const ySuccesses = Math.min(10, Math.max(1, excess));
|
||||
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);
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - baseCost });
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${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;
|
||||
if (blood) {
|
||||
await actor.update({ '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) {
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaCost });
|
||||
}
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${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;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Spell damage formula evaluator.
|
||||
*
|
||||
* Tokens recognised in `spell.damageBase`:
|
||||
* magicalAbilities, devotion, soul, mind, body, magic
|
||||
* magicalAbilities/2 (and similar /N or *N suffixes)
|
||||
* 1d6, 2d6, 1d3 etc. (Foundry dice notation)
|
||||
* numeric literals
|
||||
* Operators: + - * / (integer division for /N tokens; standard for dice)
|
||||
*
|
||||
* Returns a Foundry Roll ready to evaluate, or null when the spell has
|
||||
* no damageBase formula.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface DamageContext {
|
||||
actor: CastableActor;
|
||||
spellName?: string;
|
||||
}
|
||||
|
||||
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
|
||||
|
||||
function resolveToken(token: string, ctx: DamageContext): number {
|
||||
const a = (ctx.actor as any).system?.attributes ?? {};
|
||||
const s = (ctx.actor as any).system?.skills ?? {};
|
||||
switch (token) {
|
||||
case 'body': return Number(a.body?.value ?? 0);
|
||||
case 'mind': return Number(a.mind?.value ?? 0);
|
||||
case 'soul': return Number(a.soul?.value ?? 0);
|
||||
case 'magic': return Number(a.magic?.value ?? 0);
|
||||
case 'magicalAbilities': return Number(s.magicalAbilities?.value ?? 0);
|
||||
case 'devotion': return Number(s.devotion?.value ?? 0);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes attribute tokens with their numeric values, leaving dice
|
||||
* notation intact for Foundry to parse.
|
||||
*/
|
||||
export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext): string | null {
|
||||
if (!spellDamageBase || !spellDamageBase.trim()) return null;
|
||||
|
||||
let formula = spellDamageBase.trim();
|
||||
|
||||
// Replace attribute tokens (with optional /N or *N suffix).
|
||||
// Match e.g. magicalAbilities, magicalAbilities/2, soul*3
|
||||
const tokenRe = /([a-zA-Z]+)(?:\s*([\/*])\s*(\d+))?/g;
|
||||
formula = formula.replace(tokenRe, (match, name: string, op: string | undefined, num: string | undefined) => {
|
||||
if (!ATTR_TOKENS.has(name)) {
|
||||
// Leave alone (likely dice notation like 1d6 — no, dice has digits before)
|
||||
return match;
|
||||
}
|
||||
let v = resolveToken(name, ctx);
|
||||
if (op && num) {
|
||||
const n = Number(num);
|
||||
if (op === '/') v = Math.ceil(v / n); // ceil per HbM rounding rules
|
||||
if (op === '*') v = v * n;
|
||||
}
|
||||
return String(v);
|
||||
});
|
||||
|
||||
return formula;
|
||||
}
|
||||
|
||||
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
|
||||
const formula = buildDamageFormula(spellDamageBase, ctx);
|
||||
if (!formula) return null;
|
||||
const roll = new Roll(formula, {});
|
||||
await roll.evaluate();
|
||||
return roll;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Reactive spell trigger registry.
|
||||
*
|
||||
* Spells with `triggers[]` register flag-based listeners on the caster:
|
||||
* actor.flags.hbm.triggers[] = [{ event, effect, spellId, expiresAt }]
|
||||
*
|
||||
* Hooks check the matching event and prompt/resolve the trigger.
|
||||
*
|
||||
* Supported events:
|
||||
* - killWithWeapon (e.g. Szkarłatny Sztylet — free spell after kill)
|
||||
* - targetCastsSpell (e.g. Klątwa Szkarłatu — opposed save → unconscious)
|
||||
* - damageTaken (reactive shields)
|
||||
* - turnStart (per-turn drains)
|
||||
*
|
||||
* Triggers are stored as world-flag data (serialisable); the runtime hook
|
||||
* dispatches based on `event`. Effect strings are advisory text (GM-resolved)
|
||||
* unless they map to a known machine effect ID.
|
||||
*/
|
||||
|
||||
import { SYSTEM_ID } from '../hbm';
|
||||
|
||||
export interface SpellTrigger {
|
||||
event: string;
|
||||
effect: string;
|
||||
spellId: string;
|
||||
spellName: string;
|
||||
/** Optional: combat round at which this trigger expires. */
|
||||
expiresAtRound?: number;
|
||||
/** Single-use after fire. */
|
||||
oneShot?: boolean;
|
||||
}
|
||||
|
||||
interface ActorWithFlags {
|
||||
id?: string;
|
||||
name?: string;
|
||||
getFlag?: (scope: string, key: string) => unknown;
|
||||
setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown>;
|
||||
unsetFlag?: (scope: string, key: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function readTriggers(actor: ActorWithFlags): SpellTrigger[] {
|
||||
const raw = actor.getFlag?.(SYSTEM_ID, 'triggers') as SpellTrigger[] | undefined;
|
||||
return Array.isArray(raw) ? raw.slice() : [];
|
||||
}
|
||||
|
||||
async function writeTriggers(actor: ActorWithFlags, triggers: SpellTrigger[]): Promise<void> {
|
||||
if (triggers.length === 0) {
|
||||
await actor.unsetFlag?.(SYSTEM_ID, 'triggers');
|
||||
} else {
|
||||
await actor.setFlag?.(SYSTEM_ID, 'triggers', triggers);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerSpellTriggers(
|
||||
actor: ActorWithFlags,
|
||||
spell: { id?: string; name?: string; system?: { triggers?: Array<{ event: string; effect: string }> } },
|
||||
): Promise<number> {
|
||||
const list = spell.system?.triggers ?? [];
|
||||
if (!Array.isArray(list) || list.length === 0) return 0;
|
||||
const existing = readTriggers(actor);
|
||||
const now = (game as any).combat?.round ?? 0;
|
||||
for (const t of list) {
|
||||
existing.push({
|
||||
event: t.event,
|
||||
effect: t.effect,
|
||||
spellId: spell.id ?? '',
|
||||
spellName: spell.name ?? '',
|
||||
expiresAtRound: now + 1, // default: end of next round; spell may override later
|
||||
oneShot: true,
|
||||
});
|
||||
}
|
||||
await writeTriggers(actor, existing);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
export async function fireTriggers(
|
||||
actor: ActorWithFlags,
|
||||
event: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<SpellTrigger[]> {
|
||||
const triggers = readTriggers(actor);
|
||||
const fired: SpellTrigger[] = [];
|
||||
const remaining: SpellTrigger[] = [];
|
||||
for (const t of triggers) {
|
||||
if (t.event === event) {
|
||||
fired.push(t);
|
||||
// Post chat card so GM can resolve the effect manually if no auto-handler.
|
||||
await ChatMessage.create({
|
||||
content: `<strong>${actor.name ?? ''}</strong> — Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
|
||||
whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [],
|
||||
});
|
||||
if (!t.oneShot) remaining.push(t);
|
||||
} else {
|
||||
remaining.push(t);
|
||||
}
|
||||
}
|
||||
if (fired.length > 0) await writeTriggers(actor, remaining);
|
||||
return fired;
|
||||
}
|
||||
|
||||
export function registerTriggerHooks(): void {
|
||||
// Combat-end / actor death events feed triggers.
|
||||
// Foundry hooks: 'updateActor' (HP delta), 'createChatMessage' (attack rolls), 'updateCombat'.
|
||||
Hooks.on('updateActor', async (actor: any, change: any) => {
|
||||
const newHp = change?.system?.attributes?.health?.value;
|
||||
if (typeof newHp === 'number' && newHp <= 0) {
|
||||
// Find any actor in combat with a killWithWeapon trigger awaiting fire
|
||||
const combat = (game as any).combat;
|
||||
if (!combat?.started) return;
|
||||
for (const c of combat.combatants ?? []) {
|
||||
const a = c.actor as ActorWithFlags;
|
||||
if (!a) continue;
|
||||
const triggers = readTriggers(a);
|
||||
if (triggers.some((t) => t.event === 'killWithWeapon')) {
|
||||
await fireTriggers(a, 'killWithWeapon', { victim: actor.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Trade logic — founding companies, transactions, smuggling.
|
||||
* Source: Złoto Stal i Magia, Rozdział IX.
|
||||
*
|
||||
* The book's exact difficulty formulas depend on commodity, route, and party
|
||||
* skill, so this module exposes the *primitives*; the cast dialog/UI passes
|
||||
* the threshold and required-successes derived from the table.
|
||||
*
|
||||
* State: a "company" is just a JournalEntry created in a configurable folder;
|
||||
* here we only expose the rolling helpers.
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface TradeTestParams {
|
||||
/** TS threshold, default 5. */
|
||||
threshold?: number;
|
||||
/** Required successes, default 2. */
|
||||
required?: number;
|
||||
/** Skill key contributing to the pool (e.g. `tradeAndPersuasion`). */
|
||||
skill?: string;
|
||||
/** Attribute key whose `value` adds to the pool (default `mind`). */
|
||||
attribute?: 'body' | 'mind' | 'soul' | 'magic';
|
||||
/** Free-form description for the chat card. */
|
||||
flavor?: string;
|
||||
}
|
||||
|
||||
function rollTrade(actor: CastableActor, label: string, params: TradeTestParams): Promise<HbmTSRoll> {
|
||||
const attribute = params.attribute ?? 'mind';
|
||||
const attrVal = (actor.system.attributes as any)[attribute]?.value ?? 0;
|
||||
const skillVal = params.skill ? (actor.system.skills?.[params.skill]?.value ?? 0) : 0;
|
||||
const pool = attrVal + skillVal;
|
||||
const flavor = params.flavor ?? `${label}: ${actor.name}`;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
required: params.required ?? TS_DEFAULT_REQUIRED,
|
||||
flavor,
|
||||
});
|
||||
return roll.evaluate().then(async () => {
|
||||
await roll.toMessage({ flavor });
|
||||
return roll;
|
||||
});
|
||||
}
|
||||
|
||||
/** Founding a trading company — usually a single TS test plus capital. */
|
||||
export function foundCompany(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
return rollTrade(actor, 'Założenie Firmy Handlowej', params);
|
||||
}
|
||||
|
||||
/** Standard legal transaction. */
|
||||
export function transaction(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
return rollTrade(actor, 'Transakcja', params);
|
||||
}
|
||||
|
||||
/** Smuggling — illegal transaction; failure should fire `hbm.smugglingFailed`. */
|
||||
export async function smuggling(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
const roll = await rollTrade(actor, 'Przemyt', params);
|
||||
if (!roll.ts?.isSuccess) {
|
||||
Hooks.callAll('hbm.smugglingFailed', actor, roll);
|
||||
}
|
||||
return roll;
|
||||
}
|
||||
Reference in New Issue
Block a user