17 changed files with 145 additions and 15 deletions
+20
View File
@@ -5,6 +5,26 @@ 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 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). this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### 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`.
## [1.5.1] - 2026-10-22
### Added
- Weapons have wound kinds (`system.weapon.woundKinds`: `slashing` sieczne / `piercing` kłute / `blunt` obuchowe, several allowed, empty = unspecified), editable on the gear sheet as a multi-select. Matches Foundry Sync Brief §18.
- Gear parser reads `wound` (string, comma list or YAML list; Polish or English words) from vault weapon notes. Unknown values are dropped; nothing is guessed.
- Packs rebuilt from the current vault: Sztylet piercing+slashing, Bicz slashing, Laska blunt, bows and Włócznia piercing; generic one-/two-handed entries list all three (pick the real type per weapon).
### Changed
- `applyDamage` takes `woundKinds` (array) instead of a single kind; passive Krew applies when any is slashing or piercing. Blunt (and unarmed) never gives Krew.
- Vault sync of 2026-10-22 needs no further logic changes: runic armour wording, Zakazana Osłona retaliation, Rozerwanie Run artifact destruction and free Immediate Action for Kradzież Życia / Złożenie w Ofierze were already covered in 1.5.0 (spell, talent and discipline text arrives through the rebuilt packs).
### Notes
- Callers of `applyDamage` still need to pass the equipped weapon's `woundKinds`.
- Kości Losu use is now an Immediate Action that counts toward the limit (vault `Zużywanie Ładunków`); the system has no Fate-dice action code yet.
## [1.5.0] - 2026-10-20 ## [1.5.0] - 2026-10-20
Vault answers (Foundry Sync Brief §17): blood magic and Runiczny Pancerz. Vault answers (Foundry Sync Brief §17): blood magic and Runiczny Pancerz.
+9
View File
@@ -239,6 +239,13 @@
"physical": "Physical", "physical": "Physical",
"magical": "Magical", "magical": "Magical",
"environmental": "Environmental", "environmental": "Environmental",
"woundKinds": "Wound kind",
"woundKindsHint": "Several allowed. Only slashing and piercing wounds grant talent Krew (Respect for Life, Crimson Duelist). Empty = unspecified.",
"wound": {
"slashing": "Slashing",
"piercing": "Piercing",
"blunt": "Blunt"
},
"properties": "Properties", "properties": "Properties",
"damageReduction": "Damage Reduction", "damageReduction": "Damage Reduction",
"damageReductionBonus": "DR Bonus", "damageReductionBonus": "DR Bonus",
@@ -356,6 +363,8 @@
}, },
"spellCast": { "spellCast": {
"title": "Casting spell", "title": "Casting spell",
"diceModifier": "Dice modifier",
"diceModifierHint": "+/- dice in the casting test (the pool never drops below 0)",
"manaSpent": "Mana spent", "manaSpent": "Mana spent",
"cannotBeUnraveled": "Cannot be unraveled", "cannotBeUnraveled": "Cannot be unraveled",
"zealSpent": "Zeal spent", "zealSpent": "Zeal spent",
+9
View File
@@ -259,6 +259,13 @@
"physical": "Fizyczne", "physical": "Fizyczne",
"magical": "Magiczne", "magical": "Magiczne",
"environmental": "Środowiskowe", "environmental": "Środowiskowe",
"woundKinds": "Rodzaj rany",
"woundKindsHint": "Można wybrać kilka. Tylko rany sieczne i kłute dają Krew z talentów (Szacunek do Życia, Krwisty Pojedynkowicz). Puste = nieokreślony.",
"wound": {
"slashing": "Sieczne",
"piercing": "Kłute",
"blunt": "Obuchowe"
},
"properties": "Właściwości", "properties": "Właściwości",
"damageReduction": "Redukcja Obrażeń", "damageReduction": "Redukcja Obrażeń",
"damageReductionBonus": "Premia do Redukcji", "damageReductionBonus": "Premia do Redukcji",
@@ -376,6 +383,8 @@
}, },
"spellCast": { "spellCast": {
"title": "Rzucanie zaklęcia", "title": "Rzucanie zaklęcia",
"diceModifier": "Modyfikator liczby kości",
"diceModifierHint": "+/- kości w Teście rzucania (liczba kości nie spadnie poniżej 0)",
"manaSpent": "Wydana mana", "manaSpent": "Wydana mana",
"cannotBeUnraveled": "Nie da się rozpleść", "cannotBeUnraveled": "Nie da się rozpleść",
"cast": "Rzuć", "cast": "Rzuć",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hbm-rpg-v3-foundry", "name": "hbm-rpg-v3-foundry",
"version": "1.5.0", "version": "1.5.1",
"description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3", "description": "Foundry VTT system for Homebrew Magic: Role Playing Game v3",
"private": true, "private": true,
"type": "module", "type": "module",
+32 -1
View File
@@ -38,6 +38,27 @@ export const parseGear: ParserFn = async (ctx: ParserContext): Promise<ParsedDoc
return docs; return docs;
}; };
type WoundKind = 'slashing' | 'piercing' | 'blunt';
/**
* Vault `wound` frontmatter → woundKinds. Accepts one value, a comma list ("piercing, slashing"),
* or a YAML list (flattened to a comma list by extractMarkdown). Unknown values are dropped,
* absent stays empty – never guessed.
*/
export function parseWoundKinds(raw: string | undefined): WoundKind[] {
const map: Record<string, WoundKind> = {
slashing: 'slashing', sieczne: 'slashing', sieczna: 'slashing',
piercing: 'piercing', kłute: 'piercing', kłuta: 'piercing', kluta: 'piercing', kłujące: 'piercing',
blunt: 'blunt', obuchowe: 'blunt', obuchowa: 'blunt',
};
const out: WoundKind[] = [];
for (const part of (raw ?? '').split(',')) {
const kind = map[part.trim().toLowerCase()];
if (kind && !out.includes(kind)) out.push(kind);
}
return out;
}
function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } { function extractMarkdown(raw: string): { frontmatter: Record<string, string>; content: string } {
const fm: Record<string, string> = {}; const fm: Record<string, string> = {};
let content = raw; let content = raw;
@@ -48,9 +69,17 @@ function extractMarkdown(raw: string): { frontmatter: Record<string, string>; co
const fmText = raw.slice(3, endIdx).trim(); const fmText = raw.slice(3, endIdx).trim();
content = raw.slice(endIdx + 3).trim(); content = raw.slice(endIdx + 3).trim();
const lines = fmText.split('\n'); const lines = fmText.split(/\r?\n/);
let listKey = '';
for (const line of lines) { for (const line of lines) {
// "key:" followed by " - item" lines → comma-joined value.
const item = line.match(/^\s+-\s+(.*)$/);
if (item && listKey) {
fm[listKey] = fm[listKey] ? `${fm[listKey]}, ${item[1].trim()}` : item[1].trim();
continue;
}
const colonIdx = line.indexOf(':'); const colonIdx = line.indexOf(':');
listKey = colonIdx > -1 && !line.slice(colonIdx + 1).trim() ? line.slice(0, colonIdx).trim() : '';
if (colonIdx > -1) { if (colonIdx > -1) {
const key = line.slice(0, colonIdx).trim(); const key = line.slice(0, colonIdx).trim();
let val = line.slice(colonIdx + 1).trim(); let val = line.slice(colonIdx + 1).trim();
@@ -91,6 +120,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
weapon: { weapon: {
damage: '', damage: '',
damageType: 'physical', damageType: 'physical',
woundKinds: [],
properties: [], properties: [],
}, },
armor: { armor: {
@@ -105,6 +135,7 @@ function buildGearDoc(name: string, fm: Record<string, string>, content: string,
if (category === 'weapon') { if (category === 'weapon') {
system.weapon.damage = fm.damage || fm.obrazenia || ''; system.weapon.damage = fm.damage || fm.obrazenia || '';
system.weapon.woundKinds = parseWoundKinds(fm.wound || fm.rana);
if (fm.traits || fm.cechy) { if (fm.traits || fm.cechy) {
system.weapon.properties = parseList(fm.traits || fm.cechy); system.weapon.properties = parseList(fm.traits || fm.cechy);
} }
+5
View File
@@ -192,6 +192,11 @@ export type GearCategory = (typeof GEAR_CATEGORIES)[number];
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const; export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number]; export type DamageType = (typeof DAMAGE_TYPES)[number];
/** Weapon wound kinds (sieczne / kłute / obuchowe). A weapon may have several; none = unspecified.
* Only slashing and piercing wounds feed the passive Krew talents. */
export const WOUND_KINDS = ['slashing', 'piercing', 'blunt'] as const;
export type WoundKind = (typeof WOUND_KINDS)[number];
/** Armor types */ /** Armor types */
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const; export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
export type ArmorType = (typeof ARMOR_TYPES)[number]; export type ArmorType = (typeof ARMOR_TYPES)[number];
+3 -1
View File
@@ -6,7 +6,7 @@ import {
makeArrayField, makeArrayField,
fields, fields,
} from './fields'; } from './fields';
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants'; import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES, WOUND_KINDS } from '../constants';
/** /**
* Unified Gear item - discriminated by `category`. * Unified Gear item - discriminated by `category`.
@@ -39,6 +39,8 @@ export class GearData extends foundry.abstract.TypeDataModel {
choices: DAMAGE_TYPES as unknown as readonly string[], choices: DAMAGE_TYPES as unknown as readonly string[],
}), }),
properties: makeArrayField(new f.StringField({ blank: false })), properties: makeArrayField(new f.StringField({ blank: false })),
// Rodzaj rany: slashing (sieczne) / piercing (kłute) / blunt (obuchowe); several allowed, empty = unspecified.
woundKinds: makeArrayField(new f.StringField({ blank: false, choices: WOUND_KINDS as unknown as string[] })),
}), }),
armor: new f.SchemaField({ armor: new f.SchemaField({
+8
View File
@@ -76,6 +76,12 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/> <input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
</div>`); </div>`);
} }
fields.push(`
<div class="form-group">
<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>`);
if (mode !== 'sacred') { if (mode !== 'sacred') {
fields.push(` fields.push(`
<div class="form-group"> <div class="form-group">
@@ -162,6 +168,8 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
} }
if (fd.zealSpent != null) opts.zealSpent = Math.max(1, Number(fd.zealSpent) || 1); 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); 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.hekateMode) opts.hekateMode = 'witch';
if (fd.castAsPrayer) opts.castAsPrayer = true; if (fd.castAsPrayer) opts.castAsPrayer = true;
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true; if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
+2 -2
View File
@@ -180,7 +180,7 @@ export async function resolveLifeSteal(actor: CastableActor, hpActuallyLost: num
/** /**
* Złożenie w Ofierze: 1 Zapał, free Akcja Natychmiastowa, once per round. Triggers when your attack * Złożenie w Ofierze: 1 Zapał, free Akcja Natychmiastowa, once per round. Triggers when your attack
* kills a target with a cutting / piercing wound within 1 m. `successes` are the Sukcesy of the * kills a target with a slashing / piercing wound within 1 m. `successes` are the Sukcesy of the
* killing attack's Test: heal Sukcesy + Magia, gain Krew = Sukcesy. * killing attack's Test: heal Sukcesy + Magia, gain Krew = Sukcesy.
*/ */
export async function sacrifice(actor: CastableActor, successes: number): Promise<BloodActionResult> { export async function sacrifice(actor: CastableActor, successes: number): Promise<BloodActionResult> {
@@ -256,7 +256,7 @@ export async function absorbBlood(
/** /**
* Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz), * Passive Krew (talents): 1 per 4 HP lost (Szacunek do Życia) / 1 per 2 HP (Krwisty Pojedynkowicz),
* by either side, only from cutting or piercing weapons. Call for the victim and the attacker. * by either side, only from slashing or piercing weapons. Call for the victim and the attacker.
*/ */
export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> { export async function gainKrewFromWeaponDamage(actor: CastableActor, hpLost: number): Promise<number> {
const blood = (actor.system.attributes as any)?.blood; const blood = (actor.system.attributes as any)?.blood;
+4 -3
View File
@@ -35,7 +35,8 @@ export interface ApplyDamageOptions {
/** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */ /** Rozerwanie Run: permanently destroys N Runiczny Pancerz points (lowers its maximum). */
destroyRunicPoints?: number; destroyRunicPoints?: number;
/** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */ /** Wound kind of a weapon hit (gear data has none yet – callers pass it); drives passive Krew. */
woundKind?: 'cutting' | 'piercing' | 'blunt'; /** Wound kinds of the attacking weapon; only slashing / piercing give passive Krew. */
woundKinds?: ('slashing' | 'piercing' | 'blunt')[];
/** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */ /** Attacker, for passive Krew gain (Szacunek do Życia / Krwisty Pojedynkowicz). */
sourceActor?: any; sourceActor?: any;
postChat?: boolean; postChat?: boolean;
@@ -161,8 +162,8 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
if (Object.keys(updates).length > 0) await actor.update(updates); if (Object.keys(updates).length > 0) await actor.update(updates);
// Passive Krew from cutting / piercing weapons (talents): victim and attacker side. // Passive Krew from slashing / piercing weapons (talents): victim and attacker side.
if (report.hpDamage > 0 && (opts.woundKind === 'cutting' || opts.woundKind === 'piercing')) { if (report.hpDamage > 0 && (opts.woundKinds ?? []).some((k) => k === 'slashing' || k === 'piercing')) {
report.krewGained = { report.krewGained = {
victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage), victim: await gainKrewFromWeaponDamage(actor as any, report.hpDamage),
source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0, source: opts.sourceActor ? await gainKrewFromWeaponDamage(opts.sourceActor, report.hpDamage) : 0,
+6 -1
View File
@@ -196,7 +196,7 @@ export function greatSacrificeResult(successes: number, highestAttribute: number
if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) }; if (successes > highestAttribute) return { kills: true, heal: 0, krew: Math.max(0, targetHealth) };
return { kills: false, heal: Math.max(0, successes), krew: 0 }; return { kills: false, heal: Math.max(0, successes), krew: 0 };
} }
/** Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (cutting/piercing only). */ /** Passive Krew: Szacunek do Życia 1 per 4 HP, Krwisty Pojedynkowicz 1 per 2 HP (slashing/piercing only). */
export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number { export function krewFromWeaponHpLoss(hpLost: number, talent: KrewTalent | null): number {
if (talent === 'respect-for-life') return Math.floor(Math.max(0, hpLost) / 4); if (talent === 'respect-for-life') return Math.floor(Math.max(0, hpLost) / 4);
if (talent === 'krwisty-pojedynkowicz') return Math.floor(Math.max(0, hpLost) / 2); if (talent === 'krwisty-pojedynkowicz') return Math.floor(Math.max(0, hpLost) / 2);
@@ -259,3 +259,8 @@ export function negotiationBand(successes: number): number {
export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number { export function fearThresholdMod(sourceId: string | undefined, targetId: string | undefined): number {
return sourceId && targetId && sourceId === targetId ? 1 : 0; 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));
}
+10 -5
View File
@@ -16,7 +16,7 @@
import { HbmTSRoll } from '../dice/ts-roll'; import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants'; 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, SPELL_DAMAGE_PROFILES } from './rules';
import { validateCast } from './spell-validation'; import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation'; import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage'; import { rollSpellDamage } from './spell-damage';
@@ -76,6 +76,8 @@ export interface CastOptions {
bypassSuperspellWarning?: boolean; bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean; bypassNonCombatBlock?: boolean;
groupCasters?: string[]; 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). */ /** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number; requiredOverride?: number;
speaker?: ChatMessage.SpeakerData; speaker?: ChatMessage.SpeakerData;
@@ -276,9 +278,10 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
const magicDice = typeof a.magic.dicePool === 'number' const magicDice = typeof a.magic.dicePool === 'number'
? a.magic.dicePool ? a.magic.dicePool
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool; : getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
const pool = opts.castAsPrayer const basePool = opts.castAsPrayer
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name}`; : `${spell.name}`;
@@ -349,7 +352,7 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
const zealCost = Math.max(1, opts.zealSpent ?? 1); const zealCost = Math.max(1, opts.zealSpent ?? 1);
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost }); await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0); const pool = applyDiceModifier(a.soul.value + (actor.system.skills?.devotion?.value ?? 0), opts.diceModifier);
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 required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({ const roll = HbmTSRoll.fromParams({
@@ -379,9 +382,10 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).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) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
@@ -421,9 +425,10 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
const magicDice = typeof (a.magic as any).dicePool === 'number' const magicDice = typeof (a.magic as any).dicePool === 'number'
? (a.magic as any).dicePool ? (a.magic as any).dicePool
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).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) ? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0); : magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
const pool = applyDiceModifier(basePool, opts.diceModifier);
const flavor = opts.castAsPrayer const flavor = opts.castAsPrayer
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}` ? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`; : `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
+2
View File
@@ -9,6 +9,7 @@ import {
AOE_SHAPES, AOE_SHAPES,
SPELL_DAMAGE_TYPES, SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS, TRIGGER_EVENTS,
WOUND_KINDS,
} from '../constants'; } from '../constants';
const { ItemSheetV2 } = foundry.applications.sheets as unknown as { const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
@@ -67,6 +68,7 @@ export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
itemType: item.type, itemType: item.type,
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs', bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
choices: buildChoiceMaps(), choices: buildChoiceMaps(),
woundChoices: Object.fromEntries(WOUND_KINDS.map((k) => [k, `HBM.gear.wound.${k}`])),
effects, effects,
supportsEffects, supportsEffects,
}; };
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "hbm-rpg-v3", "id": "hbm-rpg-v3",
"title": "Homebrew Magic: 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).", "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.0", "version": "1.5.1",
"manifest": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3.json", "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", "download": "https://vtt-content.octoturge.com/packages/system/hbm-rpg-v3-v1.5.0.zip",
"compatibility": { "compatibility": {
+5
View File
@@ -31,6 +31,11 @@
<option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option> <option value="environmental" {{#if (eq system.weapon.damageType 'environmental')}}selected{{/if}}>{{localize 'HBM.gear.environmental'}}</option>
</select> </select>
</label> </label>
<label title="{{localize 'HBM.gear.woundKindsHint'}}">{{localize 'HBM.gear.woundKinds'}}:
<multi-select name="system.weapon.woundKinds">
{{selectOptions woundChoices selected=system.weapon.woundKinds localize=true}}
</multi-select>
</label>
</fieldset> </fieldset>
{{/if}} {{/if}}
+11
View File
@@ -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]); 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);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { parseWoundKinds } from '../../scripts/parsers/gear-parser';
describe('gear-parser parseWoundKinds', () => {
it('maps English and Polish labels, single or listed', () => {
expect(parseWoundKinds('slashing')).toEqual(['slashing']);
expect(parseWoundKinds('kłute')).toEqual(['piercing']);
expect(parseWoundKinds('piercing, slashing')).toEqual(['piercing', 'slashing']);
expect(parseWoundKinds('slashing, piercing, blunt, slashing')).toEqual(['slashing', 'piercing', 'blunt']);
});
it('never guesses: missing or unknown stays empty', () => {
expect(parseWoundKinds(undefined)).toEqual([]);
expect(parseWoundKinds('')).toEqual([]);
expect(parseWoundKinds('ognista')).toEqual([]);
});
});