Compare commits
7
Commits
main
...
23c1e94ab5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23c1e94ab5 | ||
|
|
c5e53e591a | ||
|
|
da236ca069 | ||
|
|
9321f9cb5b | ||
|
|
27c900e26d | ||
|
|
2c3781a2e8 | ||
|
|
9f93745c42 |
@@ -5,6 +5,18 @@ All notable changes to the **HbM RPG v3** Foundry VTT system.
|
||||
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.5.2] - 2026-10-26
|
||||
|
||||
### Fixed
|
||||
- A character with the discipline Magia Sakralna - Egzorcyzmy (`sacredExorcism`) is now treated as a Sacred Magic caster in the spell discipline gate. Before, it only matched its own id, so exorcists failed the requirement for Sacred spells. The match is one-way: a plain Sacred mage is not an exorcist.
|
||||
- The "new round" chat message and README no longer claim that Mana is reset. Mana is a daily pool in the vault and has not been reset per round since 1.3.x; the message now says which counters really reset.
|
||||
|
||||
### Added
|
||||
- Spell cast dialog has a **Modyfikator liczby kości** (dice modifier, +/-). It changes the number of dice in the casting test for every casting mode (standard, sacred, witch, blood, prayer); the pool never drops below 0. The over-limit Nadczarowanie test uses the same modified pool. API: `CastOptions.diceModifier`, `applyDiceModifier()` in `rules.ts`.
|
||||
|
||||
### Changed
|
||||
- English text "Cannot be unraveled" is now "Cannot be dispelled" on the spell cast card.
|
||||
|
||||
## [1.5.1] - 2026-10-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -16,7 +16,7 @@ A companion lore module ([`foundry-lore`](https://github.com/iwo-strzebonski/hbm
|
||||
- **TS d6 dice pool roller** (`HbmTSRoll`) - `1` auto-fail, `6` auto-success, configurable Threshold (T) and required Successes (Y), plus T-step / Y-step modifiers driven by Active Effects.
|
||||
- **Derived stats** - `health.max = 3 × (body + mind + soul)`, `zeal.max = ceil(soul/2)`, initiative pool, mana table lookup (Magic Power Level 0–X), blood pool (`body + soul`), elixir tolerance cap (`body + 1`), insanity counter.
|
||||
- **4-layer damage model** - Magical Armor (with runic counter) → Magical Shield → Physical Armor (DR + condition) → Health. `Apply Damage` dialog with bypass flags; auto-applies `Oszołomiony` on heavy HP loss.
|
||||
- **Combat hooks** - Mana resets each round; Zeal +1 each turn; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save.
|
||||
- **Combat hooks** - Mana is a daily pool (not reset per round; recovered by rests); Zeal +1 each turn; Immediate Action and Krew counters reset each round; turns auto-skipped for Nieprzytomny / Obezwładniony; Podpalony ticks 1 environmental damage; Umierający prompts a Death Save.
|
||||
- **Spell-cast workflow** - `validateCast()` gates on resources / race / talent / discipline / deity / group / inCombat / superspell / witch-symbol; dispatcher routes to standard / Sacred / Witch / Blood / Hekate hybrid; `rollSpellDamage()` parses formula tokens; reactive triggers (`killWithWeapon`, `targetCastsSpell`, `damageTaken`, `turnStart`).
|
||||
- **Cast dialog** - mana / zeal / blood inputs, group-caster picker, Hekate toggle, GM bypass checkboxes (superspell, non-combat block), inline validation feedback.
|
||||
- **Mechanics modules** (`game.hbm.*`) - `bloodMagic` (Self-Harm, Life Steal, talent integration), `abyssMagic` (Aspects vs Primal, Mistrz Losu penalty, `Dary Otchłani`, insanity gain), `brewing` (TS test + tolerance counter), `rest` (short / long), `trade` (founding company workflow, transactions, smuggling).
|
||||
|
||||
+13
-2
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"HBM": {
|
||||
"insanity": {
|
||||
"illnessRoll": "Madness threshold reached - Mental Illness (d100, re-roll duplicates)"
|
||||
},
|
||||
"system": {
|
||||
"name": "Homebrew Magic: RPG v3"
|
||||
},
|
||||
@@ -138,6 +141,7 @@
|
||||
"aoeUnit": "Unit",
|
||||
"damage": "Damage",
|
||||
"damageBase": "Base damage",
|
||||
"damageCategory": "Category Obrażenia (+1 damage)",
|
||||
"damageType": "Damage type",
|
||||
"ignoresArmor": "Ignores armor",
|
||||
"statusEffects": "Status effects",
|
||||
@@ -175,6 +179,7 @@
|
||||
"artifice": "Artifice",
|
||||
"golemancy": "Golemancy",
|
||||
"runes": "Runic Magic",
|
||||
"runic": "Runic Magic",
|
||||
"manaSourceMage": "Mana Source",
|
||||
"illusion": "Illusion",
|
||||
"sacred": "Sacred Magic",
|
||||
@@ -260,6 +265,8 @@
|
||||
"heavy": "Heavy",
|
||||
"shield": "Shield",
|
||||
"stealthDisadvantage": "Stealth Disadvantage",
|
||||
"ignoresCastingPenalty": "Does not hamper spellcasting",
|
||||
"ignoresCastingPenaltyHint": "Special case (e.g. Cultist medium armour, Ninurta's Breastplate): skips the casting penalty",
|
||||
"strengthRequirement": "Strength Requirement",
|
||||
"rarity": "Rarity",
|
||||
"value": "Value",
|
||||
@@ -363,8 +370,12 @@
|
||||
},
|
||||
"spellCast": {
|
||||
"title": "Casting spell",
|
||||
"route": "Casting route",
|
||||
"routeSpell": "Regular spell (Magia + Zdolności Magiczne, Mana only)",
|
||||
"diceModifier": "Dice modifier",
|
||||
"diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)",
|
||||
"manaSpent": "Mana spent",
|
||||
"cannotBeUnraveled": "Cannot be unraveled",
|
||||
"cannotBeUnraveled": "Cannot be dispelled",
|
||||
"zealSpent": "Zeal spent",
|
||||
"bloodSpent": "Blood spent",
|
||||
"hekateMode": "Hekate mode (Sacred + Witch hybrid)",
|
||||
@@ -423,7 +434,7 @@
|
||||
}
|
||||
},
|
||||
"combat": {
|
||||
"newRound": "New round - Mana reset",
|
||||
"newRound": "New round - Immediate Action and Krew counters reset",
|
||||
"newTurn": "New turn - Zeal +1",
|
||||
"turnSkipped": "Turn skipped (condition)",
|
||||
"deathSavePrompt": "Make a Death Saving Throw (Body, T:4, Y:1)"
|
||||
|
||||
+12
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"HBM": {
|
||||
"insanity": {
|
||||
"illnessRoll": "Próg Obłędu osiągnięty - Choroba Psychiczna (k100, powtórz duplikaty)"
|
||||
},
|
||||
"system": {
|
||||
"name": "Homebrew Magic: RPG v3"
|
||||
},
|
||||
@@ -138,6 +141,7 @@
|
||||
"aoeUnit": "Jednostka",
|
||||
"damage": "Obrażenia",
|
||||
"damageBase": "Obrażenia bazowe",
|
||||
"damageCategory": "Kategoria Obrażenia (+1 do Obrażeń)",
|
||||
"damageType": "Typ obrażeń",
|
||||
"ignoresArmor": "Ignoruje pancerz",
|
||||
"statusEffects": "Efekty statusu",
|
||||
@@ -175,6 +179,7 @@
|
||||
"artifice": "Rzemiosło Artefaktów",
|
||||
"golemancy": "Golemancja",
|
||||
"runes": "Magia Runiczna",
|
||||
"runic": "Magia Runiczna",
|
||||
"manaSourceMage": "Źródło Mocy",
|
||||
"illusion": "Magia Iluzji",
|
||||
"sacred": "Magia Sakralna",
|
||||
@@ -280,6 +285,8 @@
|
||||
"heavy": "Ciężka",
|
||||
"shield": "Tarcza",
|
||||
"stealthDisadvantage": "Utrudnia Skradanie",
|
||||
"ignoresCastingPenalty": "Nie utrudnia rzucania zaklęć",
|
||||
"ignoresCastingPenaltyHint": "Przypadek szczególny (np. Średnia zbroja kultysty, Napierśnik Ninurty): pomija karę do rzucania zaklęć",
|
||||
"strengthRequirement": "Wymóg Siły",
|
||||
"rarity": "Rzadkość",
|
||||
"value": "Wartość",
|
||||
@@ -383,6 +390,10 @@
|
||||
},
|
||||
"spellCast": {
|
||||
"title": "Rzucanie zaklęcia",
|
||||
"route": "Sposób rzucenia",
|
||||
"routeSpell": "Zwykłe zaklęcie (Magia + Zdolności Magiczne, tylko Mana)",
|
||||
"diceModifier": "Modyfikator liczby kości",
|
||||
"diceModifierHint": "+/- kości w Teście rzucania (liczba kości nie spadnie poniżej 0)",
|
||||
"manaSpent": "Wydana mana",
|
||||
"cannotBeUnraveled": "Nie da się rozpleść",
|
||||
"cast": "Rzuć",
|
||||
@@ -426,7 +437,7 @@
|
||||
}
|
||||
},
|
||||
"combat": {
|
||||
"newRound": "Nowa runda - Mana zresetowana",
|
||||
"newRound": "Nowa runda - liczniki Akcji Natychmiastowych i Krwi zresetowane",
|
||||
"newTurn": "Nowa tura - Zapał +1",
|
||||
"turnSkipped": "Tura pominięta (stan)",
|
||||
"deathSavePrompt": "Wykonaj Rzut Obronny przeciw Śmierci (Ciało, T:4, Y:1)"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hbm-rpg-v3-foundry",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.2",
|
||||
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -19,7 +19,6 @@ const SpellNoteSchema = z.object({
|
||||
bloodCost: z.number().int().min(0).default(0),
|
||||
threshold: z.number().int().min(1).max(6),
|
||||
successes: z.number().int().min(1),
|
||||
complexityLevel: z.number().int().min(0).default(0),
|
||||
isSuperspell: z.boolean().default(false),
|
||||
requiresGroupCast: z.boolean().default(false),
|
||||
minCasters: z.number().int().min(1).default(1),
|
||||
@@ -36,6 +35,7 @@ const SpellNoteSchema = z.object({
|
||||
targets: z.coerce.string().default(''),
|
||||
duration: z.coerce.string().default(''),
|
||||
damageBase: z.coerce.string().default(''),
|
||||
damageCategory: z.boolean().default(false),
|
||||
damageType: z.string().default('magical'),
|
||||
ignoresArmor: z.boolean().default(false),
|
||||
statusEffects: z.array(z.string()).default([]),
|
||||
@@ -93,6 +93,7 @@ function toSystem(n: SpellNote): Record<string, unknown> {
|
||||
duration: n.duration,
|
||||
areaOfEffect: deriveAreaOfEffect(n.targets),
|
||||
damageBase: n.damageBase,
|
||||
damageCategory: n.damageCategory,
|
||||
damageType: n.damageType || 'magical',
|
||||
ignoresArmor: n.ignoresArmor,
|
||||
statusEffects: n.statusEffects,
|
||||
@@ -105,7 +106,6 @@ function toSystem(n: SpellNote): Record<string, unknown> {
|
||||
nonCombatOnly: n.nonCombatOnly,
|
||||
overcastOptions: n.overcast.map((description) => ({ description, manaPerStep: 1 })),
|
||||
triggers: n.triggers,
|
||||
complexityLevel: n.complexityLevel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@ export const SPELL_SCHOOLS = [
|
||||
// Academic disciplines
|
||||
'alchemyTransmutation', 'alchemyBrewing', 'botany',
|
||||
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
|
||||
'artifice', 'golemancy', 'runes', 'manaSourceMage',
|
||||
'artifice', 'golemancy', 'runes', 'runic', 'manaSourceMage',
|
||||
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
|
||||
// Forbidden / extra-academic
|
||||
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
|
||||
|
||||
@@ -87,9 +87,11 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
}),
|
||||
// Wielka Ofiara used today (once per day; reset by Długi Odpoczynek).
|
||||
greatSacrificeUsed: makeIntField(0, { min: 0, max: 1 }),
|
||||
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
|
||||
// mental-condition table when reaching thresholds.
|
||||
// Punkty Obłędu (Choroby Psychiczne). Reaching Próg Obłędu (Dusza + 3, Krasnolud +1)
|
||||
// resets them to 0 and gives one illness (k100 table in the vault).
|
||||
insanity: makeIntField(0, { min: 0 }),
|
||||
// Mental illnesses gained so far (names). The 6th one means the character is lost.
|
||||
mentalIllnesses: makeArrayField(new f.StringField({ blank: false })),
|
||||
magicalArmor: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
@@ -164,6 +166,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
bloodUsed: { selfHarm: number; lifeSteal: number; sacrifice: number; absorb: number };
|
||||
greatSacrificeUsed: number;
|
||||
insanity: number;
|
||||
mentalIllnesses: string[];
|
||||
magicalArmor: { value: number; max: number; runicCounter: number };
|
||||
magicalShield: { value: number };
|
||||
physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
|
||||
|
||||
@@ -53,6 +53,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
|
||||
}),
|
||||
stealthDisadvantage: makeBoolField(false),
|
||||
strengthRequirement: makeIntField(0, { min: 0 }),
|
||||
// Special-case armour that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty).
|
||||
ignoresCastingPenalty: makeBoolField(false),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ export class SpellData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
// Damage
|
||||
damageBase: makeStringField(''),
|
||||
// Category Obrażenia: +1 damage (Rodzaje Obrażeń). Vault frontmatter has no category yet.
|
||||
damageCategory: makeBoolField(false),
|
||||
damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }),
|
||||
ignoresArmor: makeBoolField(false),
|
||||
|
||||
@@ -108,7 +110,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
// Legacy fields (kept for migration; will be removed in a later release)
|
||||
overcasting: makeStringField(''),
|
||||
complexityLevel: makeIntField(0, { min: 0, max: 10 }),
|
||||
// `complexityLevel` was removed 2026-10-26: Poziom Skomplikowania = number of Symbols.
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+18
-15
@@ -4,7 +4,8 @@
|
||||
*
|
||||
* Inputs adapt to spell mode:
|
||||
* - standard: mana
|
||||
* - sacred: zeal (+ optional Hekate hybrid toggle if witch symbols set)
|
||||
* - sacred: mana (+ optional Hekate hybrid toggle if witch symbols set)
|
||||
* - all: regular spell / prayer route (sacred defaults to prayer)
|
||||
* - witch: mana
|
||||
* - blood: mana + blood
|
||||
*
|
||||
@@ -43,7 +44,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
const a = actor.system.attributes;
|
||||
const baseMana = Math.max(0, spell.system.manaCost ?? 0);
|
||||
const baseBlood = Math.max(0, spell.system.bloodCost ?? 0);
|
||||
const baseZeal = mode === 'sacred' ? 1 : 0;
|
||||
|
||||
const showHekate = mode === 'sacred' && (spell.system.components?.symbols?.length ?? 0) > 0;
|
||||
const showSuper = !!spell.system.isSuperspell;
|
||||
@@ -52,7 +52,7 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
|
||||
const fields: string[] = [];
|
||||
|
||||
if (mode === 'standard' || mode === 'witch' || mode === 'blood') {
|
||||
if (mode === 'standard' || mode === 'witch' || mode === 'blood' || mode === 'sacred') {
|
||||
const max = a.mana.value;
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
@@ -61,13 +61,6 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
<small>${game.i18n.localize('HBM.resources.maxManaPerSpell')}: ${a.mana.maxPerSpell}</small>
|
||||
</div>`);
|
||||
}
|
||||
if (mode === 'sacred') {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.zealSpent')} (max ${a.zeal.value})</label>
|
||||
<input type="number" name="zealSpent" value="${baseZeal}" min="1" max="${a.zeal.value}"/>
|
||||
</div>`);
|
||||
}
|
||||
if (mode === 'blood' && a.blood) {
|
||||
const max = a.blood.value;
|
||||
fields.push(`
|
||||
@@ -76,12 +69,21 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
|
||||
</div>`);
|
||||
}
|
||||
if (mode !== 'sacred') {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="castAsPrayer"/> ${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</label>
|
||||
<label>${game.i18n.localize('HBM.spellCast.diceModifier')}</label>
|
||||
<input type="number" name="diceModifier" value="0" step="1"/>
|
||||
<small>${game.i18n.localize('HBM.spellCast.diceModifierHint')}</small>
|
||||
</div>`);
|
||||
// Every known spell can be cast as a regular spell or as a prayer; sacred spells default to a prayer.
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.spellCast.route')}</label>
|
||||
<select name="castRoute">
|
||||
<option value="spell" ${mode === 'sacred' ? '' : 'selected'}>${game.i18n.localize('HBM.spellCast.routeSpell')}</option>
|
||||
<option value="prayer" ${mode === 'sacred' ? 'selected' : ''}>${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</option>
|
||||
</select>
|
||||
</div>`);
|
||||
}
|
||||
if (showHekate) {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
@@ -160,10 +162,11 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
}
|
||||
opts.manaSpent = Math.max(baseMana, spent);
|
||||
}
|
||||
if (fd.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1);
|
||||
if (fd.bloodSpent != null) opts.bloodSpent = Math.max(baseBlood, Number(fd.bloodSpent) || baseBlood);
|
||||
const diceMod = Math.trunc(Number(fd.diceModifier) || 0);
|
||||
if (diceMod !== 0) opts.diceModifier = diceMod;
|
||||
if (fd.hekateMode) opts.hekateMode = 'witch';
|
||||
if (fd.castAsPrayer) opts.castAsPrayer = true;
|
||||
opts.castAsPrayer = fd.castRoute === 'prayer';
|
||||
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
|
||||
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
|
||||
const groupRaw = String(fd.groupCasters ?? '').trim();
|
||||
|
||||
+25
-11
@@ -12,12 +12,12 @@
|
||||
* 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.
|
||||
* - addInsanity(actor, n) - adds Punkty Obłędu; at Próg Obłędu resets them,
|
||||
* rolls the illness k100 and fires `hbm.mentalIllness`.
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
import { madnessThreshold } from './rules';
|
||||
|
||||
export type AbyssDiscipline = 'aspects' | 'primal';
|
||||
|
||||
@@ -40,19 +40,33 @@ export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
|
||||
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;
|
||||
|
||||
/**
|
||||
* Punkty Obłędu (Choroby Psychiczne). On reaching Próg Obłędu (Dusza + 3, Krasnolud +1) the points reset
|
||||
* to 0 (the excess is lost) and the character gains one illness: k100 against the 50-illness table in the
|
||||
* vault (`rules/02. Klątwa Otchłani/Rozdział IV - Choroby Psychiczne.md`), re-rolling duplicates. The table
|
||||
* itself is not copied here, so the roll is posted to chat for the GM and `hbm.mentalIllness` is fired.
|
||||
*/
|
||||
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 attrs = actor.system.attributes as any;
|
||||
const before = (attrs.insanity ?? 0) as number;
|
||||
const after = before + n;
|
||||
const raceItem = (actor as any).items?.find?.((i: any) => i.type === 'race');
|
||||
const raceId = String(raceItem?.system?.raceId ?? raceItem?.name ?? (actor as any).system?.details?.raceId ?? '');
|
||||
const threshold = madnessThreshold(Number(attrs.soul?.value ?? 0), raceId);
|
||||
if (after < threshold) {
|
||||
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: [] };
|
||||
}
|
||||
return { before, after, thresholdsCrossed: [...crossed] };
|
||||
await actor.update({ 'system.attributes.insanity': 0 });
|
||||
const roll = new Roll('1d100');
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({
|
||||
flavor: `${game.i18n.localize('HBM.insanity.illnessRoll')} - ${actor.name}`,
|
||||
speaker: ChatMessage.getSpeaker({ actor: actor as unknown as Actor }),
|
||||
});
|
||||
Hooks.callAll('hbm.mentalIllness', actor, { before, after, threshold, roll: roll.total });
|
||||
return { before, after: 0, thresholdsCrossed: [threshold] };
|
||||
}
|
||||
|
||||
/** Roll d100 against a named table inside the abyss roll-tables pack. */
|
||||
|
||||
@@ -85,6 +85,7 @@ async function onApplyDamage(btn: HTMLElement): Promise<void> {
|
||||
shieldMultiplier: profile?.shieldMultiplier,
|
||||
destroyMagicalDefenses: profile?.destroyMagicalDefenses,
|
||||
minDamageAfterArmor: btn.dataset.minAfterArmor === 'true',
|
||||
fire: btn.dataset.fire === 'true',
|
||||
} as any);
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -14,7 +14,7 @@
|
||||
*/
|
||||
|
||||
import { DamageType } from '../constants';
|
||||
import { triggersStun, concentrationTest, runicArmorPointsDisabled } from './rules';
|
||||
import { triggersStun, concentrationTest, runicArmorPointsDisabled, hardenedFireDamage, actorHasTalent } from './rules';
|
||||
import { gainKrewFromWeaponDamage } from './blood-magic';
|
||||
|
||||
export interface ApplyDamageOptions {
|
||||
@@ -39,6 +39,8 @@ export interface ApplyDamageOptions {
|
||||
woundKinds?: ('slashing' | 'piercing' | 'blunt')[];
|
||||
/** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */
|
||||
sourceActor?: any;
|
||||
/** Fire damage: a Hartowany w Ogniu target takes 2 less per hit (min 0). */
|
||||
fire?: boolean;
|
||||
postChat?: boolean;
|
||||
}
|
||||
|
||||
@@ -68,7 +70,8 @@ interface ActorLike {
|
||||
}
|
||||
|
||||
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
|
||||
const total = Math.max(0, Math.floor(opts.amount));
|
||||
let total = Math.max(0, Math.floor(opts.amount));
|
||||
if (opts.fire && actorHasTalent(actor as any, 'Hartowany w Ogniu')) total = hardenedFireDamage(total);
|
||||
const a = actor.system.attributes;
|
||||
const updates: Record<string, unknown> = {};
|
||||
const report: DamageReport = {
|
||||
|
||||
+109
-2
@@ -4,8 +4,8 @@
|
||||
*
|
||||
* Rounding convention: everything fractional rounds UP, except
|
||||
* - new-spell mana cost (nearest, min 1),
|
||||
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1),
|
||||
* - fire damage with Hartowany w Ogniu (halved, DOWN).
|
||||
* - Nadczarowanie bonus from level 3 (halved, DOWN, min 1).
|
||||
* (Hartowany w Ogniu is a flat -2 per fire hit since the 2026-10-26 sync, no rounding.)
|
||||
*/
|
||||
|
||||
/** Odetchnięcie: at most twice between two Długie Odpoczynki. */
|
||||
@@ -133,6 +133,108 @@ export function bloodToZeal(blood: number): number {
|
||||
return Math.floor(Math.max(0, blood) / 5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2026-10-26 sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WornArmorPiece {
|
||||
/** `armor.armorType` of an equipped gear item. */
|
||||
armorType: string;
|
||||
/** Special-case item that never hampers casting (Średnia zbroja kultysty, Napierśnik Ninurty). */
|
||||
ignoresCastingPenalty?: boolean;
|
||||
}
|
||||
|
||||
export interface CastingArmorPenalty {
|
||||
/** Dice removed from the Magia test (positive number). */
|
||||
dice: number;
|
||||
/** Extra Mana per spell, also when overcasting. */
|
||||
mana: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zbroja a rzucanie zaklęć: Średni -1 die; Ciężki -3 dice and +2 Mana; Tarcza -1 die (stacks).
|
||||
* Pancerz Wiary removes the armour penalties (dice AND Mana) but never the shield penalty.
|
||||
* Runiczny Pancerz and Magiczne Osłony are not gear and never carry a penalty.
|
||||
*/
|
||||
export function castingArmorPenalty(worn: WornArmorPiece[], faithArmor = false): CastingArmorPenalty {
|
||||
let dice = 0;
|
||||
let mana = 0;
|
||||
for (const piece of worn) {
|
||||
if (piece.ignoresCastingPenalty) continue;
|
||||
if (piece.armorType === 'shield') dice += 1;
|
||||
else if (faithArmor) continue;
|
||||
else if (piece.armorType === 'medium') dice += 1;
|
||||
else if (piece.armorType === 'heavy') { dice += 3; mana += 2; }
|
||||
}
|
||||
return { dice, mana };
|
||||
}
|
||||
|
||||
/** Does the actor own a talent item with this id / name? (case-insensitive) */
|
||||
export function actorHasTalent(actor: unknown, 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;
|
||||
}
|
||||
|
||||
/** Prayer or regular spell? Sacred spells default to a prayer; everything else defaults to a regular spell. */
|
||||
export function resolveCastAsPrayer(
|
||||
spell: { system: { castingMode?: string } },
|
||||
opts: { castAsPrayer?: boolean; hekateMode?: string },
|
||||
): boolean {
|
||||
if (opts.castAsPrayer != null) return opts.castAsPrayer;
|
||||
return (spell.system.castingMode ?? 'standard') === 'sacred' && opts.hekateMode !== 'witch';
|
||||
}
|
||||
|
||||
/** Hartowany w Ogniu: each fire hit deals 2 less (min 0). */
|
||||
export function hardenedFireDamage(amount: number): number {
|
||||
return Math.max(0, Math.trunc(amount) - 2);
|
||||
}
|
||||
|
||||
/** Próg Obłędu: Dusza + 3 for everyone, Krasnolud +1. */
|
||||
export function madnessThreshold(soul: number, raceId = ''): number {
|
||||
return Math.max(0, Math.trunc(soul)) + 3 + (raceId.toLowerCase() === 'dwarf' || raceId.toLowerCase() === 'krasnolud' ? 1 : 0);
|
||||
}
|
||||
|
||||
/** Golem limit: floor(Umysł / 2), min 1 (spell golems count too; two straw golems count as one). */
|
||||
export function golemLimit(mind: number): number {
|
||||
return Math.max(1, Math.floor(Math.max(0, mind) / 2));
|
||||
}
|
||||
|
||||
/** Upadek: 1 per full metre above 2 m, cap 20; Ciało (Atletyka) 4:1 halves it (rounded down). */
|
||||
export function fallDamage(metres: number, halved = false): number {
|
||||
const raw = Math.min(20, Math.max(0, Math.floor(metres) - 2));
|
||||
return halved ? Math.floor(raw / 2) : raw;
|
||||
}
|
||||
/** A fall of 4 m or more knocks the target Przewrócony unless the landing was cushioned. */
|
||||
export const fallKnocksProne = (metres: number, cushioned = false) => !cushioned && metres >= 4;
|
||||
|
||||
/** Test Przeżycia (Umierający): tier by damage past the death threshold; each hit while dying raises S's T by 1. */
|
||||
export function survivalTest(damagePastThreshold: number, hitsWhileDying = 0): { threshold: number; successes: number } {
|
||||
const successes = damagePastThreshold <= 2 ? 1 : damagePastThreshold <= 5 ? 2 : 3;
|
||||
return { threshold: 4 + Math.max(0, hitsWhileDying), successes };
|
||||
}
|
||||
|
||||
/** Pełny Unik: Unik tests are one Stopień easier until the start of the next turn. */
|
||||
export const FULL_DODGE_THRESHOLD_DELTA = -1;
|
||||
|
||||
/**
|
||||
* Zapał regen at the start of a turn is blocked when since the start of the previous turn the character
|
||||
* took a PAID extra Action or a PAID extra Immediate Action (beyond the free one). Zapał spent on prayers
|
||||
* or Talents with their own Zapał cost does not count; Eliksir Szybkości's extra Action is free.
|
||||
*/
|
||||
export function zealRegenBlocked(paidExtraActions: number, paidExtraImmediateActions: number): boolean {
|
||||
return paidExtraActions > 0 || paidExtraImmediateActions > 0;
|
||||
}
|
||||
|
||||
/** Spells of category Obrażenia: +1 damage once, after multipliers/division; not for periodic/overcast/curses/artifacts. */
|
||||
export const DAMAGE_CATEGORY_BONUS = 1;
|
||||
|
||||
/**
|
||||
* Runiczny Pancerz – points disabled by ONE hit (hits are never added together).
|
||||
* Default: a hit of more than 5 damage (after the Runiczny Pancerz step) disables 1 point.
|
||||
@@ -259,3 +361,8 @@ export function negotiationBand(successes: number): number {
|
||||
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
|
||||
return sourceId && targetId && sourceId === targetId ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Casting test pool with the player's +/- dice modifier (whole dice, never below 0). */
|
||||
export function applyDiceModifier(pool: number, modifier?: number): number {
|
||||
return Math.max(0, Math.trunc(Number(pool) || 0) + Math.trunc(Number(modifier) || 0));
|
||||
}
|
||||
|
||||
+57
-36
@@ -16,7 +16,8 @@
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
|
||||
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, SPELL_DAMAGE_PROFILES } from './rules';
|
||||
import { manaToBorrow, exhaustionDice, manaExhaustionTier, overLimitTest, applyDiceModifier, castingArmorPenalty, actorHasTalent, resolveCastAsPrayer, SPELL_DAMAGE_PROFILES } from './rules';
|
||||
import type { CastingArmorPenalty } from './rules';
|
||||
import { validateCast } from './spell-validation';
|
||||
import type { ValidationResult } from './spell-validation';
|
||||
import { rollSpellDamage } from './spell-damage';
|
||||
@@ -49,12 +50,13 @@ export interface SpellLike {
|
||||
deity?: string;
|
||||
manaCost: number;
|
||||
bloodCost?: number;
|
||||
complexityLevel: number;
|
||||
isSuperspell?: boolean;
|
||||
requiresGroupCast?: boolean;
|
||||
minCasters?: number;
|
||||
nonCombatOnly?: boolean;
|
||||
damageBase?: string;
|
||||
/** Spell category is Obrażenia (+1 damage). Not carried by vault frontmatter yet; set per spell. */
|
||||
damageCategory?: boolean;
|
||||
damageType?: string;
|
||||
ignoresArmor?: boolean;
|
||||
statusEffects?: string[];
|
||||
@@ -72,10 +74,17 @@ export interface CastOptions {
|
||||
zealSpent?: number;
|
||||
bloodSpent?: number;
|
||||
hekateMode?: 'sacred' | 'witch';
|
||||
/**
|
||||
* Cast as a prayer (Dusza + Oddanie, Mana + 1 Zapał, cannot be dispelled) instead of a regular spell
|
||||
* (Magia + Zdolności Magiczne, Mana only, can be dispelled). Every known spell may be cast either way;
|
||||
* for `castingMode: sacred` spells an unset value means prayer (the usual route for a priest).
|
||||
*/
|
||||
castAsPrayer?: boolean;
|
||||
bypassSuperspellWarning?: boolean;
|
||||
bypassNonCombatBlock?: boolean;
|
||||
groupCasters?: string[];
|
||||
/** Modifier to the number of dice in the casting test (+/-, pool never below 0). */
|
||||
diceModifier?: number;
|
||||
/** Override required successes (used by variableSuccesses summons). */
|
||||
requiredOverride?: number;
|
||||
speaker?: ChatMessage.SpeakerData;
|
||||
@@ -109,7 +118,22 @@ async function recordRoll(rolls: CastRollEntry[], roll: HbmTSRoll, label: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
|
||||
/** Equipped armour / shield worn by the actor, as the casting-penalty helper wants it. */
|
||||
export function castingPenaltyFor(actor: CastableActor): CastingArmorPenalty {
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
const worn: Array<{ armorType: string; ignoresCastingPenalty?: boolean }> = [];
|
||||
for (const it of items ?? []) {
|
||||
if (it.type !== 'gear' || !it.system?.equipped || it.system?.category !== 'armor') continue;
|
||||
worn.push({
|
||||
armorType: String(it.system.armor?.armorType ?? 'light'),
|
||||
ignoresCastingPenalty: !!it.system.armor?.ignoresCastingPenalty,
|
||||
});
|
||||
}
|
||||
return castingArmorPenalty(worn, actorHasTalent(actor, 'Pancerz Wiary'));
|
||||
}
|
||||
|
||||
export async function castSpell(actor: CastableActor, spell: SpellLike, rawOpts: CastOptions = {}): Promise<CastResult> {
|
||||
const opts: CastOptions = { ...rawOpts, castAsPrayer: resolveCastAsPrayer(spell, rawOpts) };
|
||||
// 1. Validate
|
||||
const validation = validateCast(actor, spell, opts);
|
||||
if (!validation.ok) {
|
||||
@@ -127,9 +151,10 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
|
||||
let baseRoll: HbmTSRoll | null = null;
|
||||
const rolls: CastRollEntry[] = [];
|
||||
if (mode === 'sacred') {
|
||||
// Sacred spells are cast like any other spell: as a prayer (default) or as a regular spell.
|
||||
baseRoll = opts.hekateMode === 'witch'
|
||||
? await castWitch(actor, spell, opts, rolls)
|
||||
: await castSacred(actor, spell, opts, rolls);
|
||||
: await castStandard(actor, spell, opts, rolls);
|
||||
} else if (mode === 'witch') {
|
||||
baseRoll = await castWitch(actor, spell, opts, rolls);
|
||||
} else if (mode === 'blood') {
|
||||
@@ -144,7 +169,11 @@ export async function castSpell(actor: CastableActor, spell: SpellLike, opts: Ca
|
||||
if (baseRoll?.ts?.isSuccess) {
|
||||
// 4. Damage
|
||||
if (spell.system.damageBase) {
|
||||
damageRoll = await rollSpellDamage(spell.system.damageBase, { actor, spellName: spell.name });
|
||||
damageRoll = await rollSpellDamage(spell.system.damageBase, {
|
||||
actor,
|
||||
spellName: spell.name,
|
||||
categoryBonus: !!spell.system.damageCategory,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Triggers
|
||||
@@ -198,6 +227,8 @@ async function renderCastCard(
|
||||
})),
|
||||
type: spell.system.damageType ?? 'magical',
|
||||
ignoresArmor: !!spell.system.ignoresArmor,
|
||||
// Fire damage: Hartowany w Ogniu takes 2 off each hit.
|
||||
fire: spell.system.school === 'elementsFire',
|
||||
// Magiczny Pocisk: at least 1 damage reaches Żywotność after Pancerz.
|
||||
minAfterArmor: ((spell as any).flags?.['hbm-rpg-v3']?.slug ?? (spell.system as any).slug) === 'magic-missile',
|
||||
// Shield-breakers / Szkarłatny Pocisk / Prawdziwa Dezintegracja treat the magical defences specially.
|
||||
@@ -258,7 +289,9 @@ async function renderCastCard(
|
||||
|
||||
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
|
||||
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
|
||||
const armorPenalty = castingPenaltyFor(actor);
|
||||
// Ciężki armour: +2 Mana per spell, overcast included.
|
||||
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost)) + armorPenalty.mana;
|
||||
const a = actor.system.attributes;
|
||||
|
||||
// Casting beyond the remaining daily pool is allowed: the missing mana is
|
||||
@@ -276,9 +309,11 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
|
||||
const magicDice = typeof a.magic.dicePool === 'number'
|
||||
? a.magic.dicePool
|
||||
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
|
||||
const pool = opts.castAsPrayer
|
||||
// Armour and shield penalties hit the Magia test only (a prayer rolls Dusza + Oddanie).
|
||||
const basePool = opts.castAsPrayer
|
||||
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
|
||||
const pool = applyDiceModifier(basePool, opts.diceModifier);
|
||||
const flavor = opts.castAsPrayer
|
||||
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
|
||||
: `${spell.name}`;
|
||||
@@ -344,32 +379,15 @@ async function rollIncompetencePenalty(actor: CastableActor, missing: number, ba
|
||||
});
|
||||
}
|
||||
|
||||
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): 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 recordRoll(rolls, roll, flavor);
|
||||
return roll;
|
||||
}
|
||||
|
||||
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions, rolls: CastRollEntry[]): Promise<HbmTSRoll | null> {
|
||||
const a = actor.system.attributes;
|
||||
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
|
||||
// Poziom Skomplikowania = number of Symbols in the spell.
|
||||
const required = Math.max(0, spell.system.components?.symbols?.length ?? 0);
|
||||
const maxSymbols = Math.ceil(a.magic.actual / 2);
|
||||
const armorPenalty = castingPenaltyFor(actor);
|
||||
|
||||
// Witch magic still has a mana cost.
|
||||
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
// Witch magic still has a mana cost (+2 in Ciężki armour).
|
||||
const baseCost = Math.max(0, spell.system.manaCost ?? 0) + armorPenalty.mana;
|
||||
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;
|
||||
@@ -379,9 +397,10 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
|
||||
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
|
||||
const basePool = opts.castAsPrayer
|
||||
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
|
||||
const pool = applyDiceModifier(basePool, opts.diceModifier);
|
||||
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})`;
|
||||
@@ -408,8 +427,9 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
|
||||
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);
|
||||
// Mana cost (some blood spells may also have one; +2 in Ciężki armour)
|
||||
const armorPenalty = castingPenaltyFor(actor);
|
||||
const manaCost = Math.max(0, spell.system.manaCost ?? 0) + (Math.max(0, spell.system.manaCost ?? 0) > 0 ? armorPenalty.mana : 0);
|
||||
if (manaCost > 0) {
|
||||
updates['system.attributes.mana.value'] = a.mana.value - manaCost;
|
||||
}
|
||||
@@ -421,9 +441,10 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
|
||||
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
|
||||
const basePool = opts.castAsPrayer
|
||||
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0) - armorPenalty.dice;
|
||||
const pool = applyDiceModifier(basePool, opts.diceModifier);
|
||||
const flavor = opts.castAsPrayer
|
||||
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
|
||||
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
|
||||
|
||||
@@ -13,10 +13,16 @@
|
||||
*/
|
||||
|
||||
import type { CastableActor } from './spell-cast';
|
||||
import { DAMAGE_CATEGORY_BONUS } from './rules';
|
||||
|
||||
export interface DamageContext {
|
||||
actor: CastableActor;
|
||||
spellName?: string;
|
||||
/**
|
||||
* Spell is of category Obrażenia: +1 damage, added once after multipliers/division
|
||||
* (not for periodic damage, overcast bonuses, other categories, curses or artifacts).
|
||||
*/
|
||||
categoryBonus?: boolean;
|
||||
}
|
||||
|
||||
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
|
||||
@@ -61,7 +67,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
|
||||
return String(v);
|
||||
});
|
||||
|
||||
return formula;
|
||||
return ctx.categoryBonus ? `(${formula}) + ${DAMAGE_CATEGORY_BONUS}` : formula;
|
||||
}
|
||||
|
||||
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
|
||||
import { resolveCastAsPrayer, actorHasTalent } from './rules';
|
||||
|
||||
export interface ValidationIssue {
|
||||
code: string;
|
||||
@@ -29,19 +30,7 @@ function getActorRaceId(actor: CastableActor): string {
|
||||
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 matchDiscipline(disciplineId: string, targetId: string): boolean {
|
||||
export function matchDiscipline(disciplineId: string, targetId: string): boolean {
|
||||
const d1 = disciplineId.toLowerCase().trim();
|
||||
const d2 = targetId.toLowerCase().trim();
|
||||
if (d1 === d2) return true;
|
||||
@@ -52,6 +41,10 @@ function matchDiscipline(disciplineId: string, targetId: string): boolean {
|
||||
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'],
|
||||
// Vault spells say `runic`; the discipline item id is `runes`.
|
||||
runes: ['runes', 'runic', 'magia runiczna', 'runic magic'],
|
||||
sacred: ['sacred', 'magia sakralna', 'sacred magic'],
|
||||
sacredexorcism: ['sacredexorcism', 'magia sakralna - egzorcyzmy', 'egzorcyzmy', 'exorcism', 'exorcisms', 'sacred - exorcisms'],
|
||||
};
|
||||
|
||||
let key1: string | null = null;
|
||||
@@ -63,6 +56,8 @@ function matchDiscipline(disciplineId: string, targetId: string): boolean {
|
||||
}
|
||||
|
||||
if (key1 && key2 && key1 === key2) return true;
|
||||
// Egzorcyzmy are a sub-discipline of Magia Sakralna: an exorcist counts as Sacred, not the other way round.
|
||||
if (key1 === 'sacredexorcism' && key2 === 'sacred') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -98,22 +93,17 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
|
||||
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 (mode === 'standard' || mode === 'witch' || mode === 'sacred') {
|
||||
if (manaSpent > Number(a.mana?.value ?? 0)) {
|
||||
// Allowed since the 2026-10-20 sync: the shortfall is borrowed (Wyczerpanie Many).
|
||||
warnings.push({ code: 'borrow-mana', message: 'Not enough mana - the shortfall will be borrowed (Wyczerpanie Many)', i18nKey: 'HBM.spellCast.borrowMana' });
|
||||
}
|
||||
}
|
||||
if (opts.castAsPrayer) {
|
||||
if (1 > Number(a.zeal?.value ?? 0)) {
|
||||
// Prayer (any spell, sacred by default): Mana + 1 Zapał. A regular spell costs no Zapał.
|
||||
const asPrayer = resolveCastAsPrayer(spell, opts);
|
||||
if (asPrayer && 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' });
|
||||
}
|
||||
}
|
||||
if (mode === 'blood') {
|
||||
if (bloodSpent > Number(a.blood?.value ?? 0)) {
|
||||
errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' });
|
||||
@@ -206,7 +196,8 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
|
||||
|
||||
// Witch symbol cap
|
||||
if (mode === 'witch' || opts.hekateMode === 'witch') {
|
||||
const required = Math.max(0, Number(sys.complexityLevel ?? sys.components?.symbols?.length ?? 0));
|
||||
// Poziom Skomplikowania = number of Symbols in the spell.
|
||||
const required = Math.max(0, Number(sys.components?.symbols?.length ?? 0));
|
||||
const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
|
||||
if (required > maxSymbols) {
|
||||
errors.push({
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"id": "hbm-rpg-v3",
|
||||
"title": "Homebrew Magic: RPG v3",
|
||||
"description": "System Foundry VTT dla Homebrew Magic: Role Playing Game v3 - autorska gra fabularna z mechaniką puli kości d6 (TS - Trudność:Sukcesy).",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.2",
|
||||
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json",
|
||||
"download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
|
||||
"compatibility": {
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{{#if damage.targets}}
|
||||
<div class="hbm-card-targets">
|
||||
{{#each damage.targets as |t|}}
|
||||
<button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}" data-profile="{{../damage.profile}}">
|
||||
<button type="button" class="hbm-action" data-action="hbm-apply-damage" data-target-uuid="{{t.uuid}}" data-amount="{{../damage.total}}" data-type="{{../damage.type}}" data-ignores-armor="{{../damage.ignoresArmor}}" data-min-after-armor="{{../damage.minAfterArmor}}" data-profile="{{../damage.profile}}" data-fire="{{../damage.fire}}">
|
||||
⚔ {{t.name}}
|
||||
</button>
|
||||
{{/each}}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
</label>
|
||||
<label>{{localize 'HBM.gear.strengthRequirement'}}: <input type="number" name="system.armor.strengthRequirement" value="{{system.armor.strengthRequirement}}" min="0"/></label>
|
||||
<label><input type="checkbox" name="system.armor.stealthDisadvantage" {{#if system.armor.stealthDisadvantage}}checked{{/if}}/> {{localize 'HBM.gear.stealthDisadvantage'}}</label>
|
||||
<label title="{{localize 'HBM.gear.ignoresCastingPenaltyHint'}}"><input type="checkbox" name="system.armor.ignoresCastingPenalty" {{#if system.armor.ignoresCastingPenalty}}checked{{/if}}/> {{localize 'HBM.gear.ignoresCastingPenalty'}}</label>
|
||||
</fieldset>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
<label>{{localize 'HBM.spell.damageBase'}}:
|
||||
<input type="text" name="system.damageBase" value="{{system.damageBase}}" placeholder="np. 1d6+magicalAbilities" />
|
||||
</label>
|
||||
<label><input type="checkbox" name="system.damageCategory" {{#if system.damageCategory}}checked{{/if}}/> {{localize 'HBM.spell.damageCategory'}}</label>
|
||||
<label>{{localize 'HBM.spell.damageType'}}:
|
||||
<select name="system.damageType">
|
||||
{{#each @root.choices.damageTypes as |label key|}}
|
||||
|
||||
@@ -104,3 +104,14 @@ describe('blood, elixirs, trade', () => {
|
||||
expect([0, 1, 2, 3, 4, 5, 6, 7, 12].map(negotiationBand)).toEqual([0, 1, 1, 2, 2, 3, 3, 4, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDiceModifier', () => {
|
||||
it('adds a signed modifier and never goes below 0', async () => {
|
||||
const { applyDiceModifier } = await import('../../src/logic/rules');
|
||||
expect(applyDiceModifier(5, 2)).toBe(7);
|
||||
expect(applyDiceModifier(5, -2)).toBe(3);
|
||||
expect(applyDiceModifier(2, -5)).toBe(0);
|
||||
expect(applyDiceModifier(4)).toBe(4);
|
||||
expect(applyDiceModifier(4, 1.9)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchDiscipline } from '../../src/logic/spell-validation';
|
||||
|
||||
describe('matchDiscipline: Magia Sakralna - Egzorcyzmy', () => {
|
||||
it('an exorcist counts as Sacred Magic for casting', () => {
|
||||
expect(matchDiscipline('sacredExorcism', 'sacred')).toBe(true);
|
||||
expect(matchDiscipline('Magia Sakralna - Egzorcyzmy', 'sacred')).toBe(true);
|
||||
expect(matchDiscipline('Egzorcyzmy', 'Magia Sakralna')).toBe(true);
|
||||
});
|
||||
|
||||
it('a plain Sacred mage is not an exorcist', () => {
|
||||
expect(matchDiscipline('sacred', 'sacredExorcism')).toBe(false);
|
||||
expect(matchDiscipline('Magia Sakralna', 'egzorcyzmy')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not leak into other disciplines', () => {
|
||||
expect(matchDiscipline('sacredExorcism', 'witch')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user