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
+77
View File
@@ -0,0 +1,77 @@
/**
* Magia Otchłani — Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII).
*
* 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;
}
+89
View File
@@ -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;
}
+59
View File
@@ -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 };
}
+93
View File
@@ -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;
}
+81
View File
@@ -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);
}
+77
View File
@@ -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`);
}
+131
View File
@@ -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;
}
+160
View File
@@ -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;
}
+325
View File
@@ -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;
}
+73
View File
@@ -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;
}
+120
View File
@@ -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 });
}
}
}
});
}
+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 };
}
+65
View File
@@ -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;
}