feat: implement core Foundry VTT system framework, including actor/item data models, spellcasting logic, dice mechanics, and automation scripts
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Magia Otchłani — Abyss Magic logic (Klątwa Otchłani Ch. III–VIII).
|
||||
* 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
|
||||
* - 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,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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user