feat: implement core Foundry VTT system framework, including actor/item data models, spellcasting logic, dice mechanics, and automation scripts

This commit is contained in:
Octoturge
2026-06-14 10:12:47 +02:00
parent fb42c6e8cc
commit 624fe2ee1a
70 changed files with 1645 additions and 1146 deletions
+8 -8
View File
@@ -1,18 +1,18 @@
/**
* Magia Otchłani Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII).
* 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
* - 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
* - 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;
* - 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.
*/
@@ -69,7 +69,7 @@ async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor:
await roll.evaluate();
const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor });
await roll.toMessage({
flavor: `${flavor} ${actor.name} (${slug})`,
flavor: `${flavor} - ${actor.name} (${slug})`,
speaker,
});
Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total);
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Magia Krwi Blood Magic logic (Arcanum Sanguinis Ch. III).
* Magia Krwi - Blood Magic logic (Arcanum Sanguinis Ch. III).
*
* Three primitives:
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
+9 -5
View File
@@ -1,5 +1,5 @@
/**
* Warzenie Eliksirów Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
* 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
@@ -12,12 +12,12 @@
*/
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import type { CastableActor } from './spell-cast';
export interface ElixirRecipe {
name: string;
/** TS test target { threshold, successes }. */
/** TS test target - { threshold, successes }. */
difficulty: { threshold: number; successes: number };
/** Ingredient names (free-form). */
ingredients: string[];
@@ -44,7 +44,11 @@ export async function consumeElixir(actor: CastableActor, recipe: Pick<ElixirRec
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 magicAttr = actor.system.attributes.magic as any;
const magicDice = typeof magicAttr.dicePool === 'number'
? magicAttr.dicePool
: getMagicPowerEntry(magicAttr.actual ?? magicAttr.value ?? 0).dicePool;
const pool = magicDice + skillValue;
const roll = HbmTSRoll.fromParams({
pool,
threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
@@ -53,7 +57,7 @@ export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Pr
});
await roll.evaluate();
await roll.toMessage({
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || ''})`,
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '-'})`,
});
return { roll, success: !!roll.ts?.isSuccess };
}
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* Combat hooks handle per-round Mana reset, per-turn Zeal regen,
* Combat hooks - handle per-round Mana reset, per-turn Zeal regen,
* and condition-driven turn behavior (skip / damage tick / death save).
*/
@@ -50,14 +50,14 @@ export function registerCombatHooks(): void {
await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true });
}
// Dying: prompt death save (simplified posts a chat reminder)
// 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.
// 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);
+26 -26
View File
@@ -1,5 +1,5 @@
/**
* HbM canonical status conditions (Aneks A Podręcznik Gry).
* 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.
*/
@@ -17,51 +17,51 @@ export interface ConditionDef {
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: '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.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: '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.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 } } },
{ 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 } } },
{ 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;
// 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 } } },
{ 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 {
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Damage application engine flows through 4 layers:
* 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)
@@ -54,7 +54,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
let remaining = total;
// Layer 1: Magical Armor DR-based; runicCounter accumulates incoming damage.
// 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);
@@ -70,7 +70,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
}
}
// Layer 2: Magical Shield pure temp HP (raw subtraction).
// 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);
@@ -83,7 +83,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
}
}
// Layer 3: Physical Armor flat DR; optionally degrade condition.
// 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);
@@ -118,7 +118,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (opts.postChat ?? true) {
const lines: string[] = [
`<strong>${actor.name}</strong> ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
`<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 ? ' ✦' : ''}`);
+6 -6
View File
@@ -3,14 +3,14 @@
*
* 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.
* - 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
* - 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).
* - 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.
@@ -134,9 +134,9 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
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);
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
conditionIds.has(ef.flags?.core?.statusId);
if (isState) {
ids.push(ef.id);
continue;
+63 -23
View File
@@ -1,21 +1,21 @@
/**
* Spell casting workflow handles standard, sacred (Magia Sakralna),
* 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,
* 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[]
* 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 { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage';
@@ -28,7 +28,7 @@ export interface CastableActor {
type: 'character' | 'npc';
system: {
attributes: {
magic: { value: number };
magic: { value: number; actual?: number; dicePool?: number };
soul: { value: number };
mana: { value: number; max: number; maxPerSpell: number };
zeal: { value: number; max: number };
@@ -71,6 +71,7 @@ export interface CastOptions {
zealSpent?: number;
bloodSpent?: number;
hekateMode?: 'sacred' | 'witch';
castAsPrayer?: boolean;
bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean;
groupCasters?: string[];
@@ -147,12 +148,16 @@ async function renderCastCard(
triggersRegistered: number,
): Promise<void> {
const success = baseRoll?.ts?.isSuccess ?? false;
const mode = spell.system.castingMode ?? 'standard';
const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard');
// Costs ledger
const costs: Array<{ label: string; amount: number }> = [];
if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! });
if ((opts.zealSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
if (opts.castAsPrayer) {
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: 1 });
} else if ((opts.zealSpent ?? 0) > 0) {
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
}
if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! });
// Damage block
@@ -220,10 +225,21 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
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 updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - manaSpent };
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = `${spell.name}`;
const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const baseRoll = HbmTSRoll.fromParams({
pool,
@@ -234,7 +250,7 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
await baseRoll.evaluate();
await baseRoll.toMessage({ flavor, speaker: opts.speaker });
// Overcast extra TS test if manaSpent exceeds maxPerSpell.
// 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));
@@ -259,7 +275,7 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
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 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,
@@ -279,10 +295,21 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
// 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 updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({
pool,
@@ -300,18 +327,31 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
const blood = a.blood;
const updates: Record<string, any> = {};
if (blood) {
await actor.update({ 'system.attributes.blood.value': Math.max(0, blood.value - bloodSpent) });
updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
}
// Mana cost (some blood spells may also have one)
const manaCost = Math.max(0, spell.system.manaCost ?? 0);
if (manaCost > 0) {
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaCost });
updates['system.attributes.mana.value'] = a.mana.value - manaCost;
}
if (opts.castAsPrayer) {
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
}
await actor.update(updates);
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.blood')}`;
const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({
pool,
+7 -7
View File
@@ -25,13 +25,13 @@ 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 '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;
case 'devotion': return Number(s.devotion?.value ?? 0);
default: return 0;
}
}
@@ -49,7 +49,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
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)
// Leave alone (likely dice notation like 1d6 - no, dice has digits before)
return match;
}
let v = resolveToken(name, ctx);
+3 -3
View File
@@ -7,8 +7,8 @@
* 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)
* - 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)
*
@@ -86,7 +86,7 @@ export async function fireTriggers(
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}`,
content: `<strong>${actor.name ?? ''}</strong> - Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [],
});
if (!t.oneShot) remaining.push(t);
+57 -13
View File
@@ -41,18 +41,42 @@ function actorHasTalent(actor: CastableActor, talentId: string): boolean {
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;
function matchDiscipline(disciplineId: string, targetId: string): boolean {
const d1 = disciplineId.toLowerCase().trim();
const d2 = targetId.toLowerCase().trim();
if (d1 === d2) return true;
const mappings: Record<string, string[]> = {
crimson: ['crimson', 'magia szkarłatu', 'magia szkarłatnego kultu', 'crimson magic', 'crimson cult magic'],
abyssaspects: ['abyssaspects', 'magia aspektów', 'aspects', 'aspect magic', 'abyss - aspects', 'magia otchłani - aspekty'],
abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'],
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
blood: ['blood', 'magia krwi', 'blood magic'],
};
let key1: string | null = null;
let key2: string | null = null;
for (const [k, aliases] of Object.entries(mappings)) {
if (k === d1 || aliases.includes(d1)) key1 = k;
if (k === d2 || aliases.includes(d2)) key2 = k;
}
// Also accept characters whose details.discipline matches
const detail = String((actor as any).system?.details?.discipline ?? '').toLowerCase();
return detail === id;
if (key1 && key2 && key1 === key2) return true;
return false;
}
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
const items = (actor as any).items as Iterable<any> | undefined;
if (items) {
for (const it of items) {
if (it.type !== 'discipline') continue;
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '');
if (matchDiscipline(d, disciplineId)) return true;
}
}
const detail = String((actor as any).system?.details?.discipline ?? '');
return matchDiscipline(detail, disciplineId);
}
function actorIsInCombat(actor: CastableActor): boolean {
@@ -79,7 +103,11 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
}
}
if (mode === 'sacred' && opts.hekateMode !== 'witch') {
if (opts.castAsPrayer) {
if (1 > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
} else if (mode === 'sacred' && opts.hekateMode !== 'witch') {
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
@@ -122,7 +150,23 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
// 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));
const hasEldritchMagic = actorHasDiscipline(actor, 'abyssPrimal');
const hasAspectMagic = actorHasDiscipline(actor, 'abyssAspects');
const hasWitchMagic = actorHasDiscipline(actor, 'witch');
// Is the spell an Eldritch Spell?
const isEldritchSpell = discReq.some((d) => matchDiscipline(d, 'abyssPrimal')) ||
matchDiscipline(sys.school ?? '', 'abyssPrimal') ||
matchDiscipline(sys.discipline ?? '', 'abyssPrimal');
let bypassDisc = false;
if (hasEldritchMagic) {
bypassDisc = true; // Eldritch Magic allows casting ANY spells
} else if ((hasWitchMagic || hasAspectMagic) && !isEldritchSpell) {
bypassDisc = true; // Witch/Aspect Magic allows casting any spells EXCEPT Eldritch spells
}
const ok = bypassDisc || discReq.some((d) => actorHasDiscipline(actor, d));
if (!ok) {
const translatedDisciplines = discReq.map(d => {
return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d;
+3 -3
View File
@@ -1,5 +1,5 @@
/**
* Trade logic founding companies, transactions, smuggling.
* 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
@@ -45,7 +45,7 @@ function rollTrade(actor: CastableActor, label: string, params: TradeTestParams)
});
}
/** Founding a trading company usually a single TS test plus capital. */
/** 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);
}
@@ -55,7 +55,7 @@ export function transaction(actor: CastableActor, params: TradeTestParams = {}):
return rollTrade(actor, 'Transakcja', params);
}
/** Smuggling illegal transaction; failure should fire `hbm.smugglingFailed`. */
/** 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) {