Initial commit

This commit is contained in:
Octoturge
2026-06-09 22:06:56 +02:00
commit fb42c6e8cc
121 changed files with 14976 additions and 0 deletions
+203
View File
@@ -0,0 +1,203 @@
/**
* Shared constants for the HbM RPG v3 Foundry system.
* All identifiers are English; UI labels live in lang/{pl,en}.json.
*/
export const ATTRIBUTES = ['body', 'mind', 'soul', 'magic'] as const;
export type AttributeKey = (typeof ATTRIBUTES)[number];
/** Skill tag categories used by the roll-modifier pipeline. */
export type SkillTag = 'sight' | 'hearing' | 'social' | 'attack' | 'magic' | 'movement';
/**
* Canonical 24 skills. Each maps to its default associated attribute,
* but the GM may permit a different attribute for a specific roll
* (handled at the roller level via attributeOverride).
*/
export const SKILLS: Readonly<Record<string, AttributeKey>> = Object.freeze({
athletics: 'body',
agility: 'body',
strength: 'body',
melee: 'body',
ranged: 'body',
stealth: 'body',
endurance: 'body',
reflex: 'body',
perception: 'mind',
intuition: 'mind',
craft: 'mind',
medicine: 'mind',
generalLore: 'mind',
natureLore: 'mind',
magicLore: 'mind',
theology: 'mind',
empathy: 'soul',
persuasion: 'soul',
intimidation: 'soul',
determination: 'soul',
devotion: 'soul',
disguise: 'soul',
animalHandling: 'soul',
magicalAbilities: 'magic',
});
/**
* Skill → tag mapping consulted by Active-Effect-driven roll modifiers.
* `blocksTags` on a condition prevents rolls of any skill containing that tag.
*/
export const SKILL_TAGS: Readonly<Record<string, readonly SkillTag[]>> = Object.freeze({
athletics: ['movement'],
agility: ['movement'],
strength: [],
melee: ['attack'],
ranged: ['attack', 'sight'],
stealth: ['movement', 'sight'],
endurance: [],
reflex: [],
perception: ['sight', 'hearing'],
intuition: ['social'],
craft: [],
medicine: [],
generalLore: [],
natureLore: [],
magicLore: [],
theology: [],
empathy: ['social', 'hearing'],
persuasion: ['social', 'hearing'],
intimidation: ['social'],
determination: [],
devotion: [],
disguise: ['social'],
animalHandling: ['social'],
magicalAbilities: ['magic'],
});
export const SKILL_KEYS = Object.freeze(Object.keys(SKILLS)) as readonly string[];
/**
* Magic Power Level lookup table (level 0..10 → I..X).
* Drives dice pool size, max mana per single spell, and total mana per round.
*/
export interface MagicPowerEntry {
level: number; // 0..10
label: string; // '0' | 'I' | 'II' | ... | 'X'
dicePool: number; // dice added when casting
maxPerSpell: number; // max mana spent on one spell
manaPerRound: number; // mana budget per combat round
}
export const MAGIC_POWER_TABLE: readonly MagicPowerEntry[] = Object.freeze([
{ level: 0, label: '0', dicePool: 6, maxPerSpell: 10, manaPerRound: 20 },
{ level: 1, label: 'I', dicePool: 4, maxPerSpell: 6, manaPerRound: 12 },
{ level: 2, label: 'II', dicePool: 4, maxPerSpell: 5, manaPerRound: 10 },
{ level: 3, label: 'III', dicePool: 3, maxPerSpell: 5, manaPerRound: 7 },
{ level: 4, label: 'IV', dicePool: 3, maxPerSpell: 4, manaPerRound: 8 },
{ level: 5, label: 'V', dicePool: 3, maxPerSpell: 4, manaPerRound: 6 },
{ level: 6, label: 'VI', dicePool: 2, maxPerSpell: 3, manaPerRound: 6 },
{ level: 7, label: 'VII', dicePool: 2, maxPerSpell: 3, manaPerRound: 4 },
{ level: 8, label: 'VIII', dicePool: 1, maxPerSpell: 2, manaPerRound: 3 },
{ level: 9, label: 'IX', dicePool: 1, maxPerSpell: 1, manaPerRound: 2 },
{ level: 10, label: 'X', dicePool: 0, maxPerSpell: 1, manaPerRound: 1 },
]);
export function getMagicPowerEntry(level: number): MagicPowerEntry {
const clamped = Math.max(0, Math.min(10, Math.floor(level ?? 0)));
return MAGIC_POWER_TABLE[clamped]!;
}
/** TS roll defaults */
export const TS_DEFAULT_THRESHOLD = 4;
export const TS_DEFAULT_REQUIRED = 1;
export const TS_DIE_FACES = 6;
export const TS_AUTO_FAILURE_FACE = 1;
export const TS_AUTO_SUCCESS_FACE = 6;
/** Casting modes for spells */
export const CASTING_MODES = ['standard', 'sacred', 'witch', 'blood'] as const;
export type CastingMode = (typeof CASTING_MODES)[number];
/**
* Spell schools / disciplines (English identifiers).
* Covers academic, sacred, witch, and forbidden disciplines from all books.
*/
export const SPELL_SCHOOLS = [
// Generic / unclassified
'general',
// Academic disciplines
'alchemyTransmutation', 'alchemyBrewing', 'botany',
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
'artifice', 'golemancy', 'runes', 'manaSourceMage',
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
// Forbidden / extra-academic
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
] as const;
export type SpellSchool = (typeof SPELL_SCHOOLS)[number];
/** Sacred Magic deities (sub-school identifier when school === 'sacred'). */
export const SACRED_DEITIES = [
'common', 'jahwe', 'zeus', 'demeter', 'artemis',
'hekate', 'aphrodite', 'eros',
] as const;
export type SacredDeity = (typeof SACRED_DEITIES)[number];
/**
* Witch magic symbols. EXTENSIBLE — additional content may add more.
* Used for autocomplete only; spell.components.symbols accepts arbitrary strings.
*/
export const WITCH_SYMBOLS: readonly string[] = Object.freeze([
'Potentia', 'Tutamen', 'Lux', 'Motus', 'Iter', 'Vacuos', 'Vitium',
'Praecantatio', 'Aer', 'Aqua', 'Gelum', 'Ignis', 'Terra',
'Cognitio', 'Alienis', 'Illusio', 'Somnium', 'Tenebrae', 'Auram',
'Vinculum', 'Telum', 'Sensus', 'Perditio', 'Perfodio', 'Sano',
'Volatus', 'Tempestas',
]);
/** Source-book provenance for compendium content. */
export const SOURCE_BOOKS = [
'core-rules', 'magic-book', 'arcanum-sanguinis',
'crimson-cult', 'abyss-curse', 'humanity-guide',
'bestiary', 'gold-steel-magic',
] as const;
export type SourceBook = (typeof SOURCE_BOOKS)[number];
/** Area-of-effect shapes for spells. */
export const AOE_SHAPES = ['point', 'square', 'rectangle', 'cone', 'sphere', 'line'] as const;
export type AoeShape = (typeof AOE_SHAPES)[number];
/** Spell damage types (extends DAMAGE_TYPES with magical variants). */
export const SPELL_DAMAGE_TYPES = ['magical', 'physicalMagical', 'pure'] as const;
export type SpellDamageType = (typeof SPELL_DAMAGE_TYPES)[number];
/** Trigger event identifiers (reactive spell hooks). */
export const TRIGGER_EVENTS = ['killWithWeapon', 'targetCastsSpell', 'damageTaken', 'turnStart'] as const;
export type TriggerEvent = (typeof TRIGGER_EVENTS)[number];
/** Gear categories */
export const GEAR_CATEGORIES = ['weapon', 'armor', 'equipment'] as const;
export type GearCategory = (typeof GEAR_CATEGORIES)[number];
/** Damage types */
export const DAMAGE_TYPES = ['physical', 'magical', 'environmental'] as const;
export type DamageType = (typeof DAMAGE_TYPES)[number];
/** Armor types */
export const ARMOR_TYPES = ['light', 'medium', 'heavy', 'shield'] as const;
export type ArmorType = (typeof ARMOR_TYPES)[number];
/** Ability action type */
export const ABILITY_TYPES = ['passive', 'active', 'reaction', 'freeAction'] as const;
export type AbilityType = (typeof ABILITY_TYPES)[number];
/** Item rarity (Polish-source naming preserved as identifiers in English form) */
export const RARITIES = ['common', 'uncommon', 'rare', 'veryRare', 'legendary', 'artifact'] as const;
export type Rarity = (typeof RARITIES)[number];
/**
* Mental illness condition IDs (Klątwa Otchłani VIII).
* Registered as ActiveEffect-driven status conditions alongside physical conditions.
*/
export const MENTAL_CONDITIONS = ['paranoja', 'fobia', 'depresja', 'mania', 'schizofrenia'] as const;
export type MentalCondition = (typeof MENTAL_CONDITIONS)[number];
+303
View File
@@ -0,0 +1,303 @@
import {
makeIntField,
makeStringField,
makeHtmlField,
makeValueMaxField,
makeArrayField,
fields,
} from './fields';
import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from '../constants';
/**
* Player Character data model.
*
* Derived stats (computed in prepareDerivedData):
* - attributes.health.max = 3 * (body + mind + soul)
* - attributes.zeal.max = ceil(soul / 2)
* - attributes.initiative = mind + skills.reflex.value + skills.perception.value
* - attributes.mana.max / .maxPerSpell / magic.dicePool — from MAGIC_POWER_TABLE
*/
export class CharacterData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
const attributeSchema = () => new f.SchemaField({
value: makeIntField(1, { min: 1, max: 8 }),
});
const skillEntries: Record<string, foundry.data.fields.DataField.Any> = {};
for (const key of SKILL_KEYS) {
skillEntries[key] = new f.SchemaField({
value: makeIntField(0, { min: 0, max: 8 }),
defaultAttribute: makeStringField(SKILLS[key] as AttributeKey, {
blank: false,
choices: ATTRIBUTES as unknown as readonly string[],
}),
});
}
const scheduleRows: Record<string, foundry.data.fields.DataField.Any> = {};
for (let i = 1; i <= 12; i++) {
scheduleRows[`row${i}`] = new f.SchemaField({
hours: makeStringField('', { blank: true }),
mon: makeStringField('', { blank: true }),
tue: makeStringField('', { blank: true }),
wed: makeStringField('', { blank: true }),
thu: makeStringField('', { blank: true }),
fri: makeStringField('', { blank: true }),
sat: makeStringField('', { blank: true }),
});
}
return {
attributes: new f.SchemaField({
body: attributeSchema(),
mind: attributeSchema(),
soul: attributeSchema(),
magic: new f.SchemaField({
value: makeIntField(0, { min: 0, max: 10 }),
actual: makeIntField(0, { min: 0, max: 10 }), // derived
dicePool: makeIntField(0, { min: 0 }), // derived
}),
health: makeValueMaxField(0, 0),
mana: new f.SchemaField({
value: makeIntField(0, { min: 0 }),
max: makeIntField(0, { min: 0 }),
maxPerSpell: makeIntField(0, { min: 0 }),
}),
zeal: makeValueMaxField(0, 0),
// Blood Pool (Arcanum Sanguinis Ch. III) — experimental until full spell list ships.
// Placeholder formula: max = body + soul.
blood: makeValueMaxField(0, 0),
// Elixir tolerance (Podręcznik Gry — brewing). Each potion consumed adds 1;
// recovers 1 per long rest. Soft cap = body + 1; over cap → poisoning.
elixirTolerance: makeIntField(0, { min: 0 }),
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
// mental-condition table when reaching thresholds.
insanity: makeIntField(0, { min: 0 }),
magicalArmor: new f.SchemaField({
value: makeIntField(0, { min: 0 }),
max: makeIntField(0, { min: 0 }),
runicCounter: makeIntField(0, { min: 0 }),
}),
magicalShield: new f.SchemaField({
value: makeIntField(0, { min: 0 }),
}),
physicalArmor: new f.SchemaField({
value: makeIntField(0, { min: 0 }), // derived
max: makeIntField(0, { min: 0 }), // derived: base DR from equipment
bonus: makeIntField(0, { min: 0 }), // editable bonus (flat +DR)
condition: makeIntField(0, { min: 0 }), // derived: sum of armor conditions
conditionMax: makeIntField(0, { min: 0 }), // derived: sum of armor conditionMax
}),
initiative: makeIntField(0, { min: 0 }),
}),
skills: new f.SchemaField(skillEntries),
details: new f.SchemaField({
race: makeStringField('', { blank: true }),
raceId: makeStringField('', { blank: true }),
classIds: makeArrayField(new f.StringField({ blank: true })),
year: makeIntField(1, { min: 1, max: 4 }),
discipline: makeStringField('', { blank: true }),
title: makeStringField('', { blank: true }),
biography: makeHtmlField(''),
customEquipment: makeHtmlField(''), // legacy textarea — kept for migration
customItems: makeArrayField(new f.SchemaField({
name: makeStringField('', { blank: true }),
qty: makeIntField(1, { min: 0 }),
note: makeStringField('', { blank: true }),
})),
experience: makeIntField(0, { min: 0 }),
money: makeIntField(0, { min: 0 }),
classSchedule: makeStringField('', { blank: true }),
schedule: new f.SchemaField(scheduleRows),
personalDetails: makeHtmlField(''),
startingYear: makeIntField(2026, { min: 1900 }),
currentYear: makeIntField(2026, { min: 1900 }),
}),
advancement: new f.SchemaField({
attributePointsAvailable: makeIntField(0, { min: 0 }),
skillPointsAvailable: makeIntField(0, { min: 0 }),
freeTalentsAvailable: makeIntField(0, { min: 0 }), // derived
bonusAttributePoints: makeIntField(0, { min: 0 }),
bonusSkillPoints: makeIntField(0, { min: 0 }),
bonusFreeTalents: makeIntField(0, { min: 0 }),
}),
};
}
/** prepareDerivedData runs after source data loads; compute formulas here. */
override prepareDerivedData() {
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
const sys = this as unknown as {
attributes: {
body: { value: number };
mind: { value: number };
soul: { value: number };
magic: { value: number; actual: number; dicePool: number };
health: { value: number; max: number };
mana: { value: number; max: number; maxPerSpell: number };
zeal: { value: number; max: number };
blood: { value: number; max: number };
elixirTolerance: number;
insanity: number;
magicalArmor: { value: number; max: number; runicCounter: number };
magicalShield: { value: number };
physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
initiative: number;
};
skills: Record<string, { value: number; defaultAttribute: AttributeKey }>;
details: { raceId?: string; classIds?: string[] };
advancement: {
attributePointsAvailable: number;
skillPointsAvailable: number;
freeTalentsAvailable: number;
bonusAttributePoints: number;
bonusSkillPoints: number;
bonusFreeTalents: number;
};
};
const a = sys.attributes;
a.health.max = 3 * (a.body.value + a.mind.value + a.soul.value);
if (a.health.value > a.health.max) a.health.value = a.health.max;
a.zeal.max = Math.ceil(a.soul.value / 2);
if (a.zeal.value > a.zeal.max) a.zeal.value = a.zeal.max;
// Compute physical armor DR from equipped gear + talents
const items = (self.parent?.items ? Array.from(self.parent.items as Iterable<any>) : []) as any[];
// Calculate actual magic circle based on talents
let magicMod = 0;
for (const it of items) {
if (it.type === 'talent') {
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
const name = it.name?.toLowerCase() || '';
if (slug === 'jednosc-z-magia' || slug === 'unity-with-magic' || name === 'jedność z magią' || name === 'unity with magic') {
magicMod -= 1;
} else if (slug === 'odpornosc-na-magie' || slug === 'magic-resistance' || name === 'odporność na magię' || name === 'magic resistance') {
magicMod += 1;
}
}
}
const actualMagic = a.magic.value + magicMod;
a.magic.actual = Math.max(0, Math.min(10, actualMagic));
const mp = getMagicPowerEntry(a.magic.actual);
a.magic.dicePool = mp.dicePool;
a.mana.max = mp.manaPerRound;
a.mana.maxPerSpell = mp.maxPerSpell;
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
// Count battle-readiness stacks for initiative bonus (+2 per copy)
let initiativeBonus = 0;
// Count Man of Iron copies for passive armor (only applies when no physical armor is worn)
let manOfIronStacks = 0;
for (const it of items) {
if (it.type === 'talent') {
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
const name = it.name?.toLowerCase() || '';
if (
slug === 'battle-readiness' || slug === 'gotowosc-do-walki' ||
name === 'gotowo\u015b\u0107 do walki' || name === 'battle readiness'
) {
initiativeBonus += 2;
}
if (
slug === 'man-of-iron' || slug === 'czlowiek-z-zelaza' ||
name === 'cz\u0142owiek z \u017celaza' || name === 'man of iron'
) {
manOfIronStacks += 1;
}
}
}
a.initiative =
a.mind.value +
(sys.skills.reflex?.value ?? 0) +
(sys.skills.perception?.value ?? 0) +
initiativeBonus;
// Clamp magical armor / shield
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
let armorDR = 0;
let armorPieces = 0;
let totalArmorCondition = 0;
let totalArmorConditionMax = 0;
// Compute effective DR per piece using condition scaling
let effectiveDR = 0;
for (const it of items) {
if (it.type === 'gear') {
const g = it.system;
if (!g) continue;
if (g.equipped && g.category === 'armor') {
const baseDR = Number(g.armor?.damageReduction ?? 0);
const cond = Number(g.armor?.condition ?? 0);
const condMax = Number(g.armor?.conditionMax ?? 1);
armorDR += baseDR;
armorPieces += 1;
totalArmorCondition += cond;
totalArmorConditionMax += condMax;
// Condition-scaled DR: broken armor (cond=0) gives 0 DR
if (condMax > 0) {
effectiveDR += Math.floor(baseDR * (cond / condMax));
}
}
}
}
// physicalArmorBonus is stored/editable; add it to effective DR
const paBonus = (sys as any).attributes?.physicalArmor?.bonus ?? 0;
a.physicalArmor.max = Math.max(0, armorDR + paBonus);
a.physicalArmor.value = Math.max(0, effectiveDR + paBonus);
if (armorPieces > 0) {
a.physicalArmor.condition = totalArmorCondition;
a.physicalArmor.conditionMax = totalArmorConditionMax;
}
// Man of Iron: +1 passive armor per stack, only when wearing no physical armor
if (manOfIronStacks > 0 && armorPieces === 0) {
a.physicalArmor.max = Math.max(a.physicalArmor.max, manOfIronStacks);
a.physicalArmor.value = Math.max(a.physicalArmor.value, manOfIronStacks);
}
// Blood Pool (placeholder formula): max = body + soul.
a.blood.max = a.body.value + a.soul.value;
if (a.blood.value > a.blood.max) a.blood.value = a.blood.max;
// Advancement points (UUID-based race/class lookup; falls back to 0 if not set)
const raceItem = items.find((i) => i.type === 'race');
const classItems = items.filter((i) => i.type === 'class');
const startingAttr = Number(raceItem?.system?.attributePoints ?? 0);
const startingSkill = Number(raceItem?.system?.skillPoints ?? 0);
const startingFreeTalents = raceItem?.system?.freeTalents?.length ?? 0;
const classAttr = classItems.reduce((sum, c) => sum + Number(c.system?.attributePoints ?? 0), 0);
const classSkill = classItems.reduce((sum, c) => sum + Number(c.system?.skillPoints ?? 0), 0);
const spentAttr = (a.body.value - 1) + (a.mind.value - 1) + (a.soul.value - 1) + a.magic.value;
const spentSkill = Object.values(sys.skills).reduce((sum, s) => sum + (s.value ?? 0), 0);
const spentFreeTalents = items.filter((i) => i.type === 'talent').length;
sys.advancement.attributePointsAvailable = Math.max(0, startingAttr + classAttr + (sys.advancement.bonusAttributePoints ?? 0) - spentAttr);
sys.advancement.skillPointsAvailable = Math.max(0, startingSkill + classSkill + (sys.advancement.bonusSkillPoints ?? 0) - spentSkill);
sys.advancement.freeTalentsAvailable = Math.max(0, startingFreeTalents + (sys.advancement.bonusFreeTalents ?? 0) - spentFreeTalents);
}
/**
* Migration shim: map legacy `attributes.armor` (single number) to new
* `physicalArmor.value` so existing characters do not crash on load.
*/
_initializeSource(data: any, options: any): any {
const initialized = super._initializeSource(data, options);
if (initialized?.attributes && typeof initialized.attributes.armor === 'number') {
const legacy = initialized.attributes.armor;
if (!initialized.attributes.physicalArmor) {
initialized.attributes.physicalArmor = { value: legacy, condition: 1, conditionMax: 1 };
}
delete initialized.attributes.armor;
}
return initialized;
}
}
+140
View File
@@ -0,0 +1,140 @@
import {
makeIntField,
makeStringField,
makeHtmlField,
makeValueMaxField,
makeArrayField,
fields,
} from './fields';
import { getMagicPowerEntry } from '../constants';
/**
* NPC / Creature data model.
* Looser than character (no derived health formula — set directly).
*/
export class NpcData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
const attackSchema = () => new f.SchemaField({
name: makeStringField(''),
description: makeStringField(''),
damage: makeStringField(''),
damageType: makeStringField('physical'),
bonus: makeIntField(0),
});
const namedEntry = () => new f.SchemaField({
name: makeStringField(''),
description: makeStringField(''),
});
return {
attributes: new f.SchemaField({
body: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
soul: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
magic: new f.SchemaField({
value: makeIntField(0, { min: 0, max: 10 }),
actual: makeIntField(0, { min: 0, max: 10 }), // derived
}),
mana: new f.SchemaField({
value: makeIntField(0, { min: 0 }),
max: makeIntField(0, { min: 0 }),
maxPerSpell: makeIntField(0, { min: 0 }),
}),
zeal: makeValueMaxField(0, 0),
blood: makeValueMaxField(0, 0),
health: makeValueMaxField(1, 1),
magicalArmor: makeValueMaxField(0, 0),
magicalShield: makeValueMaxField(0, 0),
physicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }) }),
speed: makeStringField('5m'),
}),
details: new f.SchemaField({
type: makeStringField(''),
size: makeStringField('medium'),
alignment: makeStringField(''),
xp: makeIntField(0, { min: 0 }),
senses: makeStringField(''),
languages: makeStringField(''),
customEquipment: makeHtmlField(''),
money: makeIntField(0, { min: 0 }),
startingYear: makeIntField(2026, { min: 1900 }),
currentYear: makeIntField(2026, { min: 1900 }),
}),
combat: new f.SchemaField({
attacks: makeArrayField(attackSchema()),
specialAbilities: makeArrayField(namedEntry()),
reactions: makeArrayField(namedEntry()),
legendaryActions: makeArrayField(namedEntry()),
}),
defenses: new f.SchemaField({
savingThrows: makeArrayField(new f.StringField({ blank: false })),
damageResistances: makeArrayField(new f.StringField({ blank: false })),
damageImmunities: makeArrayField(new f.StringField({ blank: false })),
conditionImmunities: makeArrayField(new f.StringField({ blank: false })),
}),
lore: new f.SchemaField({
description: makeHtmlField(''),
habitat: makeStringField(''),
behavior: makeStringField(''),
history: makeStringField(''),
}),
};
}
override prepareDerivedData() {
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
const sys = this as unknown as {
attributes: {
magic: { value: number; actual: number };
mana: { value: number; max: number; maxPerSpell: number };
health: { value: number; max: number };
magicalArmor: { value: number; max: number };
magicalShield: { value: number; max: number };
};
};
const a = sys.attributes;
// Compute magic.actual based on NPC talents
const items = (self.parent?.items ? Array.from(self.parent.items as Iterable<any>) : []) as any[];
let magicMod = 0;
for (const it of items) {
if (it.type === 'talent') {
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
const name = it.name?.toLowerCase() || '';
if (slug === 'jednosc-z-magia' || slug === 'unity-with-magic' || name === 'jedność z magią' || name === 'unity with magic') {
magicMod -= 1;
} else if (slug === 'odpornosc-na-magie' || slug === 'magic-resistance' || name === 'odporność na magię' || name === 'magic resistance') {
magicMod += 1;
}
}
}
const actualMagic = a.magic.value + magicMod;
a.magic.actual = Math.max(0, Math.min(10, actualMagic));
// Derive NPC mana from MAGIC_POWER_TABLE
const mp = getMagicPowerEntry(a.magic.actual);
a.mana.max = mp.manaPerRound;
a.mana.maxPerSpell = mp.maxPerSpell;
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
if (a.health.value > a.health.max) a.health.value = a.health.max;
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
if (a.magicalShield.value > a.magicalShield.max) a.magicalShield.value = a.magicalShield.max;
}
/** Migrate legacy `attributes.armor` → `physicalArmor.value` */
_initializeSource(data: any, options: any): any {
const initialized = super._initializeSource(data, options);
if (initialized?.attributes && typeof initialized.attributes.armor === 'number') {
const legacy = initialized.attributes.armor;
if (!initialized.attributes.physicalArmor) {
initialized.attributes.physicalArmor = { value: legacy };
}
delete initialized.attributes.armor;
}
return initialized;
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Shared DataModel field helpers. Foundry's data model field constructors
* live under foundry.data.fields at runtime; we re-export local aliases
* to keep the data model files terse.
*/
// Foundry exposes fields globally; declared via fvtt-types.
export const fields = (): typeof foundry.data.fields => foundry.data.fields;
export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean }> = {}) {
const f = fields();
return new f.NumberField({
required: true,
nullable: options.nullable ?? false,
integer: true,
initial,
min: options.min,
max: options.max,
});
}
export function makeStringField(initial = '', options: Partial<{ blank: boolean; choices: readonly string[] }> = {}) {
const f = fields();
return new f.StringField({
required: true,
blank: options.blank ?? true,
initial,
choices: options.choices as string[] | undefined,
});
}
export function makeBoolField(initial = false) {
const f = fields();
return new f.BooleanField({ required: true, initial });
}
export function makeHtmlField(initial = '') {
const f = fields();
return new f.HTMLField({ required: true, blank: true, initial });
}
export function makeArrayField(element: foundry.data.fields.DataField.Any) {
const f = fields();
return new f.ArrayField(element);
}
export function makeValueMaxField(initial = 0, max = 0) {
const f = fields();
return new f.SchemaField({
value: makeIntField(initial, { min: 0 }),
max: makeIntField(max, { min: 0 }),
});
}
+19
View File
@@ -0,0 +1,19 @@
import { makeStringField, makeHtmlField, makeIntField } from './fields';
import { ABILITY_TYPES } from '../constants';
export class AbilityData extends foundry.abstract.TypeDataModel {
static defineSchema() {
return {
type: makeStringField('passive', {
blank: false,
choices: ABILITY_TYPES as unknown as readonly string[],
}),
prerequisite: makeStringField(''),
cost: makeStringField(''),
manaCost: makeIntField(0, { min: 0 }),
zealCost: makeIntField(0, { min: 0 }),
description: makeHtmlField(''),
mechanics: makeHtmlField(''),
};
}
}
+21
View File
@@ -0,0 +1,21 @@
import { makeIntField, makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
export class ClassData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
return {
year: makeIntField(1, { min: 1, max: 4 }),
attributePoints: makeIntField(0, { min: 0 }),
skillPoints: makeIntField(0, { min: 0 }),
talents: makeArrayField(new f.StringField({ blank: false })),
spells: makeArrayField(new f.StringField({ blank: false })),
features: makeArrayField(
new f.SchemaField({
name: makeStringField(''),
description: makeStringField(''),
}),
),
description: makeHtmlField(''),
};
}
}
+19
View File
@@ -0,0 +1,19 @@
import { makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
import { ATTRIBUTES } from '../constants';
export class DisciplineData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
return {
color: makeStringField(''),
suggestedAttribute: makeStringField('magic', {
blank: false,
choices: ATTRIBUTES as unknown as readonly string[],
}),
suggestedSkills: makeArrayField(new f.StringField({ blank: false })),
passiveAbility: makeHtmlField(''),
availableSpells: makeArrayField(new f.StringField({ blank: false })),
description: makeHtmlField(''),
};
}
}
+67
View File
@@ -0,0 +1,67 @@
import {
makeIntField,
makeStringField,
makeBoolField,
makeHtmlField,
makeArrayField,
fields,
} from './fields';
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
/**
* Unified Gear item — discriminated by `category`.
* Weapon/armor/equipment all share base fields; specialised fields
* are scoped under `weapon` and `armor` sub-schemas.
*/
export class GearData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
return {
category: makeStringField('equipment', {
blank: false,
choices: GEAR_CATEGORIES as unknown as readonly string[],
}),
rarity: makeStringField('common', {
blank: false,
choices: RARITIES as unknown as readonly string[],
}),
quantity: makeIntField(1, { min: 0 }),
weight: makeIntField(0, { min: 0 }),
value: makeIntField(0, { min: 0 }),
equipped: makeBoolField(false),
damageReductionBonus: makeIntField(0, { min: 0 }),
description: makeHtmlField(''),
weapon: new f.SchemaField({
damage: makeStringField(''),
damageType: makeStringField('physical', {
blank: false,
choices: DAMAGE_TYPES as unknown as readonly string[],
}),
properties: makeArrayField(new f.StringField({ blank: false })),
}),
armor: new f.SchemaField({
damageReduction: makeIntField(0, { min: 0 }),
condition: makeIntField(1, { min: 0 }),
conditionMax: makeIntField(1, { min: 0 }),
armorType: makeStringField('light', {
blank: false,
choices: ARMOR_TYPES as unknown as readonly string[],
}),
stealthDisadvantage: makeBoolField(false),
strengthRequirement: makeIntField(0, { min: 0 }),
}),
};
}
/** Migrate legacy `armor.armorClass` → `armor.damageReduction`. */
_initializeSource(data: any, options: any): any {
const initialized = super._initializeSource(data, options);
if (initialized?.armor && typeof initialized.armor.armorClass === 'number' && initialized.armor.damageReduction == null) {
initialized.armor.damageReduction = initialized.armor.armorClass;
delete initialized.armor.armorClass;
}
return initialized;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { makeIntField, makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
export class RaceData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
return {
availableDisciplines: makeArrayField(new f.StringField({ blank: false })),
attributePoints: makeIntField(0, { min: 0 }),
skillPoints: makeIntField(0, { min: 0 }),
freeTalents: makeArrayField(new f.StringField({ blank: false })),
racialAbilities: makeArrayField(
new f.SchemaField({
name: makeStringField(''),
description: makeStringField(''),
}),
),
physicalDescription: makeStringField(''),
description: makeHtmlField(''),
};
}
}
+115
View File
@@ -0,0 +1,115 @@
import {
makeIntField,
makeStringField,
makeBoolField,
makeHtmlField,
makeArrayField,
fields,
} from './fields';
import {
CASTING_MODES,
SPELL_SCHOOLS,
SACRED_DEITIES,
SOURCE_BOOKS,
AOE_SHAPES,
SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS,
TS_DEFAULT_THRESHOLD,
TS_DEFAULT_REQUIRED,
} from '../constants';
export class SpellData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
return {
// Identification
circle: makeIntField(1, { min: 0, max: 9 }),
school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }),
discipline: makeStringField(''),
deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }),
sourceBook: makeStringField('', { blank: true, choices: SOURCE_BOOKS as unknown as readonly string[] }),
// Casting mode + resources
castingMode: makeStringField('standard', { blank: false, choices: CASTING_MODES as unknown as readonly string[] }),
manaCost: makeIntField(1, { min: 0 }),
bloodCost: makeIntField(0, { min: 0 }),
// Difficulty
difficulty: new f.SchemaField({
threshold: makeIntField(TS_DEFAULT_THRESHOLD, { min: 2, max: 6 }),
successes: makeIntField(TS_DEFAULT_REQUIRED, { min: 1, max: 10 }),
}),
// Components
components: new f.SchemaField({
verbal: makeBoolField(false),
somatic: makeBoolField(false),
material: makeStringField(''),
symbols: makeArrayField(new f.StringField({ blank: false })),
}),
// Timing & geometry
castingTime: makeStringField('1 akcja'),
castingTimeRounds: makeIntField(0, { min: 0 }),
castingTimeMinutes: makeIntField(0, { min: 0 }),
range: makeStringField(''),
targets: makeStringField(''),
duration: makeStringField(''),
areaOfEffect: new f.SchemaField({
shape: makeStringField('point', { blank: false, choices: AOE_SHAPES as unknown as readonly string[] }),
x: makeIntField(0, { min: 0 }),
y: makeIntField(0, { min: 0 }),
unit: makeStringField('m'),
}),
// Damage
damageBase: makeStringField(''),
damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }),
ignoresArmor: makeBoolField(false),
// Status effects on hit
statusEffects: makeArrayField(new f.StringField({ blank: false })),
saveAttribute: makeStringField(''),
saveSkill: makeStringField(''),
// Requirements
requirements: new f.SchemaField({
race: makeArrayField(new f.StringField({ blank: false })),
talent: makeArrayField(new f.StringField({ blank: false })),
discipline: makeArrayField(new f.StringField({ blank: false })),
}),
// Flags
isSuperspell: makeBoolField(false),
requiresGroupCast: makeBoolField(false),
minCasters: makeIntField(1, { min: 1 }),
nonCombatOnly: makeBoolField(false),
// Overcast — structured
overcastOptions: makeArrayField(new f.SchemaField({
description: makeStringField(''),
manaPerStep: makeIntField(1, { min: 0 }),
})),
// Reactive triggers
triggers: makeArrayField(new f.SchemaField({
event: makeStringField('killWithWeapon', { blank: false, choices: TRIGGER_EVENTS as unknown as readonly string[] }),
effect: makeStringField(''),
})),
// Variable success summons (Przywołanie Istoty z Otchłani)
variableSuccesses: makeArrayField(new f.SchemaField({
label: makeStringField(''),
successes: makeIntField(1, { min: 1, max: 10 }),
})),
// Description
description: makeHtmlField(''),
higherCircles: makeHtmlField(''), // legacy — superseded by overcastOptions
// Legacy fields (kept for migration; will be removed in a later release)
overcasting: makeStringField(''),
complexityLevel: makeIntField(0, { min: 0, max: 10 }),
};
}
}
+22
View File
@@ -0,0 +1,22 @@
import { makeIntField, makeStringField, makeHtmlField, makeBoolField, fields } from './fields';
export class TalentData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const f = fields();
return {
requirements: new f.SchemaField({
race: makeStringField(''),
attribute: makeStringField(''),
skill: makeStringField(''),
talent: makeStringField(''),
title: makeStringField(''),
discipline: makeStringField(''),
}),
multiSelect: makeBoolField(false),
cost: makeStringField(''),
damageReductionBonus: makeIntField(0, { min: 0 }),
description: makeHtmlField(''),
effect: makeHtmlField(''),
};
}
}
+194
View File
@@ -0,0 +1,194 @@
/**
* Comprehensive cast dialog. Replaces the old `askManaSpent` for spell-cast
* actions on the character/NPC sheet. Returns null if cancelled.
*
* Inputs adapt to spell mode:
* - standard: mana
* - sacred: zeal (+ optional Hekate hybrid toggle if witch symbols set)
* - witch: mana
* - blood: mana + blood
*
* Plus GM bypass checkboxes for super/non-combat warnings.
*/
import type { CastOptions } from '../logic/spell-cast';
interface SpellForDialog {
name: string;
system: {
castingMode: 'standard' | 'sacred' | 'witch' | 'blood';
manaCost: number;
bloodCost?: number;
isSuperspell?: boolean;
nonCombatOnly?: boolean;
requiresGroupCast?: boolean;
minCasters?: number;
components?: { symbols?: string[] };
variableSuccesses?: Array<{ label: string; successes: number }>;
};
}
interface ActorForDialog {
system: {
attributes: {
mana: { value: number; maxPerSpell: number };
zeal: { value: number; max: number };
blood?: { value: number; max: number };
};
};
}
export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialog): Promise<CastOptions | null> {
const mode = spell.system.castingMode ?? 'standard';
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;
const showNonCombat = !!spell.system.nonCombatOnly;
const showGroup = !!spell.system.requiresGroupCast;
const fields: string[] = [];
if (mode === 'standard' || mode === 'witch' || mode === 'blood') {
const max = a.mana.value;
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.manaSpent')} (max ${max})</label>
<input type="number" name="manaSpent" value="${baseMana}" min="${baseMana}" max="${max}" step="${baseMana > 0 ? baseMana : 1}"/>
<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(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.bloodSpent')} (max ${max})</label>
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
</div>`);
}
if (showHekate) {
fields.push(`
<div class="form-group">
<label><input type="checkbox" name="hekateMode"/> ${game.i18n.localize('HBM.spellCast.hekateMode')}</label>
</div>`);
}
if (showSuper) {
fields.push(`
<div class="form-group warn">
<label><input type="checkbox" name="bypassSuperspellWarning"/> ${game.i18n.localize('HBM.spellCast.bypassSuperspell')}</label>
</div>`);
}
if (showNonCombat) {
fields.push(`
<div class="form-group warn">
<label><input type="checkbox" name="bypassNonCombatBlock"/> ${game.i18n.localize('HBM.spellCast.bypassNonCombatBlock')}</label>
</div>`);
}
if (showGroup) {
// Try to populate from canvas tokens (excluding the caster).
const canvasAny = (canvas as unknown as { tokens?: { placeables: Array<{ id: string; name: string; actor?: { id: string; name: string } }> } } | undefined);
const tokens = canvasAny?.tokens?.placeables ?? [];
const candidates = tokens
.filter((t) => t.actor)
.map((t) => ({ id: t.actor!.id, name: t.actor!.name }));
if (candidates.length > 0) {
const checkboxes = candidates.map((c) =>
`<label class="caster-option"><input type="checkbox" name="groupCaster_${c.id}" value="${c.id}"/> ${c.name}</label>`
).join('');
fields.push(`
<div class="form-group group-casters">
<label>${game.i18n.localize('HBM.spellCast.groupCastersFromCanvas')} (${game.i18n.localize('HBM.spell.minCasters')}: ${spell.system.minCasters ?? 1})</label>
<div class="caster-list">${checkboxes}</div>
</div>`);
} else {
fields.push(`
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.groupCasters')} (${game.i18n.localize('HBM.spell.minCasters')}: ${spell.system.minCasters ?? 1})</label>
<input type="text" name="groupCasters" placeholder="actorId1, actorId2"/>
</div>`);
}
}
// Variable-success picker (e.g. Przywołanie Istoty z Otchłani)
const variants = spell.system.variableSuccesses ?? [];
if (variants.length > 0) {
const radios = variants.map((v, i) =>
`<label class="variant-option"><input type="radio" name="variantIdx" value="${i}" ${i === 0 ? 'checked' : ''}/> ${v.label} <small>(${v.successes} sukcesów)</small></label>`
).join('');
fields.push(`
<div class="form-group variable-successes">
<label>${game.i18n.localize('HBM.spellCast.variableSuccesses')}</label>
<div class="variant-list">${radios}</div>
</div>`);
}
return new Promise((resolve) => {
new foundry.appv1.api.Dialog({
title: `${game.i18n.localize('HBM.spell.cast')}: ${spell.name}`,
content: `<form class="hbm-dialog cast-dialog">${fields.join('')}</form>`,
buttons: {
cast: {
icon: '<i class="fas fa-hat-wizard"></i>',
label: game.i18n.localize('HBM.spell.cast'),
callback: (html: any) => {
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
const opts: CastOptions = {};
if (fd.manaSpent != null) {
let spent = Number(fd.manaSpent) || baseMana;
if (baseMana > 0) {
const remainder = spent % baseMana;
if (remainder !== 0) {
spent = Math.round(spent / baseMana) * baseMana;
}
}
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);
if (fd.hekateMode) opts.hekateMode = 'witch';
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
const groupRaw = String(fd.groupCasters ?? '').trim();
if (groupRaw) opts.groupCasters = groupRaw.split(/[,\s]+/).filter(Boolean);
// Collect checkbox-form group casters
const checkedCasters: string[] = [];
for (const key of Object.keys(fd)) {
if (key.startsWith('groupCaster_') && fd[key]) {
const id = String(fd[key]);
if (id) checkedCasters.push(id);
}
}
if (checkedCasters.length > 0) {
opts.groupCasters = [...(opts.groupCasters ?? []), ...checkedCasters];
}
// Variable-success override
if (variants.length > 0 && fd.variantIdx != null) {
const idx = Number(fd.variantIdx);
const variant = variants[idx];
if (variant) opts.requiredOverride = variant.successes;
}
resolve(opts);
},
},
cancel: {
icon: '<i class="fas fa-times"></i>',
label: game.i18n.localize('HBM.ui.cancel') ?? 'Anuluj',
callback: () => resolve(null),
},
},
default: 'cast',
close: () => resolve(null),
}).render(true);
});
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Apply-damage dialog: collects amount, type, and bypass flags. Returns
* an `ApplyDamageOptions` object suitable for passing into `applyDamage`,
* or `null` if cancelled.
*/
import { ApplyDamageOptions } from '../logic/damage';
import { DamageType } from '../constants';
interface ActorLike { name: string; system: any }
export async function askApplyDamage(actor: ActorLike, presetAmount = 0): Promise<ApplyDamageOptions | null> {
const ma = actor.system?.attributes?.magicalArmor?.value ?? 0;
const ms = actor.system?.attributes?.magicalShield?.value ?? 0;
const pa = actor.system?.attributes?.physicalArmor?.value ?? 0;
return new Promise((resolve) => {
new foundry.appv1.api.Dialog({
title: `${game.i18n.localize('HBM.damage.title')}${actor.name}`,
content: `
<form class="hbm-dialog">
<div class="form-group">
<label>${game.i18n.localize('HBM.damage.amount')}</label>
<input type="number" name="amount" value="${presetAmount}" min="0"/>
</div>
<div class="form-group">
<label>${game.i18n.localize('HBM.damage.type')}</label>
<select name="type">
<option value="physical">${game.i18n.localize('HBM.gear.physical')}</option>
<option value="magical">${game.i18n.localize('HBM.gear.magical')}</option>
<option value="environmental">${game.i18n.localize('HBM.gear.environmental')}</option>
</select>
</div>
<p class="hint">
${game.i18n.localize('HBM.resources.magicalArmor')}: <strong>${ma}</strong> ·
${game.i18n.localize('HBM.resources.magicalShield')}: <strong>${ms}</strong> ·
${game.i18n.localize('HBM.resources.physicalArmor')}: <strong>${pa}</strong>
</p>
<div class="form-group">
<label><input type="checkbox" name="ignoreMagicalArmor"/> ${game.i18n.localize('HBM.damage.ignoreMagicalArmor')}</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="ignoreMagicalShield"/> ${game.i18n.localize('HBM.damage.ignoreMagicalShield')}</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="ignorePhysicalArmor"/> ${game.i18n.localize('HBM.damage.ignorePhysicalArmor')}</label>
</div>
<div class="form-group">
<label><input type="checkbox" name="damageArmor"/> ${game.i18n.localize('HBM.damage.damageArmor')}</label>
</div>
</form>`,
buttons: {
apply: {
icon: '<i class="fas fa-burst"></i>',
label: game.i18n.localize('HBM.damage.apply'),
callback: (html: any) => {
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
resolve({
amount: Math.max(0, Number(fd['amount']) || 0),
type: (fd['type'] as DamageType) ?? 'physical',
ignoreMagicalArmor: Boolean(fd['ignoreMagicalArmor']),
ignoreMagicalShield: Boolean(fd['ignoreMagicalShield']),
ignorePhysicalArmor: Boolean(fd['ignorePhysicalArmor']),
damageArmor: Boolean(fd['damageArmor']),
});
},
},
},
default: 'apply',
close: () => resolve(null),
}).render(true);
});
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Active-Effect-driven roll modifier collection.
* Reads `flags.hbm.*` metadata from the actor's currently-applied effects
* (`actor.appliedEffects` in v13) and aggregates threshold steps + blocked tags.
*/
import { SkillTag } from '../constants';
export interface RollModifiers {
thresholdSteps: number;
requiredSteps: number;
/** First blocked condition encountered, for friendly error message. */
blockedBy?: { conditionLabel: string; tag: SkillTag };
}
export function collectRollModifiers(actor: any, tags: readonly SkillTag[]): RollModifiers {
const result: RollModifiers = { thresholdSteps: 0, requiredSteps: 0 };
const effects = (actor?.appliedEffects ?? actor?.effects ?? []) as Iterable<any>;
for (const ef of effects) {
if (ef?.disabled) continue;
const hbm = ef?.flags?.hbm;
if (!hbm) continue;
// Threshold step modifiers — keys may be tag names or 'all'
const ts = hbm.thresholdSteps as Record<string, number> | undefined;
if (ts && typeof ts === 'object') {
if (typeof ts['all'] === 'number') result.thresholdSteps += ts['all'];
for (const t of tags) {
if (typeof ts[t] === 'number') result.thresholdSteps += ts[t]!;
}
}
if (typeof hbm.requiredSteps === 'number') result.requiredSteps += hbm.requiredSteps;
// Blocked tags
const blocked = (hbm.blocksTags ?? []) as SkillTag[];
if (Array.isArray(blocked) && !result.blockedBy) {
for (const t of tags) {
if (blocked.includes(t)) {
result.blockedBy = { conditionLabel: ef?.name ?? ef?.label ?? '?', tag: t };
break;
}
}
}
}
return result;
}
export class RollBlockedError extends Error {
constructor(public conditionLabel: string, public tag: SkillTag) {
super(`Roll blocked by ${conditionLabel} (${tag})`);
}
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Roll helpers — convenience wrappers around HbmTSRoll bound to actor data.
*/
import { HbmTSRoll } from './ts-roll';
import { collectRollModifiers, RollBlockedError } from './effect-mods';
import { ATTRIBUTES, AttributeKey, SKILLS, SKILL_TAGS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
export interface SkillRollOptions {
/** Override the skill's default attribute (GM allows another for a specific roll). */
attributeOverride?: AttributeKey;
threshold?: number;
required?: number;
modifier?: number;
flavor?: string;
speaker?: ChatMessage.SpeakerData;
}
export interface ActorLike {
name: string;
system: {
attributes: Record<AttributeKey, { value: number } | { value: number; dicePool?: number }>;
skills?: Record<string, { value: number; defaultAttribute: AttributeKey }>;
};
}
function getAttributeValue(actor: ActorLike, key: AttributeKey): number {
const a = actor.system.attributes[key];
return a?.value ?? 0;
}
export async function rollSkill(
actor: ActorLike,
skillKey: string,
opts: SkillRollOptions = {},
): Promise<HbmTSRoll> {
const skill = actor.system.skills?.[skillKey];
if (!skill) throw new Error(`Unknown skill: ${skillKey}`);
const attrKey = opts.attributeOverride ?? skill.defaultAttribute ?? SKILLS[skillKey] ?? 'mind';
if (!ATTRIBUTES.includes(attrKey)) throw new Error(`Invalid attribute: ${String(attrKey)}`);
const tags = SKILL_TAGS[skillKey] ?? [];
const mods = collectRollModifiers(actor, tags);
if (mods.blockedBy) {
const msg = game.i18n.format('HBM.roll.blocked', { reason: mods.blockedBy.conditionLabel });
(ui as any).notifications?.warn(msg);
throw new RollBlockedError(mods.blockedBy.conditionLabel, mods.blockedBy.tag);
}
const pool = getAttributeValue(actor, attrKey) + (skill.value ?? 0);
const flavor = opts.flavor ??
game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) });
const roll = HbmTSRoll.fromParams({
pool,
threshold: opts.threshold ?? TS_DEFAULT_THRESHOLD,
required: opts.required ?? TS_DEFAULT_REQUIRED,
modifier: opts.modifier ?? 0,
thresholdSteps: mods.thresholdSteps,
requiredSteps: mods.requiredSteps,
flavor,
});
await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker });
return roll;
}
export async function rollAttribute(
actor: ActorLike,
attrKey: AttributeKey,
opts: Omit<SkillRollOptions, 'attributeOverride'> = {},
): Promise<HbmTSRoll> {
const flavor = opts.flavor ??
game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attrKey}`) });
const mods = collectRollModifiers(actor, []);
const roll = HbmTSRoll.fromParams({
pool: getAttributeValue(actor, attrKey),
threshold: opts.threshold ?? TS_DEFAULT_THRESHOLD,
required: opts.required ?? TS_DEFAULT_REQUIRED,
modifier: opts.modifier ?? 0,
thresholdSteps: mods.thresholdSteps,
requiredSteps: mods.requiredSteps,
flavor,
});
await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker });
return roll;
}
export async function rollInitiative(actor: ActorLike): Promise<any> {
const mind = getAttributeValue(actor, 'mind');
const reflex = actor.system.skills?.reflex?.value ?? 0;
const perception = actor.system.skills?.perception?.value ?? 0;
const mod = mind + reflex + perception;
const flavor = game.i18n.localize('HBM.resources.initiative');
const roll = new Roll('2d6 + @mod', { mod });
await roll.evaluate();
await roll.toMessage({
flavor,
speaker: ChatMessage.getSpeaker({ actor: actor as any }),
});
return roll;
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Interactive dialogs for TS roll configuration and spell mana input.
* Uses the v12-compat foundry.appv1.api.Dialog which is always available in v13.
*/
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
export interface RollDialogResult {
pool: number;
threshold: number;
required: number;
flavor?: string;
}
/** Prompt to (optionally) adjust T and Y before rolling. Returns null if cancelled. */
export async function askRollParams(initial: {
pool: number;
threshold?: number;
required?: number;
flavor?: string;
}): Promise<RollDialogResult | null> {
const t = initial.threshold ?? TS_DEFAULT_THRESHOLD;
const y = initial.required ?? TS_DEFAULT_REQUIRED;
return new Promise((resolve) => {
new foundry.appv1.api.Dialog({
title: game.i18n.localize('HBM.roll.configTitle'),
content: `
<form class="hbm-dialog">
<p class="hint">${game.i18n.localize('HBM.roll.pool')}: <strong>${initial.pool}</strong></p>
<div class="form-group">
<label>${game.i18n.localize('HBM.roll.threshold')} (26)</label>
<input type="number" name="threshold" value="${t}" min="2" max="6"/>
</div>
<div class="form-group">
<label>${game.i18n.localize('HBM.roll.required')} (110)</label>
<input type="number" name="required" value="${y}" min="1" max="10"/>
</div>
</form>`,
buttons: {
roll: {
icon: '<i class="fas fa-dice-d6"></i>',
label: game.i18n.localize('HBM.roll.roll'),
callback: (html: any) => {
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
resolve({
pool: initial.pool,
threshold: Math.min(6, Math.max(2, Number(fd['threshold']) || t)),
required: Math.min(10, Math.max(1, Number(fd['required']) || y)),
flavor: initial.flavor,
});
},
},
},
default: 'roll',
close: () => resolve(null),
}).render(true);
});
}
/** Prompt for how many mana to spend when casting a standard or witch spell. */
export async function askManaSpent(
spell: { name: string; system: { manaCost: number } },
actor: { system: { attributes: { mana: { value: number; maxPerSpell: number } } } },
): Promise<number | null> {
const base = Math.max(0, spell.system.manaCost ?? 0);
const current = actor.system.attributes.mana.value;
const maxPerSpell = actor.system.attributes.mana.maxPerSpell;
const overcastHint = base >= maxPerSpell
? `<em class="warn"> &mdash; ${game.i18n.localize('HBM.spellCast.overcastWarning')}</em>`
: '';
return new Promise((resolve) => {
new foundry.appv1.api.Dialog({
title: `${game.i18n.localize('HBM.spell.cast')}: ${spell.name}`,
content: `
<form class="hbm-dialog">
<div class="form-group">
<label>${game.i18n.localize('HBM.spellCast.manaSpent')}</label>
<input type="number" name="manaSpent" value="${base}" min="${base}" max="${current}"/>
</div>
<p class="hint">
${game.i18n.localize('HBM.resources.mana')}: <strong>${current}</strong>
&nbsp;|&nbsp;
${game.i18n.localize('HBM.resources.maxManaPerSpell')}: <strong>${maxPerSpell}</strong>
${overcastHint}
</p>
</form>`,
buttons: {
cast: {
icon: '<i class="fas fa-hat-wizard"></i>',
label: game.i18n.localize('HBM.spell.cast'),
callback: (html: any) => {
const form = (html?.jquery ? html[0] : html as HTMLElement).querySelector('form') as HTMLFormElement;
const fd = new foundry.applications.ux.FormDataExtended(form).object as Record<string, unknown>;
resolve(Math.max(base, Math.min(current, Number(fd['manaSpent']) || base)));
},
},
},
default: 'cast',
close: () => resolve(null),
}).render(true);
});
}
+160
View File
@@ -0,0 +1,160 @@
/**
* HbM TS (Trudność:Sukcesy) d6 dice pool roller.
*
* Mechanics:
* - Roll N d6.
* - For each die:
* face === 1 → forced failure (even if T ≤ 1)
* face === 6 → forced success (even if T > 6)
* otherwise face >= T → success
* - Roll succeeds when total successes ≥ requiredSuccesses (Y).
*
* Formula syntax: `ts(N, T, Y)` — e.g. `/r ts(5, 4, 2)`.
* The N/T/Y parameters are also accepted via constructor data.
*/
import {
TS_DEFAULT_THRESHOLD,
TS_DEFAULT_REQUIRED,
TS_DIE_FACES,
TS_AUTO_FAILURE_FACE,
TS_AUTO_SUCCESS_FACE,
} from '../constants';
export interface HbmTsRollData {
pool: number;
threshold: number;
required: number;
flavor?: string;
/** Optional pre-roll modifier (added to pool). */
modifier?: number;
/** Step adjustment to T (positive = harder, negative = easier). Clamped to 2..6. */
thresholdSteps?: number;
/** Step adjustment to Y (rare; e.g. extra successes required). */
requiredSteps?: number;
}
export interface HbmTsRollResult {
faces: number[];
successes: number;
required: number;
threshold: number;
pool: number;
isSuccess: boolean;
criticalSuccesses: number;
criticalFailures: number;
}
/**
* Custom Roll subclass. Stores the parsed TS parameters and the per-die
* results in `this.options` for the chat renderer.
*/
export class HbmTSRoll extends Roll {
static override CHAT_TEMPLATE = 'systems/hbm-rpg-v3/templates/chat/ts-roll.hbs';
ts: HbmTsRollResult | null = null;
constructor(formula: string, data: Record<string, unknown> = {}, options: Record<string, unknown> = {}) {
super(formula, data, options);
}
/**
* Build a roll from N/T/Y parameters directly.
*/
static fromParams(params: HbmTsRollData): HbmTSRoll {
const pool = Math.max(0, Math.floor((params.pool ?? 0) + (params.modifier ?? 0)));
const threshold = clampThreshold((params.threshold ?? TS_DEFAULT_THRESHOLD) + (params.thresholdSteps ?? 0));
const required = Math.max(1, Math.floor((params.required ?? TS_DEFAULT_REQUIRED) + (params.requiredSteps ?? 0)));
const formula = `${pool}d${TS_DIE_FACES}`;
const roll = new HbmTSRoll(formula, {}, { tsParams: { pool, threshold, required, flavor: params.flavor } });
return roll;
}
override async evaluate(options: Parameters<Roll['evaluate']>[0] = {}): Promise<this> {
await super.evaluate(options);
this.ts = computeTsResult(this);
return this;
}
/** Synchronous variant for non-async callers (Foundry exposes evaluateSync). */
override evaluateSync(options: Parameters<Roll['evaluateSync']>[0] = {}): this {
super.evaluateSync(options);
this.ts = computeTsResult(this);
return this;
}
override async render(options: Parameters<Roll['render']>[0] = {}): Promise<string> {
if (!this.ts) this.ts = computeTsResult(this);
const dice = this.ts.faces.map((face) => ({
face,
cls: faceClass(face, this.ts!.threshold),
}));
const data = {
formula: this.formula,
total: this.ts.successes,
ts: this.ts,
dice,
flavor: (this.options as Record<string, unknown>).tsParams
? ((this.options as Record<string, { flavor?: string }>).tsParams.flavor ?? options.flavor)
: options.flavor,
};
return foundry.applications.handlebars.renderTemplate(HbmTSRoll.CHAT_TEMPLATE, data);
}
}
function clampThreshold(t: number): number {
return Math.max(2, Math.min(6, Math.floor(t)));
}
function faceClass(face: number, threshold: number): string {
if (face === TS_AUTO_FAILURE_FACE) return 'die crit-failure';
if (face === TS_AUTO_SUCCESS_FACE) return 'die crit-success';
if (face >= threshold) return 'die success';
return 'die failure';
}
function computeTsResult(roll: Roll): HbmTsRollResult {
const params = (roll.options as Record<string, HbmTsRollData | undefined>).tsParams;
const threshold = clampThreshold(params?.threshold ?? TS_DEFAULT_THRESHOLD);
const required = Math.max(1, Math.floor(params?.required ?? TS_DEFAULT_REQUIRED));
// Collect raw die faces from all DiceTerm results in the roll.
const faces: number[] = [];
for (const term of roll.terms) {
// foundry.dice.terms.DiceTerm — has .results array with { result } entries.
const anyTerm = term as unknown as { results?: Array<{ result: number; active?: boolean; discarded?: boolean }> };
if (Array.isArray(anyTerm.results)) {
for (const r of anyTerm.results) {
if (r.discarded) continue;
faces.push(r.result);
}
}
}
let successes = 0;
let criticalSuccesses = 0;
let criticalFailures = 0;
for (const face of faces) {
if (face === TS_AUTO_FAILURE_FACE) {
criticalFailures += 1;
continue;
}
if (face === TS_AUTO_SUCCESS_FACE) {
criticalSuccesses += 1;
successes += 1;
continue;
}
if (face >= threshold) successes += 1;
}
return {
faces,
successes,
required,
threshold,
pool: faces.length,
isSuccess: successes >= required,
criticalSuccesses,
criticalFailures,
};
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Permissive shims for Foundry VTT v13 globals.
*
* The official @league-of-foundry-developers/foundry-vtt-types package is
* still being aligned to v13's API surface and many generic constraints are
* over-strict for v13's actual runtime. To keep our `bun run typecheck`
* green for our own logic, we declare the Foundry surface we touch loosely.
*/
declare class Roll {
[key: string]: any;
constructor(formula?: string, data?: Record<string, unknown>, options?: Record<string, unknown>);
static CHAT_TEMPLATE: string;
formula: string;
options: Record<string, any>;
terms: any[];
total: number;
evaluate(options?: Record<string, unknown>): Promise<this>;
evaluateSync(options?: Record<string, unknown>): this;
render(options?: Record<string, unknown>): Promise<string>;
toMessage(data?: Record<string, unknown>, options?: Record<string, unknown>): Promise<any>;
}
declare class Combat {
[key: string]: any;
combatants: any[];
combatant: any;
}
declare class Combatant {
[key: string]: any;
actor: any;
}
declare class Actor {
[key: string]: any;
}
declare class Item {
[key: string]: any;
}
declare class ActorSheet { [key: string]: any; }
declare class ItemSheet { [key: string]: any; }
declare class FormDataExtended {
constructor(form: HTMLFormElement);
readonly object: Record<string, unknown>;
}
declare const Handlebars: {
registerHelper(name: string, fn: (...args: any[]) => any): void;
};
// TextEditor is now namespaced in v13; kept as shim for backwards compat tooling.
// Use foundry.applications.ux.TextEditor.implementation at runtime.
declare function fromUuid<T = unknown>(uuid: string): Promise<T | null>;
declare class ChatMessage {
[key: string]: any;
static create(data: Record<string, unknown>): Promise<any>;
static getWhisperRecipients(name: string): any[];
static getSpeaker(data?: Record<string, unknown>): any;
}
declare namespace ChatMessage {
type SpeakerData = Record<string, unknown>;
}
declare const Hooks: {
on(event: string, fn: (...args: any[]) => any): number;
once(event: string, fn: (...args: any[]) => any): number;
off(event: string, id: number | ((...args: any[]) => any)): void;
};
declare const CONFIG: any;
declare const game: any;
declare const ui: any;
declare namespace foundry {
namespace utils {
function getProperty(obj: any, path: string): any;
function setProperty(obj: any, path: string, value: any): boolean;
function mergeObject<T = any>(original: T, other?: any, options?: any): T;
function deepClone<T>(obj: T): T;
function randomID(length?: number): string;
}
namespace abstract {
class TypeDataModel {
[key: string]: any;
static defineSchema(): Record<string, any>;
prepareDerivedData(): void;
}
class DataModel {
[key: string]: any;
}
}
namespace data {
namespace fields {
class DataField { constructor(options?: Record<string, unknown>); }
namespace DataField { type Any = DataField; }
class SchemaField extends DataField { constructor(fields: Record<string, any>, options?: Record<string, unknown>); }
class StringField extends DataField {}
class NumberField extends DataField {}
class BooleanField extends DataField {}
class HTMLField extends DataField {}
class ArrayField extends DataField { constructor(element: any, options?: Record<string, unknown>); }
class ObjectField extends DataField {}
}
}
/** v12-compat layer, always available in v13 */
namespace appv1 {
namespace api {
class Dialog {
constructor(data: {
title: string;
content: string;
buttons: Record<string, { icon?: string; label: string; callback?: (html: any) => void }>;
default: string;
close?: () => void;
});
render(force?: boolean): this;
}
}
namespace sheets {
class ActorSheet { [key: string]: any; }
class ItemSheet { [key: string]: any; }
}
}
namespace applications {
namespace ux {
const TextEditor: {
implementation: {
getDragEventData(event: DragEvent): Record<string, unknown> | null;
};
};
class FormDataExtended {
constructor(form: HTMLFormElement);
readonly object: Record<string, unknown>;
}
}
namespace api {
class DialogV2 {
static wait(options: {
title: string;
content: string;
buttons: Array<{ type: string; label: string; action: string; default?: boolean }>;
rejectClose?: boolean;
modal?: boolean;
}): Promise<string | null>;
static prompt(options: {
title: string;
content: string;
label?: string;
rejectClose?: boolean;
modal?: boolean;
ok?: { callback: (event: Event, button: any, dialog: HTMLElement) => any };
}): Promise<any>;
static inform(options: { title: string; content: string; rejectClose?: boolean }): Promise<void>;
}
class ApplicationV2 {
[key: string]: any;
static DEFAULT_OPTIONS: any;
static PARTS: any;
element: HTMLElement;
tabGroups: Record<string, string>;
_prepareContext(options?: any): Promise<any>;
_preparePartContext(partId: string, context: any): Promise<any>;
_onRender(context?: any, options?: any): void | Promise<void>;
changeTab(tabId: string, groupId: string, options?: any): void;
}
function HandlebarsApplicationMixin<T>(base: T): T;
}
namespace sheets {
class ActorSheetV2 extends api.ApplicationV2 { document: any; actor: any; }
class ItemSheetV2 extends api.ApplicationV2 { document: any; item: any; }
}
namespace handlebars {
function renderTemplate(path: string, data: Record<string, unknown>): Promise<string>;
}
}
namespace documents {
namespace collections {
const Actors: { unregisterSheet(scope: string, cls: any): void; registerSheet(scope: string, cls: any, options: Record<string, unknown>): void };
const Items: { unregisterSheet(scope: string, cls: any): void; registerSheet(scope: string, cls: any, options: Record<string, unknown>): void };
}
}
}
// Vite define replacement
declare const __SYSTEM_ID__: string;
+144
View File
@@ -0,0 +1,144 @@
/**
* Homebrew Magic: RPG v3 — Foundry VTT system entry point.
*
* All internal identifiers are English. Polish is reserved for user-facing
* labels supplied via `lang/pl.json` (the canonical localisation).
*/
declare const __SYSTEM_ID__: string;
declare const Babele: any;
import { HbmTSRoll } from './dice/ts-roll';
import { CharacterData } from './data/actor-character';
import { NpcData } from './data/actor-npc';
import { SpellData } from './data/item-spell';
import { GearData } from './data/item-gear';
import { AbilityData } from './data/item-ability';
import { ClassData } from './data/item-class';
import { RaceData } from './data/item-race';
import { DisciplineData } from './data/item-discipline';
import { TalentData } from './data/item-talent';
import { CharacterSheet } from './sheets/character-sheet';
import { NpcSheet } from './sheets/npc-sheet';
import { HbmItemSheet } from './sheets/item-sheet';
import { registerHbmConditions } from './logic/conditions';
import { registerCombatHooks } from './logic/combat';
import { registerMigrationSettings, runPendingMigrations } from './migrations';
import { registerTriggerHooks } from './logic/spell-triggers';
import { registerChatCardHooks } from './logic/chat-cards';
import { castSpell } from './logic/spell-cast';
import * as bloodMagic from './logic/blood-magic';
import * as abyssMagic from './logic/abyss-magic';
import * as brewing from './logic/brewing';
import { rest } from './logic/rest';
import * as trade from './logic/trade';
export const SYSTEM_ID = __SYSTEM_ID__;
Hooks.once('init', () => {
console.log(`${SYSTEM_ID} | Initialising Homebrew Magic: RPG v3 system`);
// Register Babele translation if active
if (typeof Babele !== 'undefined') {
Babele.get().register({
module: SYSTEM_ID,
lang: 'en',
dir: 'lang/compendium/en'
});
}
// Register custom roll
CONFIG.Dice.rolls.unshift(HbmTSRoll as unknown as typeof Roll);
// Register data models
CONFIG.Actor.dataModels = {
...(CONFIG.Actor.dataModels ?? {}),
character: CharacterData,
npc: NpcData,
} as Record<string, typeof foundry.abstract.TypeDataModel>;
CONFIG.Item.dataModels = {
...(CONFIG.Item.dataModels ?? {}),
spell: SpellData,
gear: GearData,
ability: AbilityData,
class: ClassData,
race: RaceData,
discipline: DisciplineData,
talent: TalentData,
} as Record<string, typeof foundry.abstract.TypeDataModel>;
// Register sheets
foundry.documents.collections.Actors.unregisterSheet('core', foundry.appv1.sheets.ActorSheet);
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, CharacterSheet, {
types: ['character'],
makeDefault: true,
label: 'HBM.actor.character',
});
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, NpcSheet, {
types: ['npc'],
makeDefault: true,
label: 'HBM.actor.npc',
});
foundry.documents.collections.Items.unregisterSheet('core', foundry.appv1.sheets.ItemSheet);
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, HbmItemSheet, {
makeDefault: true,
label: 'HBM.system.name',
});
registerHbmConditions();
registerMigrationSettings();
// Preload Handlebars partials so the first render is synchronous.
(foundry.applications.handlebars as any).loadTemplates([
// Shared item partials
'systems/hbm-rpg-v3/templates/item/_dispatch.hbs',
'systems/hbm-rpg-v3/templates/item/_effects.hbs',
'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
'systems/hbm-rpg-v3/templates/item/header.hbs',
// Item body partials (used via {{> (lookup this 'bodyPartial')}})
'systems/hbm-rpg-v3/templates/item/talent.hbs',
'systems/hbm-rpg-v3/templates/item/spell.hbs',
'systems/hbm-rpg-v3/templates/item/gear.hbs',
'systems/hbm-rpg-v3/templates/item/ability.hbs',
'systems/hbm-rpg-v3/templates/item/class.hbs',
'systems/hbm-rpg-v3/templates/item/race.hbs',
'systems/hbm-rpg-v3/templates/item/discipline.hbs',
// Actor partials
'systems/hbm-rpg-v3/templates/actor/_actor-effects.hbs',
]);
// Override default initiative formula to HbM's 2d6 + initiative attribute.
CONFIG.Combat.initiative = {
formula: '2d6 + @attributes.initiative',
decimals: 2,
};
// Handlebars helpers used in templates
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
Handlebars.registerHelper('gt', (a: number, b: number) => a > b);
Handlebars.registerHelper('lt', (a: number, b: number) => a < b);
Handlebars.registerHelper('or', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).some(Boolean));
Handlebars.registerHelper('and', (...args: unknown[]) => (args.slice(0, -1) as unknown[]).every(Boolean));
Handlebars.registerHelper('concat', (...args: unknown[]) => (args.slice(0, -1) as string[]).join(''));
});
Hooks.once('ready', () => {
registerCombatHooks();
registerTriggerHooks();
registerChatCardHooks();
void runPendingMigrations();
// Expose system API on game object for macros & external modules.
(game as any).hbm = {
castSpell,
bloodMagic,
abyssMagic,
brewing,
rest,
trade,
};
console.log(`${SYSTEM_ID} | Ready`);
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Magia Otchłani — Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII).
*
* The Abyss splits into two disciplines:
* - Magia Aspektów ("aspects") — controlled, low-risk, predictable
* - Pierwotna Magia ("primal") — chaotic, high-power, requires `Dary Otchłani` rolls
*
* This module exposes:
* - dispatchAbyssCast(spell) — returns 'aspects' | 'primal' from spell.discipline.
* - rollAbyssGift(actor) — d100 against a roll table (resolved at runtime
* from the `roll-tables-abyss.dary-otchlani` pack
* when present; otherwise falls back to a chat
* prompt for the GM).
* - rollMistrzLosuPenalty(actor) — d100 penalty roll (Klątwa Otchłani VII).
* - addInsanity(actor, n) — increments actor.system.attributes.insanity;
* when crossing a threshold, fires a hook for
* the GM to apply a mental condition.
*/
import type { CastableActor } from './spell-cast';
export type AbyssDiscipline = 'aspects' | 'primal';
const PRIMAL_KEYS = new Set(['primal', 'pierwotna-magia', 'pierwotna_magia', 'magia-otchlani-pierwotna']);
interface SpellLikeForAbyss {
system?: {
school?: string;
discipline?: string | string[];
requirements?: { discipline?: string[] };
};
}
export function dispatchAbyssCast(spell: SpellLikeForAbyss): AbyssDiscipline {
const disc = spell.system?.discipline ?? spell.system?.requirements?.discipline ?? [];
const list = (Array.isArray(disc) ? disc : [disc]).map((s) => String(s).toLowerCase());
for (const d of list) {
if (PRIMAL_KEYS.has(d)) return 'primal';
}
return 'aspects';
}
/** Insanity thresholds (Klątwa Otchłani VIII). Crossing any triggers a mental-condition roll. */
const INSANITY_THRESHOLDS = [3, 6, 10, 15] as const;
export async function addInsanity(actor: CastableActor, n: number): Promise<{ before: number; after: number; thresholdsCrossed: number[] }> {
if (n <= 0) return { before: 0, after: 0, thresholdsCrossed: [] };
const before = ((actor.system.attributes as any).insanity ?? 0) as number;
const after = before + n;
await actor.update({ 'system.attributes.insanity': after });
const crossed = INSANITY_THRESHOLDS.filter((t) => before < t && after >= t);
if (crossed.length > 0) {
Hooks.callAll('hbm.insanityThreshold', actor, crossed, { before, after });
}
return { before, after, thresholdsCrossed: [...crossed] };
}
/** Roll d100 against a named table inside the abyss roll-tables pack. */
export async function rollAbyssGift(actor: CastableActor, tableName = 'dary-otchlani'): Promise<Roll> {
return rollAgainstAbyssTable(actor, tableName, 'Dary Otchłani');
}
export async function rollMistrzLosuPenalty(actor: CastableActor): Promise<Roll> {
return rollAgainstAbyssTable(actor, 'mistrz-losu', 'Mistrz Losu');
}
async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor: string): Promise<Roll> {
const roll = new Roll('1d100');
await roll.evaluate();
const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor });
await roll.toMessage({
flavor: `${flavor}${actor.name} (${slug})`,
speaker,
});
Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total);
return roll;
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Magia Krwi — Blood Magic logic (Arcanum Sanguinis Ch. III).
*
* Three primitives:
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
* - selfHarm(actor, hp) : convert hp → blood at 2:1 ratio (2 HP → 1 Blood).
* - lifeStealOnDamage(...) : when actor inflicts damage with a blood spell,
* restore blood = floor(damage / 4) (capped at max).
*
* Future hook: `Szacunek do Życia` talent triples self-harm cost (6 HP → 1 Blood).
*
* The cast pipeline (logic/spell-cast.ts) already deducts `bloodCost` directly;
* this module is the reusable API surface for UI dialogs and the damage hook.
*/
import type { CastableActor } from './spell-cast';
const SELF_HARM_RATIO = 2; // HP per 1 Blood
const RESPECT_FOR_LIFE_PENALTY = 3; // multiplier when talent present
const LIFE_STEAL_DIVISOR = 4; // damage / N → blood restored
interface ActorWithItems extends CastableActor {
items?: Iterable<{ type: string; system?: { slug?: string } }>;
}
function hasRespectForLife(actor: ActorWithItems): boolean {
if (!actor.items) return false;
for (const it of actor.items) {
if (it.type !== 'talent') continue;
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug;
if (slug === 'respect-for-life' || slug === 'szacunek-do-zycia') return true;
}
return false;
}
export interface BloodSpendResult {
ok: boolean;
spent: number;
remaining: number;
reason?: string;
}
/** Deduct `amount` from `actor.system.attributes.blood.value`. */
export async function spendBlood(actor: CastableActor, amount: number): Promise<BloodSpendResult> {
const blood = actor.system.attributes.blood;
if (!blood) return { ok: false, spent: 0, remaining: 0, reason: 'no-blood-pool' };
if (amount <= 0) return { ok: true, spent: 0, remaining: blood.value };
if (blood.value < amount) {
return { ok: false, spent: 0, remaining: blood.value, reason: 'insufficient-blood' };
}
await actor.update({ 'system.attributes.blood.value': blood.value - amount });
return { ok: true, spent: amount, remaining: blood.value - amount };
}
/**
* Self-Harm: convert health into blood. Returns blood gained.
* Default ratio 2 HP → 1 Blood; with `Szacunek do Życia` talent → 6 HP → 1 Blood.
*/
export async function selfHarm(actor: CastableActor, hpSpent: number): Promise<{ ok: boolean; bloodGained: number; reason?: string }> {
if (hpSpent <= 0) return { ok: true, bloodGained: 0 };
const a = actor.system.attributes;
const blood = a.blood;
if (!blood) return { ok: false, bloodGained: 0, reason: 'no-blood-pool' };
const health = (a as any).health as { value: number; max: number } | undefined;
if (!health || health.value < hpSpent) {
return { ok: false, bloodGained: 0, reason: 'insufficient-health' };
}
const ratio = hasRespectForLife(actor as ActorWithItems) ? SELF_HARM_RATIO * RESPECT_FOR_LIFE_PENALTY : SELF_HARM_RATIO;
const gained = Math.floor(hpSpent / ratio);
if (gained <= 0) return { ok: false, bloodGained: 0, reason: 'ratio-too-low' };
const newBlood = Math.min(blood.max, blood.value + gained);
await actor.update({
'system.attributes.health.value': health.value - hpSpent,
'system.attributes.blood.value': newBlood,
});
return { ok: true, bloodGained: newBlood - blood.value };
}
/** Life Steal: invoked from the damage-application hook when the source is a blood spell. */
export async function lifeStealOnDamage(actor: CastableActor, damageDealt: number): Promise<number> {
if (damageDealt <= 0) return 0;
const blood = actor.system.attributes.blood;
if (!blood) return 0;
const restore = Math.floor(damageDealt / LIFE_STEAL_DIVISOR);
if (restore <= 0) return 0;
const newBlood = Math.min(blood.max, blood.value + restore);
await actor.update({ 'system.attributes.blood.value': newBlood });
return newBlood - blood.value;
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Warzenie Eliksirów — Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
*
* Each character has `attributes.elixirTolerance` (default 0). Soft cap = body+1.
* Drinking a potion increments the counter; exceeding the cap triggers a
* poisoning hook (`hbm.elixirOverdose`). Long rest restores 1 tolerance
* (handled by rest.ts); the `Nadzwyczajna Odporność` discipline-passive lets
* a Short Rest restore 1 instead.
*
* Brewing a potion: TS test `Magic + Brewing skill` against the recipe's
* difficulty. Caller passes the recipe (`{ name, difficulty, ingredients[] }`).
*/
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
import type { CastableActor } from './spell-cast';
export interface ElixirRecipe {
name: string;
/** TS test target — { threshold, successes }. */
difficulty: { threshold: number; successes: number };
/** Ingredient names (free-form). */
ingredients: string[];
/** Discipline skill key used for the brew test (default: alchemyBrewing). */
skill?: string;
}
export function elixirToleranceCap(actor: CastableActor): number {
const body = actor.system.attributes.body?.value ?? 1;
return body + 1;
}
/** Drink a potion: increment tolerance, fire `hbm.elixirOverdose` on overflow. */
export async function consumeElixir(actor: CastableActor, recipe: Pick<ElixirRecipe, 'name'>): Promise<{ tolerance: number; overdose: boolean }> {
const cur = ((actor.system.attributes as any).elixirTolerance ?? 0) as number;
const next = cur + 1;
await actor.update({ 'system.attributes.elixirTolerance': next });
const overdose = next > elixirToleranceCap(actor);
if (overdose) Hooks.callAll('hbm.elixirOverdose', actor, recipe.name, next);
return { tolerance: next, overdose };
}
/** Brewing TS test: pool = magic + skill (default `alchemyBrewing`). */
export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Promise<{ roll: HbmTSRoll; success: boolean }> {
const skillKey = recipe.skill ?? 'alchemyBrewing';
const skillValue = actor.system.skills?.[skillKey]?.value ?? 0;
const pool = actor.system.attributes.magic.actual + skillValue;
const roll = HbmTSRoll.fromParams({
pool,
threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
required: recipe.difficulty.successes ?? TS_DEFAULT_REQUIRED,
flavor: `Warzenie: ${recipe.name}`,
});
await roll.evaluate();
await roll.toMessage({
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '—'})`,
});
return { roll, success: !!roll.ts?.isSuccess };
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Click handlers for the rich spell-cast chat card.
*
* Wires:
* - `data-action="hbm-apply-status"` → toggle/add a CONFIG.statusEffects entry
* on every selected/targeted token of the current user.
* - `data-action="hbm-apply-damage"` → run the system damage pipeline against
* the actor identified by `data-target-uuid`.
*/
import { applyDamage } from './damage';
import { CONDITIONS } from './conditions';
declare const Hooks: any;
declare const fromUuid: <T = unknown>(uuid: string) => Promise<T | null>;
declare const game: any;
declare const canvas: any;
declare const ui: any;
export function registerChatCardHooks(): void {
Hooks.on('renderChatMessageHTML', (_msg: unknown, html: HTMLElement | JQuery) => {
const root = (html as any)?.jquery ? (html as any)[0] : (html as HTMLElement);
if (!root) return;
root.querySelectorAll<HTMLElement>('button.hbm-action[data-action="hbm-apply-status"]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
void onApplyStatus(btn);
});
});
root.querySelectorAll<HTMLElement>('button.hbm-action[data-action="hbm-apply-damage"]').forEach((btn) => {
btn.addEventListener('click', (ev) => {
ev.preventDefault();
void onApplyDamage(btn);
});
});
});
}
async function onApplyStatus(btn: HTMLElement): Promise<void> {
const id = btn.dataset.effectId;
if (!id) return;
const def = CONDITIONS.find((c) => c.id === id);
if (!def) {
ui.notifications?.warn(`Unknown condition: ${id}`);
return;
}
const tokens = collectTargetTokens();
if (tokens.length === 0) {
ui.notifications?.warn(game.i18n?.localize?.('HBM.spellCast.noTargets') ?? 'No targets selected.');
return;
}
for (const token of tokens) {
const actor = token.actor;
if (!actor) continue;
if (typeof actor.toggleStatusEffect === 'function') {
await actor.toggleStatusEffect(id, { active: true });
}
}
ui.notifications?.info(`${game.i18n?.localize?.(def.i18nKey) ?? id}${tokens.length}`);
}
async function onApplyDamage(btn: HTMLElement): Promise<void> {
const uuid = btn.dataset.targetUuid;
const amount = Number(btn.dataset.amount ?? 0);
if (!uuid || !Number.isFinite(amount) || amount <= 0) return;
const target = await fromUuid<any>(uuid);
if (!target) {
ui.notifications?.warn(`Target not found: ${uuid}`);
return;
}
const ignoresArmor = btn.dataset.ignoresArmor === 'true';
await applyDamage(target, {
amount,
type: (btn.dataset.type as any) ?? 'magical',
ignoreMagicalArmor: ignoresArmor,
ignoreMagicalShield: ignoresArmor,
ignorePhysicalArmor: ignoresArmor,
} as any);
}
function collectTargetTokens(): any[] {
const tokens: any[] = [];
const targets = game.user?.targets;
if (targets && typeof targets[Symbol.iterator] === 'function') {
for (const t of targets) tokens.push(t);
}
if (tokens.length === 0 && canvas?.tokens?.controlled?.length) {
tokens.push(...canvas.tokens.controlled);
}
return tokens;
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Combat hooks — handle per-round Mana reset, per-turn Zeal regen,
* and condition-driven turn behavior (skip / damage tick / death save).
*/
import { applyDamage } from './damage';
function hasStatus(actor: any, id: string): boolean {
const effects = actor?.effects ?? [];
for (const ef of effects) {
if (ef?.disabled) continue;
const statuses = ef?.statuses;
if (statuses && typeof statuses.has === 'function' ? statuses.has(id) : Array.isArray(statuses) && statuses.includes(id)) {
return true;
}
}
return false;
}
export function registerCombatHooks(): void {
Hooks.on('combatRound', async (combat: Combat, _updateData: unknown, _options: { advanceTime?: number; direction?: number }) => {
for (const combatant of combat.combatants) {
const actor = combatant.actor;
if (!actor || actor.type !== 'character') continue;
const sys = actor.system as { attributes: { mana: { max: number; value: number } } };
await actor.update({ 'system.attributes.mana.value': sys.attributes.mana.max });
}
ChatMessage.create({
content: `<em>${game.i18n.localize('HBM.combat.newRound')}</em>`,
whisper: ChatMessage.getWhisperRecipients('GM'),
});
});
Hooks.on('combatTurn', async (combat: Combat) => {
const combatant = combat.combatant;
const actor = combatant?.actor;
if (!actor) return;
// Skip turn for unconscious / restrained-to-incapacitation
if (hasStatus(actor, 'nieprzytomny') || hasStatus(actor, 'obezwladniony')) {
ChatMessage.create({
content: `<em>${actor.name}: ${game.i18n.localize('HBM.combat.turnSkipped')}</em>`,
});
try { await (combat as any).nextTurn?.(); } catch { /* ignore */ }
return;
}
// Burning: tick environmental damage
if (hasStatus(actor, 'podpalony')) {
await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true });
}
// Dying: prompt death save (simplified — posts a chat reminder)
if (hasStatus(actor, 'umierajacy')) {
ChatMessage.create({
content: `<strong>${actor.name}</strong>: ${game.i18n.localize('HBM.combat.deathSavePrompt')}`,
});
}
// Per-turn Zeal regen (characters) — base 1 + talent bonus from flag.
if (actor.type === 'character') {
const sys = actor.system as { attributes: { zeal: { value: number; max: number } } };
const regen = getZealRegen(actor);
const next = Math.min(sys.attributes.zeal.max, sys.attributes.zeal.value + regen);
if (next !== sys.attributes.zeal.value) {
await actor.update({ 'system.attributes.zeal.value': next });
}
}
});
}
/**
* Zeal regenerated at the start of an actor's turn.
* Defaults to 1; talents may add bonus via `flags['hbm-rpg-v3'].zealRegenBonus`
* (typically set by an ActiveEffect with mode ADD targeting that flag path).
*/
export function getZealRegen(actor: any): number {
const base = 1;
const bonus = Number(actor?.getFlag?.('hbm-rpg-v3', 'zealRegenBonus') ?? 0) || 0;
return Math.max(0, base + bonus);
}
+77
View File
@@ -0,0 +1,77 @@
/**
* HbM canonical status conditions (Aneks A — Podręcznik Gry).
* 15 entries with Active-Effect change ops and `flags.hbm.*` metadata
* consumed by the roll pipeline and combat hooks.
*/
import { SYSTEM_ID } from '../hbm';
export interface ConditionDef {
id: string;
i18nKey: string;
icon: string;
changes?: Array<{ key: string; mode: number; value: string; priority?: number }>;
flags?: Record<string, unknown>;
}
const CHANGE_MODE_ADD = 2;
export const CONDITIONS: ConditionDef[] = [
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true } } },
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } },
{ id: 'obezwladniony', i18nKey: 'HBM.conditions.obezwladniony', icon: 'icons/svg/blood.svg', flags: { hbm: { blocksTags: ['attack'], defenseT: 2 } } },
{ id: 'nieprzytomny', i18nKey: 'HBM.conditions.nieprzytomny', icon: 'icons/svg/unconscious.svg', flags: { hbm: { skipTurn: true, blocksAllActions: true } } },
{ id: 'umierajacy', i18nKey: 'HBM.conditions.umierajacy', icon: 'icons/svg/skull.svg', flags: { hbm: { dyingState: { failures: 0, hits: 0, mortalDamage: 0 } } } },
{ id: 'ogluszony', i18nKey: 'HBM.conditions.ogluszony', icon: 'icons/svg/deaf.svg', flags: { hbm: { blocksTags: ['hearing'] } } },
{ id: 'oslepiony', i18nKey: 'HBM.conditions.oslepiony', icon: 'icons/svg/blind.svg', flags: { hbm: { blocksTags: ['sight'] } } },
{ id: 'oszolomiony', i18nKey: 'HBM.conditions.oszolomiony', icon: 'icons/svg/daze.svg', flags: { hbm: { maxActions: 1, blockZeal: true, halveSpeed: true } } },
{
id: 'zatruty',
i18nKey: 'HBM.conditions.zatruty',
icon: 'icons/svg/poison.svg',
changes: [
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
],
},
{ id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } },
{ id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } },
{
id: 'przeklety',
i18nKey: 'HBM.conditions.przeklety',
icon: 'icons/svg/sun.svg',
changes: [
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
],
flags: { hbm: { blocksRegen: true } },
},
{ id: 'zauroczony', i18nKey: 'HBM.conditions.zauroczony', icon: 'icons/svg/heal.svg', flags: { hbm: { thresholdSteps: { social: 1 }, charmedBy: '' } } },
{ id: 'koncentracja', i18nKey: 'HBM.conditions.koncentracja', icon: 'icons/svg/aura.svg', flags: { hbm: { concentration: true, persistent: true } } },
// Provisional: book description incomplete for Przerażony (Aneks A placeholder).
{ id: 'przerazony', i18nKey: 'HBM.conditions.przerazony', icon: 'icons/svg/terror.svg', flags: { hbm: { thresholdSteps: { all: 1 }, todoBookGap: true } } },
// Mental illnesses (Klątwa Otchłani Ch. VIII — book chapter currently a placeholder;
// these are stub registrations with `todoBookGap: true` until full mechanics drop).
{ id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
{ id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
];
export function registerHbmConditions(): void {
CONFIG.statusEffects = CONDITIONS.map((c) => ({
id: c.id,
name: c.i18nKey,
img: c.icon,
statuses: [c.id],
changes: c.changes ?? [],
flags: c.flags ?? {},
}));
console.log(`${SYSTEM_ID} | Registered ${CONDITIONS.length} canonical status effects`);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* Damage application engine — flows through 4 layers:
* 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter)
* 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
* 4. Health (the leftover hits HP)
*
* Bypass flags allow effects to skip individual layers.
*
* After resolving, if HP loss meets the Oszołomiony threshold ⌈(C+U+D)/3⌉
* the effect is auto-applied to character actors.
*/
import { DamageType } from '../constants';
export interface ApplyDamageOptions {
amount: number;
type?: DamageType;
ignoreMagicalArmor?: boolean;
ignoreMagicalShield?: boolean;
ignorePhysicalArmor?: boolean;
damageArmor?: boolean;
postChat?: boolean;
}
export interface DamageReport {
amount: number;
absorbed: { magicalArmor: number; magicalShield: number; physicalArmor: number };
hpDamage: number;
shieldDropped: boolean;
oszolomionyApplied: boolean;
}
interface ActorLike {
name: string;
type: string;
system: any;
update: (changes: Record<string, unknown>) => Promise<unknown>;
toggleStatusEffect?: (id: string, options?: any) => Promise<any> | any;
effects?: { find: (fn: (e: any) => boolean) => any };
}
export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): Promise<DamageReport> {
const total = Math.max(0, Math.floor(opts.amount));
const a = actor.system.attributes;
const updates: Record<string, unknown> = {};
const report: DamageReport = {
amount: total,
absorbed: { magicalArmor: 0, magicalShield: 0, physicalArmor: 0 },
hpDamage: 0,
shieldDropped: false,
oszolomionyApplied: false,
};
let remaining = total;
// Layer 1: Magical Armor — DR-based; runicCounter accumulates incoming damage.
if (!opts.ignoreMagicalArmor && a.magicalArmor) {
const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
const absorbed = Math.min(dr, remaining);
if (absorbed > 0) {
const incomingDmg = remaining;
report.absorbed.magicalArmor = absorbed;
remaining -= absorbed;
const newCounter = (Number(a.magicalArmor.runicCounter) || 0) + incomingDmg;
const decrements = Math.floor(newCounter / 5);
const newValue = Math.max(0, dr - decrements);
updates['system.attributes.magicalArmor.runicCounter'] = newCounter % 5;
updates['system.attributes.magicalArmor.value'] = newValue;
}
}
// Layer 2: Magical Shield — pure temp HP (raw subtraction).
if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
const shield = Math.max(0, Number(a.magicalShield.value) || 0);
const taken = Math.min(shield, remaining);
if (taken > 0) {
report.absorbed.magicalShield = taken;
remaining -= taken;
const newShield = shield - taken;
updates['system.attributes.magicalShield.value'] = newShield;
if (newShield === 0) report.shieldDropped = true;
}
}
// Layer 3: Physical Armor — flat DR; optionally degrade condition.
if (remaining > 0 && !opts.ignorePhysicalArmor && a.physicalArmor) {
const dr = Math.max(0, Number(a.physicalArmor.value) || 0);
const absorbed = Math.min(dr, remaining);
if (absorbed > 0) {
report.absorbed.physicalArmor = absorbed;
remaining -= absorbed;
}
if (opts.damageArmor && actor.type === 'character' && a.physicalArmor.condition != null) {
// Decrement durability of the first equipped armor piece (character only)
const items = (actor as any).items as Iterable<any> | undefined;
if (items) {
for (const it of items) {
if (it.type === 'gear' && it.system?.equipped && it.system?.category === 'armor') {
const cond = Math.max(0, Number(it.system.armor?.condition ?? 0) - 1);
await it.update({ 'system.armor.condition': cond });
break;
}
}
}
}
}
// Layer 4: Health
if (remaining > 0 && a.health) {
const hp = Math.max(0, Number(a.health.value) || 0);
const newHp = Math.max(0, hp - remaining);
report.hpDamage = hp - newHp;
updates['system.attributes.health.value'] = newHp;
}
if (Object.keys(updates).length > 0) await actor.update(updates);
if (opts.postChat ?? true) {
const lines: string[] = [
`<strong>${actor.name}</strong> — ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
];
if (report.absorbed.magicalArmor) lines.push(`${game.i18n.localize('HBM.resources.magicalArmor')}: ${report.absorbed.magicalArmor}`);
if (report.absorbed.magicalShield) lines.push(`${game.i18n.localize('HBM.resources.magicalShield')}: ${report.absorbed.magicalShield}${report.shieldDropped ? ' ✦' : ''}`);
if (report.absorbed.physicalArmor) lines.push(`${game.i18n.localize('HBM.resources.physicalArmor')}: ${report.absorbed.physicalArmor}`);
if (report.hpDamage) lines.push(`${game.i18n.localize('HBM.resources.health')}: ${report.hpDamage}`);
await ChatMessage.create({ content: `<div class="hbm-damage-report">${lines.join('<br/>')}</div>` });
}
return report;
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Rest logic.
*
* Short Rest (Krótki Odpoczynek):
* - Restore HP equal to actor.body.value (capped at max).
* - Restore mana to max-per-spell? — no: short rest does NOT restore mana.
* - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
*
* Long Rest (Długi Odpoczynek):
* - Restore HP, mana, zeal to max.
* - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? — book says
* elixir tolerance recovers per long rest by default; tracked here as -1).
* - Reset blood pool to max (assumption — refine when AS spell list lands).
* - Clear runic counter on magical armor.
*
* Hooks `hbm.beforeRest` and `hbm.afterRest` fire for module integration.
*/
import type { CastableActor } from './spell-cast';
import { CONDITIONS } from './conditions';
export type RestKind = 'breather' | 'short' | 'long';
declare const Hooks: any;
declare const Roll: any;
interface ActorWithItems extends CastableActor {
items?: Iterable<{ type: string; system?: { slug?: string }; flags?: any }>;
}
function hasExtraordinaryResilience(actor: ActorWithItems): boolean {
if (!actor.items) return false;
for (const it of actor.items) {
const slug = (it as any).system?.slug ?? (it as any).flags?.['hbm-rpg-v3']?.slug ?? '';
if (slug === 'extraordinary-resilience' || slug === 'nadzwyczajna-odpornosc') return true;
}
return false;
}
export interface RestResult {
kind: RestKind;
hpRestored: number;
manaRestored: number;
zealRestored: number;
bloodRestored: number;
toleranceRecovered: number;
effectsCleared: number;
}
export async function rest(actor: CastableActor, kind: RestKind): Promise<RestResult> {
Hooks.callAll('hbm.beforeRest', actor, kind);
const a = actor.system.attributes as any;
const update: Record<string, unknown> = {};
const hpBefore = a.health?.value ?? 0;
const manaBefore = a.mana?.value ?? 0;
const zealBefore = a.zeal?.value ?? 0;
const bloodBefore = a.blood?.value ?? 0;
const toleranceBefore = a.elixirTolerance ?? 0;
let hpRestored = 0;
let manaRestored = 0;
let zealRestored = 0;
let bloodRestored = 0;
let toleranceRecovered = 0;
if (kind === 'breather') {
if (a.mana?.max != null && manaBefore < a.mana.max) {
update['system.attributes.mana.value'] = a.mana.max;
manaRestored = a.mana.max - manaBefore;
}
const endurance = actor.system.skills?.endurance?.value ?? 0;
const roll = new Roll('1d6 + @endurance', { endurance });
await roll.evaluate();
const rollTotal = roll.total;
const heal = Math.min(rollTotal, (a.health?.max ?? 0) - hpBefore);
if (heal > 0) {
update['system.attributes.health.value'] = hpBefore + heal;
hpRestored = heal;
}
} else if (kind === 'short') {
if (a.mana?.max != null && manaBefore < a.mana.max) {
update['system.attributes.mana.value'] = a.mana.max;
manaRestored = a.mana.max - manaBefore;
}
const healAmount = Math.ceil((a.health?.max ?? 0) / 3);
const heal = Math.min(healAmount, (a.health?.max ?? 0) - hpBefore);
if (heal > 0) {
update['system.attributes.health.value'] = hpBefore + heal;
hpRestored = heal;
}
if (hasExtraordinaryResilience(actor as ActorWithItems) && toleranceBefore > 0) {
update['system.attributes.elixirTolerance'] = toleranceBefore - 1;
toleranceRecovered = 1;
}
} else {
if (a.health?.max != null && hpBefore < a.health.max) {
update['system.attributes.health.value'] = a.health.max;
hpRestored = a.health.max - hpBefore;
}
if (a.mana?.max != null && manaBefore < a.mana.max) {
update['system.attributes.mana.value'] = a.mana.max;
manaRestored = a.mana.max - manaBefore;
}
if (a.zeal?.max != null && zealBefore < a.zeal.max) {
update['system.attributes.zeal.value'] = a.zeal.max;
zealRestored = a.zeal.max - zealBefore;
}
if (a.blood?.max != null && bloodBefore < a.blood.max) {
update['system.attributes.blood.value'] = a.blood.max;
bloodRestored = a.blood.max - bloodBefore;
}
if (toleranceBefore > 0) {
update['system.attributes.elixirTolerance'] = Math.max(0, toleranceBefore - 1);
toleranceRecovered = 1;
}
if (a.magicalArmor?.runicCounter && a.magicalArmor.runicCounter > 0) {
update['system.attributes.magicalArmor.runicCounter'] = 0;
}
}
if (Object.keys(update).length > 0) await actor.update(update);
let effectsCleared = 0;
const actorAny = actor as unknown as { effects?: any; deleteEmbeddedDocuments?: (t: string, ids: string[]) => Promise<unknown> };
const ids: string[] = [];
if (kind === 'short') {
for (const ef of actorAny.effects ?? []) {
const isUnconscious = ef.statuses?.has('nieprzytomny') || ef.flags?.core?.statusId === 'nieprzytomny' || ef.id === 'nieprzytomny';
if (isUnconscious) ids.push(ef.id);
}
} else if (kind === 'long') {
const conditionIds = new Set(CONDITIONS.map(c => c.id));
for (const ef of actorAny.effects ?? []) {
const isState = conditionIds.has(ef.id) ||
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
conditionIds.has(ef.flags?.core?.statusId);
if (isState) {
ids.push(ef.id);
continue;
}
if (ef.origin) continue;
const dur = ef.duration ?? {};
const isTemporary = (dur.seconds ?? 0) > 0 || (dur.rounds ?? 0) > 0 || (dur.turns ?? 0) > 0 || ef.flags?.['hbm-rpg-v3']?.untilLongRest === true;
if (isTemporary) ids.push(ef.id);
}
}
if (ids.length > 0 && actorAny.deleteEmbeddedDocuments) {
await actorAny.deleteEmbeddedDocuments('ActiveEffect', ids);
effectsCleared = ids.length;
}
const result: RestResult = { kind, hpRestored, manaRestored, zealRestored, bloodRestored, toleranceRecovered, effectsCleared };
Hooks.callAll('hbm.afterRest', actor, result);
return result;
}
+325
View File
@@ -0,0 +1,325 @@
/**
* Spell casting workflow — handles standard, sacred (Magia Sakralna),
* witch (Wiedźmia Magia), and blood (Magia Krwi) modes.
*
* Pipeline per cast:
* 1. validateCast() — gate on resources, race, talent, discipline,
* deity, group-cast, in-combat, witch symbols
* 2. Resource deduction — mana / zeal / blood
* 3. TS test roll — pool depends on mode
* 4. (success) damage roll — parses spell.damageBase
* 5. Status effect auto-apply — from spell.statusEffects[]
* 6. Trigger registration — from spell.triggers[]
*
* Bypasses for GM workflows: opts.bypassSuperspellWarning, opts.bypassNonCombatBlock.
*/
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
import { validateCast } from './spell-validation';
import type { ValidationResult } from './spell-validation';
import { rollSpellDamage } from './spell-damage';
import { registerSpellTriggers } from './spell-triggers';
import { CONDITIONS } from './conditions';
export interface CastableActor {
id: string;
name: string;
type: 'character' | 'npc';
system: {
attributes: {
magic: { value: number };
soul: { value: number };
mana: { value: number; max: number; maxPerSpell: number };
zeal: { value: number; max: number };
blood?: { value: number; max: number };
};
skills?: Record<string, { value: number }>;
};
update: (changes: Record<string, unknown>) => Promise<unknown>;
}
export interface SpellLike {
id?: string;
name: string;
system: {
castingMode: 'standard' | 'sacred' | 'witch' | 'blood';
school?: string;
deity?: string;
manaCost: number;
bloodCost?: number;
complexityLevel: number;
isSuperspell?: boolean;
requiresGroupCast?: boolean;
minCasters?: number;
nonCombatOnly?: boolean;
damageBase?: string;
damageType?: string;
ignoresArmor?: boolean;
statusEffects?: string[];
saveAttribute?: string;
saveSkill?: string;
triggers?: Array<{ event: string; effect: string }>;
components?: { symbols?: string[] };
requirements?: { race?: string[]; talent?: string[]; discipline?: string[] };
difficulty: { threshold: number; successes: number };
};
}
export interface CastOptions {
manaSpent?: number;
zealSpent?: number;
bloodSpent?: number;
hekateMode?: 'sacred' | 'witch';
bypassSuperspellWarning?: boolean;
bypassNonCombatBlock?: boolean;
groupCasters?: string[];
/** Override required successes (used by variableSuccesses summons). */
requiredOverride?: number;
speaker?: ChatMessage.SpeakerData;
}
export interface CastResult {
validation: ValidationResult;
baseRoll: HbmTSRoll | null;
damageRoll: Roll | null;
triggersRegistered: number;
}
function notify(text: string, severity: 'warn' | 'error' | 'info' = 'info'): void {
const u = ui as unknown as { notifications?: { warn: (s: string) => void; error: (s: string) => void; info: (s: string) => void } };
u.notifications?.[severity](text);
}
export async function castSpell(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): Promise<CastResult> {
// 1. Validate
const validation = validateCast(actor, spell, opts);
if (!validation.ok) {
for (const err of validation.errors) {
notify(err.i18nKey ? game.i18n.format(err.i18nKey, (err.i18nArgs ?? {}) as Record<string, string>) : err.message, 'warn');
}
return { validation, baseRoll: null, damageRoll: null, triggersRegistered: 0 };
}
for (const w of validation.warnings) {
notify(w.i18nKey ? game.i18n.format(w.i18nKey, (w.i18nArgs ?? {}) as Record<string, string>) : w.message, 'info');
}
// 2-3. Mode dispatch
const mode = spell.system.castingMode ?? 'standard';
let baseRoll: HbmTSRoll | null = null;
if (mode === 'sacred') {
baseRoll = opts.hekateMode === 'witch'
? await castWitch(actor, spell, opts)
: await castSacred(actor, spell, opts);
} else if (mode === 'witch') {
baseRoll = await castWitch(actor, spell, opts);
} else if (mode === 'blood') {
baseRoll = await castBlood(actor, spell, opts);
} else {
baseRoll = await castStandard(actor, spell, opts);
}
let damageRoll: Roll | null = null;
let triggersRegistered = 0;
if (baseRoll?.ts?.isSuccess) {
// 4. Damage
if (spell.system.damageBase) {
damageRoll = await rollSpellDamage(spell.system.damageBase, { actor, spellName: spell.name });
}
// 6. Triggers
triggersRegistered = await registerSpellTriggers(actor as any, spell);
}
// Render unified rich chat card (Phase 1.6)
await renderCastCard(actor, spell, opts, baseRoll, damageRoll, triggersRegistered);
return { validation, baseRoll, damageRoll, triggersRegistered };
}
async function renderCastCard(
actor: CastableActor,
spell: SpellLike,
opts: CastOptions,
baseRoll: HbmTSRoll | null,
damageRoll: Roll | null,
triggersRegistered: number,
): Promise<void> {
const success = baseRoll?.ts?.isSuccess ?? false;
const mode = spell.system.castingMode ?? 'standard';
// Costs ledger
const costs: Array<{ label: string; amount: number }> = [];
if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! });
if ((opts.zealSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! });
// Damage block
let damage: Record<string, unknown> | null = null;
if (success && damageRoll) {
const targets: Array<{ uuid: string; name: string }> = [];
const userTargets = (game.user as any)?.targets;
if (userTargets && typeof userTargets[Symbol.iterator] === 'function') {
for (const t of userTargets) {
if (t?.actor?.uuid) targets.push({ uuid: t.actor.uuid, name: t.actor.name ?? t.name });
}
}
damage = {
total: damageRoll.total,
formula: damageRoll.formula,
type: spell.system.damageType ?? 'magical',
ignoresArmor: !!spell.system.ignoresArmor,
targets: targets.length > 0 ? targets : null,
};
}
// Status effects (with apply buttons)
const statusEffects: Array<{ id: string; label: string }> = [];
if (success && Array.isArray(spell.system.statusEffects)) {
for (const id of spell.system.statusEffects) {
const def = CONDITIONS.find((c) => c.id === id);
const label = def ? game.i18n.localize(def.i18nKey) : id;
statusEffects.push({ id, label });
}
}
// Save line
const save = success && spell.system.saveAttribute
? { attribute: spell.system.saveAttribute, skill: spell.system.saveSkill ?? '' }
: null;
const triggers = success && triggersRegistered > 0 && Array.isArray(spell.system.triggers)
? spell.system.triggers
: [];
const data = {
spell: { name: spell.name },
mode,
school: spell.system.school ?? '',
circle: (spell.system as any).circle ?? 0,
outcome: { success },
costs,
damage,
save,
statusEffects,
triggers,
description: (spell.system as any).description ?? '',
};
const html = await renderTemplate('systems/hbm-rpg-v3/templates/chat/spell-cast.hbs', data);
await ChatMessage.create({
content: html,
speaker: opts.speaker ?? ChatMessage.getSpeaker({ actor: actor as any }),
flags: { 'hbm-rpg-v3': { spellCast: { spellName: spell.name, success } } },
});
}
async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
const a = actor.system.attributes;
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaSpent });
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = `${spell.name}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const baseRoll = HbmTSRoll.fromParams({
pool,
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
required,
flavor,
});
await baseRoll.evaluate();
await baseRoll.toMessage({ flavor, speaker: opts.speaker });
// Overcast — extra TS test if manaSpent exceeds maxPerSpell.
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
const excess = manaSpent - a.mana.maxPerSpell;
const tThreshold = Math.min(6, Math.max(2, baseCost));
const ySuccesses = Math.min(10, Math.max(1, excess));
const overcastFlavor = `${game.i18n.localize('HBM.spellCast.overcastTest')}: ${spell.name}`;
const overcastRoll = HbmTSRoll.fromParams({
pool,
threshold: tThreshold,
required: ySuccesses,
flavor: overcastFlavor,
});
await overcastRoll.evaluate();
await overcastRoll.toMessage({ flavor: overcastFlavor, speaker: opts.speaker });
}
return baseRoll;
}
async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
const a = actor.system.attributes;
const zealCost = Math.max(1, opts.zealSpent ?? 1);
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({
pool,
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
required,
flavor,
});
await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker });
return roll;
}
async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
const a = actor.system.attributes;
const required = Math.max(0, spell.system.complexityLevel ?? spell.system.components?.symbols?.length ?? 0);
const maxSymbols = Math.ceil(a.magic.actual / 2);
// Witch magic still has a mana cost.
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
await actor.update({ 'system.attributes.mana.value': a.mana.value - baseCost });
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({
pool,
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
required: reqSuccesses,
flavor,
});
await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker });
return roll;
}
async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptions): Promise<HbmTSRoll | null> {
const a = actor.system.attributes;
const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
const blood = a.blood;
if (blood) {
await actor.update({ 'system.attributes.blood.value': Math.max(0, blood.value - bloodSpent) });
}
// Mana cost (some blood spells may also have one)
const manaCost = Math.max(0, spell.system.manaCost ?? 0);
if (manaCost > 0) {
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaCost });
}
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
const flavor = `${spell.name}${game.i18n.localize('HBM.spell.castingMode.blood')}`;
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
const roll = HbmTSRoll.fromParams({
pool,
threshold: spell.system.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
required,
flavor,
});
await roll.evaluate();
await roll.toMessage({ flavor, speaker: opts.speaker });
return roll;
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Spell damage formula evaluator.
*
* Tokens recognised in `spell.damageBase`:
* magicalAbilities, devotion, soul, mind, body, magic
* magicalAbilities/2 (and similar /N or *N suffixes)
* 1d6, 2d6, 1d3 etc. (Foundry dice notation)
* numeric literals
* Operators: + - * / (integer division for /N tokens; standard for dice)
*
* Returns a Foundry Roll ready to evaluate, or null when the spell has
* no damageBase formula.
*/
import type { CastableActor } from './spell-cast';
export interface DamageContext {
actor: CastableActor;
spellName?: string;
}
const ATTR_TOKENS = new Set(['body', 'mind', 'soul', 'magic', 'magicalAbilities', 'devotion']);
function resolveToken(token: string, ctx: DamageContext): number {
const a = (ctx.actor as any).system?.attributes ?? {};
const s = (ctx.actor as any).system?.skills ?? {};
switch (token) {
case 'body': return Number(a.body?.value ?? 0);
case 'mind': return Number(a.mind?.value ?? 0);
case 'soul': return Number(a.soul?.value ?? 0);
case 'magic': return Number(a.magic?.value ?? 0);
case 'magicalAbilities': return Number(s.magicalAbilities?.value ?? 0);
case 'devotion': return Number(s.devotion?.value ?? 0);
default: return 0;
}
}
/**
* Substitutes attribute tokens with their numeric values, leaving dice
* notation intact for Foundry to parse.
*/
export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext): string | null {
if (!spellDamageBase || !spellDamageBase.trim()) return null;
let formula = spellDamageBase.trim();
// Replace attribute tokens (with optional /N or *N suffix).
// Match e.g. magicalAbilities, magicalAbilities/2, soul*3
const tokenRe = /([a-zA-Z]+)(?:\s*([\/*])\s*(\d+))?/g;
formula = formula.replace(tokenRe, (match, name: string, op: string | undefined, num: string | undefined) => {
if (!ATTR_TOKENS.has(name)) {
// Leave alone (likely dice notation like 1d6 — no, dice has digits before)
return match;
}
let v = resolveToken(name, ctx);
if (op && num) {
const n = Number(num);
if (op === '/') v = Math.ceil(v / n); // ceil per HbM rounding rules
if (op === '*') v = v * n;
}
return String(v);
});
return formula;
}
export async function rollSpellDamage(spellDamageBase: string, ctx: DamageContext): Promise<Roll | null> {
const formula = buildDamageFormula(spellDamageBase, ctx);
if (!formula) return null;
const roll = new Roll(formula, {});
await roll.evaluate();
return roll;
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Reactive spell trigger registry.
*
* Spells with `triggers[]` register flag-based listeners on the caster:
* actor.flags.hbm.triggers[] = [{ event, effect, spellId, expiresAt }]
*
* Hooks check the matching event and prompt/resolve the trigger.
*
* Supported events:
* - killWithWeapon (e.g. Szkarłatny Sztylet — free spell after kill)
* - targetCastsSpell (e.g. Klątwa Szkarłatu — opposed save → unconscious)
* - damageTaken (reactive shields)
* - turnStart (per-turn drains)
*
* Triggers are stored as world-flag data (serialisable); the runtime hook
* dispatches based on `event`. Effect strings are advisory text (GM-resolved)
* unless they map to a known machine effect ID.
*/
import { SYSTEM_ID } from '../hbm';
export interface SpellTrigger {
event: string;
effect: string;
spellId: string;
spellName: string;
/** Optional: combat round at which this trigger expires. */
expiresAtRound?: number;
/** Single-use after fire. */
oneShot?: boolean;
}
interface ActorWithFlags {
id?: string;
name?: string;
getFlag?: (scope: string, key: string) => unknown;
setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown>;
unsetFlag?: (scope: string, key: string) => Promise<unknown>;
}
function readTriggers(actor: ActorWithFlags): SpellTrigger[] {
const raw = actor.getFlag?.(SYSTEM_ID, 'triggers') as SpellTrigger[] | undefined;
return Array.isArray(raw) ? raw.slice() : [];
}
async function writeTriggers(actor: ActorWithFlags, triggers: SpellTrigger[]): Promise<void> {
if (triggers.length === 0) {
await actor.unsetFlag?.(SYSTEM_ID, 'triggers');
} else {
await actor.setFlag?.(SYSTEM_ID, 'triggers', triggers);
}
}
export async function registerSpellTriggers(
actor: ActorWithFlags,
spell: { id?: string; name?: string; system?: { triggers?: Array<{ event: string; effect: string }> } },
): Promise<number> {
const list = spell.system?.triggers ?? [];
if (!Array.isArray(list) || list.length === 0) return 0;
const existing = readTriggers(actor);
const now = (game as any).combat?.round ?? 0;
for (const t of list) {
existing.push({
event: t.event,
effect: t.effect,
spellId: spell.id ?? '',
spellName: spell.name ?? '',
expiresAtRound: now + 1, // default: end of next round; spell may override later
oneShot: true,
});
}
await writeTriggers(actor, existing);
return list.length;
}
export async function fireTriggers(
actor: ActorWithFlags,
event: string,
payload?: Record<string, unknown>,
): Promise<SpellTrigger[]> {
const triggers = readTriggers(actor);
const fired: SpellTrigger[] = [];
const remaining: SpellTrigger[] = [];
for (const t of triggers) {
if (t.event === event) {
fired.push(t);
// Post chat card so GM can resolve the effect manually if no auto-handler.
await ChatMessage.create({
content: `<strong>${actor.name ?? ''}</strong> — Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [],
});
if (!t.oneShot) remaining.push(t);
} else {
remaining.push(t);
}
}
if (fired.length > 0) await writeTriggers(actor, remaining);
return fired;
}
export function registerTriggerHooks(): void {
// Combat-end / actor death events feed triggers.
// Foundry hooks: 'updateActor' (HP delta), 'createChatMessage' (attack rolls), 'updateCombat'.
Hooks.on('updateActor', async (actor: any, change: any) => {
const newHp = change?.system?.attributes?.health?.value;
if (typeof newHp === 'number' && newHp <= 0) {
// Find any actor in combat with a killWithWeapon trigger awaiting fire
const combat = (game as any).combat;
if (!combat?.started) return;
for (const c of combat.combatants ?? []) {
const a = c.actor as ActorWithFlags;
if (!a) continue;
const triggers = readTriggers(a);
if (triggers.some((t) => t.event === 'killWithWeapon')) {
await fireTriggers(a, 'killWithWeapon', { victim: actor.id });
}
}
}
});
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Pre-cast validation gates. Runs before any resource deduction or roll.
* Returns structured ValidationResult; caller decides whether to block,
* warn, or proceed with bypass flags.
*/
import type { CastableActor, SpellLike, CastOptions } from './spell-cast';
export interface ValidationIssue {
code: string;
message: string;
i18nKey?: string;
i18nArgs?: Record<string, unknown>;
}
export interface ValidationResult {
errors: ValidationIssue[]; // hard blocks
warnings: ValidationIssue[]; // soft, may be bypassed
ok: boolean; // errors.length === 0
}
function getActorRaceId(actor: CastableActor): string {
// Prefer embedded race item; fall back to details.raceId / details.race.
const raceItem = (actor as any).items?.find?.((i: any) => i.type === 'race');
if (raceItem) {
return String(raceItem.system?.raceId ?? raceItem.system?.id ?? raceItem.name ?? '').toLowerCase();
}
const d = (actor as any).system?.details ?? {};
return String(d.raceId ?? d.race ?? '').toLowerCase();
}
function actorHasTalent(actor: CastableActor, talentId: string): boolean {
const id = talentId.toLowerCase();
const items = (actor as any).items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'talent') continue;
const t = String(it.system?.talentId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (t === id) return true;
}
return false;
}
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
const id = disciplineId.toLowerCase();
const items = (actor as any).items as Iterable<any> | undefined;
if (!items) return false;
for (const it of items) {
if (it.type !== 'discipline') continue;
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '').toLowerCase();
if (d === id) return true;
}
// Also accept characters whose details.discipline matches
const detail = String((actor as any).system?.details?.discipline ?? '').toLowerCase();
return detail === id;
}
function actorIsInCombat(actor: CastableActor): boolean {
const combat = (game as any).combat;
if (!combat?.started) return false;
return Boolean(combat.combatants?.find?.((c: any) => c.actorId === actor.id));
}
export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastOptions = {}): ValidationResult {
const errors: ValidationIssue[] = [];
const warnings: ValidationIssue[] = [];
const sys = spell.system as any;
const a = (actor as any).system?.attributes ?? {};
const mode = sys.castingMode ?? 'standard';
// Resource gates
const baseMana = Math.max(0, Number(sys.manaCost ?? 0));
const manaSpent = Math.max(baseMana, Math.floor(opts.manaSpent ?? baseMana));
const bloodSpent = Math.max(0, Math.floor(opts.bloodSpent ?? Number(sys.bloodCost ?? 0)));
if (mode === 'standard' || mode === 'witch' || (mode === 'sacred' && opts.hekateMode === 'witch')) {
if (manaSpent > Number(a.mana?.value ?? 0)) {
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
}
}
if (mode === 'sacred' && opts.hekateMode !== 'witch') {
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
}
}
if (mode === 'blood') {
if (bloodSpent > Number(a.blood?.value ?? 0)) {
errors.push({ code: 'no-blood', message: 'Not enough blood', i18nKey: 'HBM.spellCast.notEnoughBlood' });
}
}
// Race gate
const raceReq: string[] = Array.isArray(sys.requirements?.race) ? sys.requirements.race : [];
if (raceReq.length > 0) {
const actorRace = getActorRaceId(actor);
const ok = raceReq.some((r) => r.toLowerCase() === actorRace);
if (!ok) {
const translatedRaces = raceReq.map(r => {
const key = r.toLowerCase();
return game.i18n.has(`HBM.racesList.${key}`) ? game.i18n.localize(`HBM.racesList.${key}`) : r;
}).join(', ');
errors.push({
code: 'race-locked', message: `Spell requires race: ${raceReq.join(', ')}`,
i18nKey: 'HBM.spellCast.raceLocked', i18nArgs: { races: translatedRaces },
});
}
}
// Talent gate
const talentReq: string[] = Array.isArray(sys.requirements?.talent) ? sys.requirements.talent : [];
for (const t of talentReq) {
if (!actorHasTalent(actor, t)) {
errors.push({
code: 'talent-missing', message: `Missing required talent: ${t}`,
i18nKey: 'HBM.spellCast.talentMissing', i18nArgs: { talent: t },
});
}
}
// Discipline gate (any-of)
const discReq: string[] = Array.isArray(sys.requirements?.discipline) ? sys.requirements.discipline : [];
if (discReq.length > 0) {
const ok = discReq.some((d) => actorHasDiscipline(actor, d));
if (!ok) {
const translatedDisciplines = discReq.map(d => {
return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d;
}).join(', ');
errors.push({
code: 'discipline-missing', message: `Requires one of disciplines: ${discReq.join(', ')}`,
i18nKey: 'HBM.spellCast.disciplineMissing', i18nArgs: { disciplines: translatedDisciplines },
});
}
}
// Deity gate (sacred + non-common)
const deity = String(sys.deity ?? '').trim();
if ((sys.school === 'sacred' || mode === 'sacred') && deity && deity !== 'common') {
const actorDeity = String((actor as any).system?.details?.deity ?? '').trim();
if (actorDeity && actorDeity !== deity) {
warnings.push({
code: 'deity-mismatch',
message: `Spell deity (${deity}) differs from devoted deity (${actorDeity})`,
i18nKey: 'HBM.spellCast.deityMismatch', i18nArgs: { spell: deity, actor: actorDeity },
});
}
}
// Group cast
if (sys.requiresGroupCast) {
const min = Math.max(1, Number(sys.minCasters ?? 1));
const provided = (opts.groupCasters?.length ?? 0) + 1;
if (provided < min) {
errors.push({
code: 'group-cast', message: `Requires ${min} co-casters; have ${provided}`,
i18nKey: 'HBM.spellCast.groupCastRequired', i18nArgs: { min, provided },
});
}
}
// Witch symbol cap
if (mode === 'witch' || opts.hekateMode === 'witch') {
const required = Math.max(0, Number(sys.complexityLevel ?? sys.components?.symbols?.length ?? 0));
const maxSymbols = Math.ceil(Number(a.magic?.value ?? 0) / 2);
if (required > maxSymbols) {
errors.push({
code: 'witch-symbols', message: `Symbols ${required}/${maxSymbols}`,
i18nKey: 'HBM.spellCast.witchSymbols', i18nArgs: { used: required, max: maxSymbols },
});
}
}
// Non-combat-only spells
if (sys.nonCombatOnly && actorIsInCombat(actor) && !opts.bypassNonCombatBlock) {
errors.push({
code: 'non-combat', message: 'Spell cannot be cast in combat',
i18nKey: 'HBM.spellCast.nonCombatOnly',
});
}
// Superspell warning (non-blocking)
if (sys.isSuperspell && actorIsInCombat(actor) && !opts.bypassSuperspellWarning) {
warnings.push({
code: 'superspell-combat',
message: 'Casting superspell during combat is generally inadvisable',
i18nKey: 'HBM.spellCast.superspellInCombat',
});
}
return { errors, warnings, ok: errors.length === 0 };
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Trade logic — founding companies, transactions, smuggling.
* Source: Złoto Stal i Magia, Rozdział IX.
*
* The book's exact difficulty formulas depend on commodity, route, and party
* skill, so this module exposes the *primitives*; the cast dialog/UI passes
* the threshold and required-successes derived from the table.
*
* State: a "company" is just a JournalEntry created in a configurable folder;
* here we only expose the rolling helpers.
*/
import { HbmTSRoll } from '../dice/ts-roll';
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
import type { CastableActor } from './spell-cast';
export interface TradeTestParams {
/** TS threshold, default 5. */
threshold?: number;
/** Required successes, default 2. */
required?: number;
/** Skill key contributing to the pool (e.g. `tradeAndPersuasion`). */
skill?: string;
/** Attribute key whose `value` adds to the pool (default `mind`). */
attribute?: 'body' | 'mind' | 'soul' | 'magic';
/** Free-form description for the chat card. */
flavor?: string;
}
function rollTrade(actor: CastableActor, label: string, params: TradeTestParams): Promise<HbmTSRoll> {
const attribute = params.attribute ?? 'mind';
const attrVal = (actor.system.attributes as any)[attribute]?.value ?? 0;
const skillVal = params.skill ? (actor.system.skills?.[params.skill]?.value ?? 0) : 0;
const pool = attrVal + skillVal;
const flavor = params.flavor ?? `${label}: ${actor.name}`;
const roll = HbmTSRoll.fromParams({
pool,
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
required: params.required ?? TS_DEFAULT_REQUIRED,
flavor,
});
return roll.evaluate().then(async () => {
await roll.toMessage({ flavor });
return roll;
});
}
/** Founding a trading company — usually a single TS test plus capital. */
export function foundCompany(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
return rollTrade(actor, 'Założenie Firmy Handlowej', params);
}
/** Standard legal transaction. */
export function transaction(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
return rollTrade(actor, 'Transakcja', params);
}
/** Smuggling — illegal transaction; failure should fire `hbm.smugglingFailed`. */
export async function smuggling(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
const roll = await rollTrade(actor, 'Przemyt', params);
if (!roll.ts?.isSuccess) {
Hooks.callAll('hbm.smugglingFailed', actor, roll);
}
return roll;
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Migration to v0.4.0 SpellData schema (numbered 0.2.0 per planning doc).
*
* Operations:
* - For each Item of type 'spell' (in world & embedded on actors):
* • If `components.symbols` is empty AND `complexityLevel > 0`,
* leave symbols empty (cannot infer names) but log so user can fix.
* • If `targets` matches a recognised AoE pattern, populate `areaOfEffect`.
* • If legacy `overcasting` text is non-empty AND `overcastOptions` is empty,
* seed `overcastOptions[0] = { description: <text>, manaPerStep: 1 }`.
* - Add `attributes.blood = { value: 0, max: 0 }` to characters missing it
* (DataModel default usually handles this; explicit safety net).
*/
import { SYSTEM_ID } from '../hbm';
import type { MigrationStep } from './index';
interface LegacySpellSystem {
targets?: string;
overcasting?: string;
overcastOptions?: Array<{ description: string; manaPerStep: number }>;
areaOfEffect?: { shape: string; x: number; y: number; unit: string };
components?: { symbols?: string[] };
complexityLevel?: number;
}
const AOE_PATTERNS: Array<{ re: RegExp; build: (m: RegExpMatchArray) => { shape: string; x: number; y: number; unit: string } }> = [
// "3 × 8 m" / "3x8 m" → rectangle
{ re: /(\d+)\s*[×x]\s*(\d+)\s*m/i, build: (m) => ({ shape: 'rectangle', x: Number(m[1]), y: Number(m[2]), unit: 'm' }) },
// "promień 5 m" → sphere
{ re: /promień\s+(\d+)\s*m/i, build: (m) => ({ shape: 'sphere', x: Number(m[1]), y: 0, unit: 'm' }) },
// "stożek 10 m" → cone
{ re: /stoż\w+\s+(\d+)\s*m/i, build: (m) => ({ shape: 'cone', x: Number(m[1]), y: 0, unit: 'm' }) },
// "linia 15 m" → line
{ re: /linia\s+(\d+)\s*m/i, build: (m) => ({ shape: 'line', x: Number(m[1]), y: 0, unit: 'm' }) },
];
function migrateSpellDoc(doc: any): Record<string, unknown> | null {
const sys = (doc.system ?? {}) as LegacySpellSystem;
const updates: Record<string, unknown> = {};
let dirty = false;
// AoE inference
const isPoint = !sys.areaOfEffect || (sys.areaOfEffect.shape === 'point' && !sys.areaOfEffect.x);
if (isPoint && sys.targets) {
for (const { re, build } of AOE_PATTERNS) {
const m = sys.targets.match(re);
if (m) {
updates['system.areaOfEffect'] = build(m);
dirty = true;
break;
}
}
}
// Overcast text → structured option
const noStructuredOvercast = !sys.overcastOptions || sys.overcastOptions.length === 0;
if (sys.overcasting && noStructuredOvercast) {
updates['system.overcastOptions'] = [{ description: sys.overcasting, manaPerStep: 1 }];
dirty = true;
}
// Witch symbols audit log
if ((sys.complexityLevel ?? 0) > 0 && (!sys.components?.symbols || sys.components.symbols.length === 0)) {
console.warn(`${SYSTEM_ID} | Spell "${doc.name}" has complexityLevel=${sys.complexityLevel} but no symbols list; please populate components.symbols manually.`);
}
return dirty ? updates : null;
}
async function migrateAllSpells(): Promise<void> {
// World items
const worldItems = (game.items?.contents ?? []) as any[];
for (const item of worldItems) {
if (item.type !== 'spell') continue;
const updates = migrateSpellDoc(item);
if (updates) await item.update(updates);
}
// Actor-embedded items
const actors = (game.actors?.contents ?? []) as any[];
for (const actor of actors) {
const items = (actor.items?.contents ?? []) as any[];
for (const item of items) {
if (item.type !== 'spell') continue;
const updates = migrateSpellDoc(item);
if (updates) await item.update(updates);
}
}
}
async function ensureBloodPool(): Promise<void> {
const actors = (game.actors?.contents ?? []) as any[];
for (const actor of actors) {
if (actor.type !== 'character') continue;
const blood = actor.system?.attributes?.blood;
if (!blood || typeof blood.max !== 'number') {
await actor.update({ 'system.attributes.blood': { value: 0, max: 0 } });
}
}
}
export const migration_0_2_0: MigrationStep = {
version: '0.4.0',
description: 'Expand SpellData schema, add Blood Pool to characters',
run: async () => {
await migrateAllSpells();
await ensureBloodPool();
},
};
+98
View File
@@ -0,0 +1,98 @@
/**
* Migration to v1.2.0.
*
* Operations:
* - Set default `flags['hbm-rpg-v3'].zealRegenBonus = 0` on all character actors
* that don't have it (so the combat-turn helper can always read a number).
* - Backfill NPC `attributes.{mana, zeal, blood}` for any NPC actors stored
* pre-v1.1.2 (the schema defaults handle it on read, but persist on save).
* - Best-effort: scan world & embedded talents for descriptions matching
* "+1 Zapał" / "regeneracja zapału" and attach a transferable ActiveEffect
* that adds `flags.hbm-rpg-v3.zealRegenBonus = 1` (only if no AE present).
*/
import type { MigrationStep } from './index';
const ZEAL_REGEN_PATTERNS = [
/\+\s*1\s*zapa[łl]/i,
/regeneracj\w+\s+zapa[łl]u/i,
/odzyskuje\s+\+?1\s+zapa[łl]/i,
];
function shouldAttachZealRegenAE(item: any): boolean {
if (item?.type !== 'talent') return false;
const text = `${item.system?.description ?? ''} ${item.system?.effect ?? ''}`;
if (!text.trim()) return false;
if (!ZEAL_REGEN_PATTERNS.some((re) => re.test(text))) return false;
// Skip if this talent already has an AE targeting the flag.
for (const ef of item.effects ?? []) {
for (const ch of ef.changes ?? []) {
if (ch.key === 'flags.hbm-rpg-v3.zealRegenBonus') return false;
}
}
return true;
}
async function attachZealRegenAE(item: any): Promise<void> {
await item.createEmbeddedDocuments('ActiveEffect', [{
name: '+1 regeneracja Zapału',
icon: 'icons/svg/lightning.svg',
transfer: true,
disabled: false,
changes: [{
key: 'flags.hbm-rpg-v3.zealRegenBonus',
value: '1',
mode: 2, // CONST.ACTIVE_EFFECT_MODES.ADD
priority: 20,
}],
}]);
}
async function migrateActorFlags(): Promise<void> {
const actors = (game.actors?.contents ?? []) as any[];
for (const a of actors) {
if (a.type !== 'character') continue;
const cur = a.getFlag?.('hbm-rpg-v3', 'zealRegenBonus');
if (cur == null) {
await a.setFlag('hbm-rpg-v3', 'zealRegenBonus', 0);
}
}
}
async function migrateTalents(): Promise<void> {
// World talents
const worldItems = (game.items?.contents ?? []) as any[];
for (const it of worldItems) {
if (shouldAttachZealRegenAE(it)) await attachZealRegenAE(it);
}
// Actor-embedded talents
const actors = (game.actors?.contents ?? []) as any[];
for (const a of actors) {
for (const it of a.items ?? []) {
if (shouldAttachZealRegenAE(it)) await attachZealRegenAE(it);
}
}
}
async function backfillNpcResources(): Promise<void> {
const actors = (game.actors?.contents ?? []) as any[];
for (const a of actors) {
if (a.type !== 'npc') continue;
const sys = a.system as any;
const update: Record<string, unknown> = {};
if (sys.attributes?.mana == null) update['system.attributes.mana'] = { value: 0, max: 0, maxPerSpell: 0 };
if (sys.attributes?.zeal == null) update['system.attributes.zeal'] = { value: 0, max: 0 };
if (sys.attributes?.blood == null) update['system.attributes.blood'] = { value: 0, max: 0 };
if (Object.keys(update).length > 0) await a.update(update);
}
}
export const migration_1_2_0: MigrationStep = {
version: '1.2.0',
description: 'v1.2.0: zealRegenBonus flag, NPC resource backfill, ActiveEffect attachment for +1 Zeal talents',
async run() {
await migrateActorFlags();
await backfillNpcResources();
await migrateTalents();
},
};
+71
View File
@@ -0,0 +1,71 @@
/**
* World data migration runner. Compares stored `flags.hbm.lastMigratedVersion`
* against the system version and applies any pending migrations in order.
*
* Each migration module exports `run(): Promise<void>` and a numeric `targetVersion`
* (semver-style string). Migrations are idempotent.
*/
import { SYSTEM_ID } from '../hbm';
import { migration_0_2_0 } from './0.2.0';
import { migration_1_2_0 } from './1.2.0';
export interface MigrationStep {
version: string; // version this migration brings the world TO
description: string;
run: () => Promise<void>;
}
const MIGRATIONS: MigrationStep[] = [
migration_0_2_0,
migration_1_2_0,
];
function compareSemver(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const da = pa[i] ?? 0;
const db = pb[i] ?? 0;
if (da !== db) return da - db;
}
return 0;
}
export async function runPendingMigrations(): Promise<void> {
if (!game.user?.isGM) return;
const sys = game.system as unknown as { version: string };
const currentVersion = sys.version ?? '0.0.0';
const last = (game.settings.get(SYSTEM_ID, 'lastMigratedVersion') as string | undefined) ?? '0.0.0';
if (compareSemver(last, currentVersion) >= 0) {
return;
}
console.log(`${SYSTEM_ID} | Running migrations: ${last}${currentVersion}`);
for (const step of MIGRATIONS) {
if (compareSemver(last, step.version) < 0 && compareSemver(step.version, currentVersion) <= 0) {
console.log(`${SYSTEM_ID} | Migration ${step.version}: ${step.description}`);
try {
await step.run();
} catch (err) {
console.error(`${SYSTEM_ID} | Migration ${step.version} failed`, err);
ui.notifications?.error(`HbM migration ${step.version} failed — see console.`);
return;
}
}
}
await game.settings.set(SYSTEM_ID, 'lastMigratedVersion', currentVersion);
ui.notifications?.info(`HbM RPG v3 migrated to ${currentVersion}.`);
}
export function registerMigrationSettings(): void {
game.settings.register(SYSTEM_ID, 'lastMigratedVersion', {
name: 'Last migrated system version',
scope: 'world',
config: false,
type: String,
default: '0.0.0',
});
}
+573
View File
@@ -0,0 +1,573 @@
/**
* Character sheet — Foundry v13 ApplicationV2 + Handlebars.
* Features: tabs, roll-dialogs, drag-and-drop item creation.
*/
import { rollSkill, rollAttribute, rollInitiative } from '../dice/macros';
import { castSpell } from '../logic/spell-cast';
import { askRollParams } from '../dice/roll-dialog';
import { askCastOptions } from '../dice/cast-dialog';
import { askApplyDamage } from '../dice/damage-dialog';
import { applyDamage } from '../logic/damage';
import { rest } from '../logic/rest';
import { ATTRIBUTES, AttributeKey, SKILL_KEYS } from '../constants';
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
};
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
};
export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
static override DEFAULT_OPTIONS = {
classes: ['hbm', 'sheet', 'character'],
position: { width: 760, height: 720 },
window: { resizable: true, title: 'HBM.actor.character' },
actions: {
rollSkill: CharacterSheet._onRollSkill,
rollAttribute: CharacterSheet._onRollAttribute,
rollInitiative: CharacterSheet._onRollInitiative,
castSpell: CharacterSheet._onCastSpell,
deleteItem: CharacterSheet._onDeleteItem,
editItem: CharacterSheet._onEditItem,
applyDamage: CharacterSheet._onApplyDamage,
takeBreather: CharacterSheet._onTakeBreather,
shortRest: CharacterSheet._onShortRest,
longRest: CharacterSheet._onLongRest,
actorEffectCreate: CharacterSheet._onActorEffectCreate,
actorEffectToggle: CharacterSheet._onActorEffectToggle,
actorEffectEdit: CharacterSheet._onActorEffectEdit,
actorEffectDelete: CharacterSheet._onActorEffectDelete,
addArrayEntry: CharacterSheet._onAddArrayEntry,
removeArrayEntry: CharacterSheet._onRemoveArrayEntry,
editImage: CharacterSheet._onEditImage,
toggleEquipped: CharacterSheet._onToggleEquipped,
recalculateMoney: CharacterSheet._onRecalculateMoney,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static override PARTS = {
main: { template: 'systems/hbm-rpg-v3/templates/actor/character.hbs' },
};
/** Active tab per group — persists across re-renders. */
tabGroups: Record<string, string> = { primary: 'stats' };
override async _prepareContext(options: unknown) {
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
const actor = (this as unknown as { actor: { system: unknown; items: any[] } }).actor;
const spells = actor.items.filter((it: any) => it.type === 'spell');
const gear = actor.items.filter((it: any) => it.type === 'gear');
const abilities = actor.items.filter((it: any) => it.type === 'ability');
const talents = actor.items.filter((it: any) => it.type === 'talent');
// Group spells by school for sheet display
const spellsBySchool = new Map<string, any[]>();
for (const sp of spells) {
const s = sp.system?.school || 'standard';
if (!spellsBySchool.has(s)) spellsBySchool.set(s, []);
spellsBySchool.get(s)!.push(sp);
}
const spellSchoolGroups = [...spellsBySchool.entries()]
.map(([school, items]) => ({
school,
label: game.i18n.localize(`HBM.spellSchool.${school}`),
items: items.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0)),
}))
.sort((a, b) => a.label.localeCompare(b.label, 'pl'));
const sys = actor.system as any;
const hasBloodMagic = sys?.details?.discipline === 'Magia Krwi' || sys?.details?.discipline === 'Blood Magic';
const elixirCap = (sys?.attributes?.body?.value ?? 0) + 1;
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
id: ef.id,
name: ef.name,
icon: ef.icon ?? 'icons/svg/aura.svg',
disabled: ef.disabled ?? false,
isTransferred: !!ef.origin,
originName: ef.origin ? (actor.items.find((it: any) => ef.origin?.endsWith(it.id))?.name ?? ef.origin) : '',
changes: ef.changes ?? [],
}));
// Pre-map attributes and skills to prevent lookup context issues in Handlebars
const attributes = ATTRIBUTES.map(key => {
const attrObj = sys.attributes?.[key];
const actualVal = attrObj?.actual !== undefined ? attrObj.actual : (attrObj?.value ?? 0);
const labels = ['0', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
const actualLabel = labels[Math.max(0, Math.min(10, actualVal))] || String(actualVal);
return {
key,
value: attrObj?.value ?? 0,
actual: actualVal,
actualLabel,
label: game.i18n.localize(`HBM.attributes.${key}`)
};
});
const skills = SKILL_KEYS.map(key => ({
key,
value: sys.skills?.[key]?.value ?? 0,
label: game.i18n.localize(`HBM.skills.${key}`)
}));
const races = ['czlowiek', 'elf', 'lamia', 'feles', 'aniol'].map(key => ({
key,
label: game.i18n.localize(`HBM.racesList.${key}`)
}));
const disciplines = [
'alchemyTransmutation', 'alchemyBrewing', 'botany',
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
'artifice', 'golemancy', 'runes', 'manaSourceMage',
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood'
].map(key => ({
key,
label: game.i18n.localize(`HBM.spellSchool.${key}`)
}));
// Filter gear categories for tabular sheet display
const weapons = gear.filter((it: any) => it.system?.category === 'weapon');
const armors = gear.filter((it: any) => it.system?.category === 'armor');
const equipment = gear.filter((it: any) => it.system?.category === 'equipment');
// Localized talents list no longer needed (dropdown removed — drag from compendium)
return {
...ctx,
system: actor.system,
attributes,
skills,
races,
disciplines,
tabGroups: this.tabGroups,
attributeKeys: ATTRIBUTES,
skillKeys: SKILL_KEYS,
spells,
spellSchoolGroups,
hasBloodMagic,
elixirCap,
gear,
weapons,
armors,
equipment,
abilities,
talents,
actorEffects,
tabs: this._prepareTabs(),
};
}
/** Build tab metadata for the template. */
private _prepareTabs() {
const active = this.tabGroups['primary'] ?? 'stats';
return ['stats', 'actions', 'skills', 'spells', 'talents', 'inventory', 'effects', 'biography'].map((id) => ({
id,
label: `HBM.ui.tabs.${id}`,
active: active === id,
cssClass: active === id ? 'active' : '',
}));
}
override _onRender(context: unknown, options: unknown) {
super._onRender(context, options);
const html = this.element;
// Tab navigation
html.querySelectorAll<HTMLElement>('.tabs .tab-item').forEach((btn) => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
if (!tab) return;
this.tabGroups['primary'] = tab;
// Update active state without full re-render
html.querySelectorAll('.tabs .tab-item').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
html.querySelectorAll<HTMLElement>('.tab-panel').forEach((p) => {
p.classList.toggle('active', p.dataset.tab === tab);
});
});
});
// Drag-and-drop: accept items dragged from compendium / sidebar
const body = html.querySelector<HTMLElement>('.sheet-body');
if (body) {
body.addEventListener('dragover', (ev) => { ev.preventDefault(); });
body.addEventListener('drop', (ev) => this._handleDrop(ev));
}
}
private async _handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation(); // prevent ActorSheetV2 base from also handling this
const data = (foundry.applications as any).ux.TextEditor.implementation.getDragEventData(event);
if (!data || data['type'] !== 'Item') return;
const item = await fromUuid<any>(data['uuid'] as string);
if (!item) return;
const actor = (this as unknown as { actor: any }).actor;
const uuid = data['uuid'] as string;
// Auto-link Race / Class items by UUID — do NOT create embedded copies.
if (item.type === 'race') {
await actor.update({ 'system.details.raceId': uuid });
return;
} else if (item.type === 'class') {
const year = Math.max(1, Math.min(4, Number(item.system?.year ?? 1)));
const ids = [...((actor.system as any).details?.classIds ?? [])];
ids[year - 1] = uuid;
await actor.update({ 'system.details.classIds': ids });
return;
}
// Talents may always be added multiple times (each rank/copy is a distinct embedded item).
// All other types: deduplicate by source slug or by matching the exact same document id.
if (item.type !== 'talent') {
const sourceSlug: string | undefined = item.flags?.['hbm-rpg-v3']?.slug;
const existing = actor.items.find((it: any) => {
if (it.id === item.id) return true; // same document
if (sourceSlug && it.flags?.['hbm-rpg-v3']?.slug === sourceSlug) return true;
return false;
});
if (existing) {
ui?.notifications?.warn(game.i18n.format('HBM.ui.itemAlreadyOwned', { name: item.name }));
return;
}
}
if (item.type === 'talent' && item.system?.multiSelect) {
const match = item.name.match(/(Dziedzina|B\u00f3stwo|Zmys\u0142|Atrybut|Umiej\u0119tno\u015b\u0107)/i);
if (match) {
const paramType = match[1];
const title = game.i18n.localize('HBM.ui.selectTalentParamTitle') || 'Wybierz parametr talentu';
const labelText = game.i18n.format('HBM.ui.selectTalentParamDesc', { param: paramType }) || `Wprowad\u017a warto\u015b\u0107 dla parametru (${paramType}):`;
let specified: string | null = null;
await (foundry.applications.api as any).DialogV2.wait({
title,
content: `
<div class="hbm-talent-dialog" style="padding:10px;">
<p>${labelText}</p>
<input id="hbm-talent-param-input" type="text" placeholder="${paramType}" style="width:100%; margin-bottom:10px;" />
</div>`,
buttons: [
{
type: 'button', action: 'confirm',
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
default: true,
callback: (_ev: Event, _btn: any, html: HTMLElement) => {
const val = (html.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
specified = val || null;
},
},
{ type: 'button', action: 'cancel', label: game.i18n.localize('HBM.ui.cancel') || 'Anuluj' },
],
rejectClose: false,
});
if (specified) {
const obj = item.toObject();
obj.name = item.name.replace(/\([^)]+\)$/, `(${specified})`);
await actor.createEmbeddedDocuments('Item', [obj]);
return;
} else {
return; // cancel
}
}
}
await actor.createEmbeddedDocuments('Item', [item.toObject()]);
}
// ─── Actions ─────────────────────────────────────────────────────────────
static async _onRollSkill(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const skillKey = target.dataset.skill;
if (!skillKey) return;
const actor = (this as unknown as { actor: any }).actor;
// Determine default pool so the dialog can show it
const skill = actor.system.skills?.[skillKey];
const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey;
const pool = (actor.system.attributes[attrKey]?.value ?? 0) + (skill?.value ?? 0);
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollSkill', { skill: game.i18n.localize(`HBM.skills.${skillKey}`) }) });
if (!params) return;
await rollSkill(actor, skillKey, {
threshold: params.threshold,
required: params.required,
flavor: params.flavor,
});
}
static async _onRollAttribute(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const attr = target.dataset.attribute as AttributeKey | undefined;
if (!attr) return;
const actor = (this as unknown as { actor: any }).actor;
const pool = actor.system.attributes[attr]?.value ?? 0;
const params = await askRollParams({ pool, flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }) });
if (!params) return;
await rollAttribute(actor, attr, {
threshold: params.threshold,
required: params.required,
});
}
static async _onCastSpell(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const spell = actor.items.get(itemId);
if (!spell || spell.type !== 'spell') return;
const opts = await askCastOptions(spell, actor);
if (!opts) return;
opts.speaker = ChatMessage.getSpeaker({ actor });
await castSpell(actor, spell, opts);
}
static async _onDeleteItem(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
await item.delete();
}
static async _onEditItem(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
item.sheet?.render(true);
}
static async _onRollInitiative(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
await rollInitiative(actor);
}
static async _onApplyDamage(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const params = await askApplyDamage(actor);
if (!params) return;
await applyDamage(actor, params);
}
static async _onTakeBreather(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'breather');
await ChatMessage.create({
content: `<strong>${actor.name}</strong> — Chwila Wytchnienia: +${r.hpRestored} ŻYW · +${r.manaRestored} MN`,
speaker: ChatMessage.getSpeaker({ actor }),
});
}
static async _onShortRest(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'short');
await ChatMessage.create({
content: `<strong>${actor.name}</strong> — Krótki Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN${r.toleranceRecovered ? `, ${r.toleranceRecovered} tolerancja` : ''}`,
speaker: ChatMessage.getSpeaker({ actor }),
});
}
static async _onLongRest(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const r = await rest(actor, 'long');
await ChatMessage.create({
content: `<strong>${actor.name}</strong> — Długi Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN · +${r.zealRestored} ZP · +${r.bloodRestored} krew · ${r.toleranceRecovered} tolerancja`,
speaker: ChatMessage.getSpeaker({ actor }),
});
}
static async _onActorEffectCreate(this: CharacterSheet) {
const actor = (this as unknown as { actor: any }).actor;
const created = await actor.createEmbeddedDocuments('ActiveEffect', [{
name: game.i18n.localize('HBM.activeEffect.newEffect'),
icon: 'icons/svg/aura.svg',
disabled: false,
changes: [],
}]);
created[0]?.sheet?.render(true);
}
static async _onActorEffectToggle(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
const effect = actor.effects.get(id);
if (!effect) return;
await effect.update({ disabled: !effect.disabled });
}
static async _onActorEffectEdit(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
actor.effects.get(id)?.sheet?.render(true);
}
static async _onActorEffectDelete(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
await actor.deleteEmbeddedDocuments('ActiveEffect', [id]);
}
static async _onAddArrayEntry(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
if (!path) return;
const actor = (this as unknown as { actor: any }).actor;
const current = (foundry.utils.getProperty(actor, path) ?? []) as unknown[];
const template = target.dataset.template;
const entry = template ? JSON.parse(template) : '';
await actor.update({ [path]: [...current, entry] });
}
static async _onRemoveArrayEntry(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
const idx = Number(target.dataset.index);
if (!path || Number.isNaN(idx)) return;
const actor = (this as unknown as { actor: any }).actor;
const current = ([...(foundry.utils.getProperty(actor, path) ?? [])] as unknown[]);
current.splice(idx, 1);
await actor.update({ [path]: current });
}
static async _onEditImage(this: CharacterSheet, event: PointerEvent, target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const current = actor.img;
const fp = new FilePicker({
type: "image",
current: current,
callback: (path: string) => {
actor.update({ img: path });
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
static async _onToggleEquipped(this: CharacterSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
await item.update({ 'system.equipped': !item.system.equipped });
}
static async _onRecalculateMoney(this: CharacterSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const pln = actor.system.details?.money ?? 0;
const currentYear = actor.system.details?.currentYear ?? 2026;
const currentRealYear = new Date().getFullYear();
const fetchNBP = async (currency: string, year: number): Promise<{ rate: number; date: string } | null> => {
if (year >= 2002 && year <= currentRealYear) {
for (let day = 1; day <= 7; day++) {
const d = `${year}-06-${String(day).padStart(2, '0')}`;
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/${d}/?format=json`);
if (res.ok) { const data = await res.json(); const r = data?.rates?.[0]?.mid; if (typeof r === 'number') return { rate: r, date: d }; }
} catch (_) { /* ignore */ }
}
}
try {
const res = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
if (res.ok) { const data = await res.json(); const r = data?.rates?.[0]?.mid; const dt = data?.rates?.[0]?.effectiveDate || ''; if (typeof r === 'number') return { rate: r, date: dt }; }
} catch (_) { /* ignore */ }
return null;
};
const [eurData, usdData] = await Promise.all([fetchNBP('eur', currentYear), fetchNBP('usd', currentYear)]);
const eurRate = eurData?.rate ?? 4.35;
const usdRate = usdData?.rate ?? 4.00;
const rateDate = eurData?.date ?? usdData?.date ?? 'default';
const isLive = !!(eurData || usdData);
const CURRENCIES: Record<string, { label: string; toPln: number; note: string }> = {
PLN: { label: 'PLN (Złoty)', toPln: 1, note: '' },
EUR: { label: 'EUR (Euro)', toPln: eurRate, note: `NBP ${rateDate}` },
USD: { label: 'USD (Dolar)', toPln: usdRate, note: `NBP ${rateDate}` },
TH: { label: 'TH (Thrakka)', toPln: eurRate, note: '1:1 z EUR' },
FC: { label: 'FC (Kredyt Federacji Sol-3)', toPln: 2.0, note: 'stały kurs 2 PLN = 1 FC' },
ST: { label: 'ST (Srebrny Talent)', toPln: 240, note: '1 ST = 240 PLN' },
ZK: { label: 'ZK (Złota Korona)', toPln: 2880, note: '1 ZK = 12 ST' },
PL: { label: 'PL (Platynowy Lingot)', toPln: 34560, note: '1 PL = 12 ZK' },
};
const currKeys = Object.keys(CURRENCIES);
const optHtml = (sel: string) => currKeys.map(k =>
`<option value="${k}"${k === sel ? ' selected' : ''}>${CURRENCIES[k].label}</option>`
).join('');
const ratesRows = currKeys.map(k => {
const c = CURRENCIES[k];
const fromPln = k === 'PLN' ? '1.0000' : (1 / c.toPln).toPrecision(4);
return `<tr><td>${c.label}</td><td style="text-align:right;font-family:monospace;">${c.toPln === 1 ? '1.0000' : c.toPln.toFixed(4)}</td><td style="text-align:right;font-family:monospace;">${fromPln}</td><td style="color:#777;font-size:0.75rem;">${c.note}</td></tr>`;
}).join('');
const ratesJson = JSON.stringify(Object.fromEntries(currKeys.map(k => [k, CURRENCIES[k].toPln])));
const content = `
<div class="hbm money-converter" style="padding:10px;font-family:'Signika',sans-serif;display:flex;flex-direction:column;gap:12px;">
<p style="margin:0;font-size:0.78rem;color:${isLive ? '#2a7a3e' : '#888'};">
<em>📡 ${isLive ? `Kursy z NBP (${rateDate})` : 'Domyślne kursy (brak połączenia z NBP)'}</em>
</p>
<div style="background:var(--hbm-card-bg,#1a1a2e);border:1px solid var(--hbm-border,#333);border-radius:6px;padding:8px 12px;">
<span style="font-weight:bold;">Gotówka aktora:</span>
<span style="font-family:monospace;font-size:1.1rem;margin-left:6px;">${pln} PLN</span>
<small style="color:#888;margin-left:6px;">(Rok kampanii: ${currentYear})</small>
</div>
<div style="background:var(--hbm-card-bg,#1a1a2e);border:1px solid var(--hbm-accent,#6060c0);border-radius:6px;padding:10px 12px;display:flex;flex-direction:column;gap:8px;">
<strong style="font-size:0.9rem;">Kalkulator</strong>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<input id="hbm-conv-left" type="number" value="1" min="0" step="any"
style="width:6.5rem;font-size:1rem;text-align:right;padding:3px 6px;background:transparent;color:var(--hbm-fg,#eee);border:1px solid var(--hbm-border,#555);border-radius:4px;"
oninput="(function(v){var r=window._hbmRates;var lk=document.getElementById('hbm-sf').value;var rk=document.getElementById('hbm-st').value;var res=v*r[lk]/r[rk];document.getElementById('hbm-conv-right').value=isFinite(res)?res.toFixed(4):'';})(this.value)" />
<select id="hbm-sf" style="flex:1;min-width:8rem;"
onchange="(function(){var v=parseFloat(document.getElementById('hbm-conv-left').value)||0;var r=window._hbmRates;var lk=this.value;var rk=document.getElementById('hbm-st').value;var res=v*r[lk]/r[rk];document.getElementById('hbm-conv-right').value=isFinite(res)?res.toFixed(4):'';}).call(this)">
${optHtml('PLN')}
</select>
<span style="font-size:1.2rem;color:var(--hbm-accent,#8080ff);">⇄</span>
<input id="hbm-conv-right" type="number" value="" min="0" step="any"
style="width:6.5rem;font-size:1rem;text-align:right;padding:3px 6px;background:transparent;color:var(--hbm-fg,#eee);border:1px solid var(--hbm-border,#555);border-radius:4px;"
oninput="(function(v){var r=window._hbmRates;var lk=document.getElementById('hbm-sf').value;var rk=document.getElementById('hbm-st').value;var res=v*r[rk]/r[lk];document.getElementById('hbm-conv-left').value=isFinite(res)?res.toFixed(4):'';}).call(this)" />
<select id="hbm-st" style="flex:1;min-width:8rem;"
onchange="(function(){var v=parseFloat(document.getElementById('hbm-conv-right').value)||0;var r=window._hbmRates;var lk=document.getElementById('hbm-sf').value;var rk=this.value;var res=v*r[rk]/r[lk];document.getElementById('hbm-conv-left').value=isFinite(res)?res.toFixed(4):'';}).call(this)">
${optHtml('EUR')}
</select>
</div>
</div>
<details>
<summary style="cursor:pointer;font-size:0.85rem;color:var(--hbm-accent-dim,#999);">▸ Tabela kursów wymiany</summary>
<table style="width:100%;border-collapse:collapse;font-size:0.8rem;margin-top:8px;">
<thead><tr style="border-bottom:1px solid var(--hbm-border,#555);">
<th style="text-align:left;">Waluta</th><th style="text-align:right;">Kurs (PLN)</th><th style="text-align:right;">1 PLN =</th><th>Uwaga</th>
</tr></thead>
<tbody>${ratesRows}</tbody>
</table>
</details>
</div>
<script>window._hbmRates=${ratesJson};</script>`;
await (foundry.applications.api as any).DialogV2.inform({
title: game.i18n.localize('HBM.ui.moneyConverterTitle'),
content,
rejectClose: false,
});
}
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Generic Item sheet that delegates the body partial based on the item type.
*/
import {
SPELL_SCHOOLS,
SACRED_DEITIES,
SOURCE_BOOKS,
AOE_SHAPES,
SPELL_DAMAGE_TYPES,
TRIGGER_EVENTS,
} from '../constants';
const { ItemSheetV2 } = foundry.applications.sheets as unknown as {
ItemSheetV2: typeof foundry.applications.sheets.ItemSheetV2;
};
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
};
const TYPE_PARTIAL: Record<string, string> = {
spell: 'systems/hbm-rpg-v3/templates/item/spell.hbs',
gear: 'systems/hbm-rpg-v3/templates/item/gear.hbs',
ability: 'systems/hbm-rpg-v3/templates/item/ability.hbs',
class: 'systems/hbm-rpg-v3/templates/item/class.hbs',
race: 'systems/hbm-rpg-v3/templates/item/race.hbs',
discipline: 'systems/hbm-rpg-v3/templates/item/discipline.hbs',
talent: 'systems/hbm-rpg-v3/templates/item/talent.hbs',
};
export class HbmItemSheet extends HandlebarsApplicationMixin(ItemSheetV2) {
static override DEFAULT_OPTIONS = {
classes: ['hbm', 'sheet', 'item'],
position: { width: 560, height: 600 },
window: { resizable: true },
actions: {
addArrayEntry: HbmItemSheet._onAddArrayEntry,
removeArrayEntry: HbmItemSheet._onRemoveArrayEntry,
effectCreate: HbmItemSheet._onEffectCreate,
effectEdit: HbmItemSheet._onEffectEdit,
effectDelete: HbmItemSheet._onEffectDelete,
effectToggle: HbmItemSheet._onEffectToggle,
},
form: { submitOnChange: true, closeOnSubmit: false },
};
static override PARTS = {
header: { template: 'systems/hbm-rpg-v3/templates/item/header.hbs' },
body: { template: 'systems/hbm-rpg-v3/templates/item/_dispatch.hbs' },
};
override async _prepareContext(options: unknown) {
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
const item = (this as unknown as { item: { system: unknown; type: string; effects: any } }).item;
const effects = Array.from(item.effects ?? []).map((e: any) => ({
id: e.id,
name: e.name,
icon: e.icon ?? e.img ?? 'icons/svg/aura.svg',
disabled: e.disabled,
transfer: e.transfer,
changes: e.changes ?? [],
}));
const supportsEffects = item.type === 'talent' || item.type === 'gear';
return {
...ctx,
system: item.system,
itemType: item.type,
bodyPartial: TYPE_PARTIAL[item.type] ?? 'systems/hbm-rpg-v3/templates/item/_unknown.hbs',
choices: buildChoiceMaps(),
effects,
supportsEffects,
};
}
static async _onAddArrayEntry(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
if (!path) return;
const item = (this as unknown as { item: any }).item;
const current = (foundry.utils.getProperty(item, path) ?? []) as unknown[];
const template = target.dataset.template;
const entry = template ? JSON.parse(template) : '';
await item.update({ [path]: [...current, entry] });
}
static async _onRemoveArrayEntry(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
const idx = Number(target.dataset.index);
if (!path || Number.isNaN(idx)) return;
const item = (this as unknown as { item: any }).item;
const current = ([...(foundry.utils.getProperty(item, path) ?? [])] as unknown[]);
current.splice(idx, 1);
await item.update({ [path]: current });
}
static async _onEffectCreate(this: HbmItemSheet) {
const item = (this as unknown as { item: any }).item;
const created = await item.createEmbeddedDocuments('ActiveEffect', [{
name: game.i18n.localize('HBM.activeEffect.newEffect'),
icon: 'icons/svg/aura.svg',
transfer: true,
disabled: false,
changes: [],
}]);
if (created?.[0]) created[0].sheet?.render(true);
}
static async _onEffectEdit(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const item = (this as unknown as { item: any }).item;
const effect = item.effects.get(id);
effect?.sheet?.render(true);
}
static async _onEffectDelete(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const item = (this as unknown as { item: any }).item;
await item.deleteEmbeddedDocuments('ActiveEffect', [id]);
}
static async _onEffectToggle(this: HbmItemSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const item = (this as unknown as { item: any }).item;
const effect = item.effects.get(id);
if (!effect) return;
await effect.update({ disabled: !effect.disabled });
}
}
/** Build localized {key → label} maps for use in <select> options. */
function buildChoiceMaps(): Record<string, Record<string, string>> {
const localize = (prefix: string, key: string) => {
const k = `HBM.${prefix}.${key}`;
const v = game.i18n.localize(k);
return v === k ? key : v;
};
const map = (prefix: string, keys: readonly string[]) =>
Object.fromEntries(keys.map((k) => [k, localize(prefix, k)]));
return {
schools: map('spellSchool', SPELL_SCHOOLS as unknown as readonly string[]),
deities: map('deity', SACRED_DEITIES as unknown as readonly string[]),
sourceBooks: map('sourceBook', SOURCE_BOOKS as unknown as readonly string[]),
aoeShapes: map('aoeShape', AOE_SHAPES as unknown as readonly string[]),
damageTypes: map('damageType', SPELL_DAMAGE_TYPES as unknown as readonly string[]),
triggerEvents: map('triggerEvent', TRIGGER_EVENTS as unknown as readonly string[]),
};
}
+408
View File
@@ -0,0 +1,408 @@
import { rollAttribute, rollInitiative } from '../dice/macros';
import { askRollParams } from '../dice/roll-dialog';
import { askApplyDamage } from '../dice/damage-dialog';
import { askCastOptions } from '../dice/cast-dialog';
import { applyDamage } from '../logic/damage';
import { castSpell } from '../logic/spell-cast';
import { HbmTSRoll } from '../dice/ts-roll';
import { ATTRIBUTES, AttributeKey, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
};
const { HandlebarsApplicationMixin } = foundry.applications.api as unknown as {
HandlebarsApplicationMixin: <T extends abstract new (...args: any[]) => any>(base: T) => T;
};
export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
static override DEFAULT_OPTIONS = {
classes: ['hbm', 'sheet', 'npc'],
position: { width: 640, height: 720 },
window: { resizable: true, title: 'HBM.actor.npc' },
actions: {
rollAttribute: NpcSheet._onRollAttribute,
rollInitiative: NpcSheet._onRollInitiative,
rollNpcAttack: NpcSheet._onRollNpcAttack,
applyDamage: NpcSheet._onApplyDamage,
castSpell: NpcSheet._onCastSpell,
deleteItem: NpcSheet._onDeleteItem,
editItem: NpcSheet._onEditItem,
actorEffectCreate: NpcSheet._onActorEffectCreate,
actorEffectToggle: NpcSheet._onActorEffectToggle,
actorEffectEdit: NpcSheet._onActorEffectEdit,
actorEffectDelete: NpcSheet._onActorEffectDelete,
addArrayEntry: NpcSheet._onAddArrayEntry,
removeArrayEntry: NpcSheet._onRemoveArrayEntry,
editImage: NpcSheet._onEditImage,
recalculateMoney: NpcSheet._onRecalculateMoney,
},
form: { submitOnChange: true, closeOnSubmit: false },
};
static override PARTS = {
main: { template: 'systems/hbm-rpg-v3/templates/actor/npc.hbs' },
};
override async _prepareContext(options: unknown) {
const ctx = (await super._prepareContext(options)) as Record<string, unknown>;
const actor = (this as unknown as { actor: { system: any; items: any[] } }).actor;
const spells = actor.items.filter((it: any) => it.type === 'spell')
.sort((a: any, b: any) => (a.system?.circle ?? 0) - (b.system?.circle ?? 0) || a.name.localeCompare(b.name, 'pl'));
const actorEffects = [...((actor as any).effects ?? [])].map((ef: any) => ({
id: ef.id,
name: ef.name,
icon: ef.icon ?? 'icons/svg/aura.svg',
disabled: ef.disabled ?? false,
isTransferred: !!ef.origin,
originName: ef.origin ? (actor.items.find((it: any) => ef.origin?.endsWith(it.id))?.name ?? ef.origin) : '',
changes: ef.changes ?? [],
}));
const sys = actor.system;
const attributes = ATTRIBUTES.map(key => {
const attrObj = sys.attributes?.[key];
const actualVal = attrObj?.actual !== undefined ? attrObj.actual : (attrObj?.value ?? 0);
const labels = ['0', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X'];
const actualLabel = labels[Math.max(0, Math.min(10, actualVal))] || String(actualVal);
return {
key,
value: attrObj?.value ?? 0,
actual: actualVal,
actualLabel,
label: game.i18n.localize(`HBM.attributes.${key}`)
};
});
return { ...ctx, system: actor.system, attributes, spells, actorEffects };
}
override _onRender(context: unknown, options: unknown) {
super._onRender(context, options);
const html = (this as unknown as { element: HTMLElement }).element;
html.addEventListener('dragover', (ev) => { ev.preventDefault(); });
html.addEventListener('drop', (ev) => this._handleDrop(ev as DragEvent));
}
private async _handleDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
const data = (foundry.applications as any).ux.TextEditor.implementation.getDragEventData(event);
if (!data || data['type'] !== 'Item') return;
const item = await fromUuid<any>(data['uuid'] as string);
if (!item) return;
const actor = (this as unknown as { actor: any }).actor;
// Deduplicate by source slug
const sourceSlug: string | undefined = item.flags?.['hbm-rpg-v3']?.slug;
const existing = actor.items.find((it: any) => {
if (it.id === item.id) return true;
if (item.type === 'talent' && item.system?.multiSelect) return false;
if (sourceSlug && it.flags?.['hbm-rpg-v3']?.slug === sourceSlug) return true;
return false;
});
if (existing) {
ui?.notifications?.warn(game.i18n.format('HBM.ui.itemAlreadyOwned', { name: item.name }));
return;
}
if (item.type === 'talent' && item.system?.multiSelect) {
const match = item.name.match(/\((Dziedzina|Bóstwo|Zmysł|Atrybut|Umiejętność)\)$/i);
if (match) {
const paramType = match[1];
const title = game.i18n.localize('HBM.ui.selectTalentParamTitle') || 'Wybierz parametr talentu';
const labelText = game.i18n.format('HBM.ui.selectTalentParamDesc', { param: paramType }) || `Wprowadź wartość dla parametru (${paramType}):`;
let specified: string | null = null;
await (foundry.applications.api as any).DialogV2.wait({
title,
content: `
<div class="hbm-talent-dialog" style="padding:10px;">
<p>${labelText}</p>
<input id="hbm-talent-param-input" type="text" placeholder="${paramType}" style="width:100%; margin-bottom:10px;" />
</div>`,
buttons: [
{
type: 'button', action: 'confirm',
label: game.i18n.localize('HBM.ui.confirm') || 'Potwierd\u017a',
default: true,
callback: (_ev: Event, _btn: any, html: HTMLElement) => {
const val = (html.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
specified = val || null;
},
},
{ type: 'button', action: 'cancel', label: game.i18n.localize('HBM.ui.cancel') || 'Anuluj' },
],
rejectClose: false,
});
if (specified) {
const obj = item.toObject();
obj.name = item.name.replace(/\([^)]+\)$/, `(${specified})`);
await actor.createEmbeddedDocuments('Item', [obj]);
return;
} else {
return; // cancel
}
}
}
await actor.createEmbeddedDocuments('Item', [item.toObject()]);
}
static async _onRollAttribute(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const attr = target.dataset.attribute as AttributeKey | undefined;
if (!attr) return;
const actor = (this as unknown as { actor: any }).actor;
const pool = actor.system.attributes[attr]?.value ?? 0;
const params = await askRollParams({
pool,
flavor: game.i18n.format('HBM.roll.rollAttribute', { attribute: game.i18n.localize(`HBM.attributes.${attr}`) }),
});
if (!params) return;
await rollAttribute(actor, attr, { threshold: params.threshold, required: params.required });
}
static async _onRollInitiative(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
await rollInitiative(actor);
}
static async _onRollNpcAttack(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const idx = Number(target.dataset.index);
if (Number.isNaN(idx)) return;
const actor = (this as unknown as { actor: any }).actor;
const attack = actor.system.combat?.attacks?.[idx];
if (!attack) return;
const params = await askRollParams({
pool: Math.max(0, Number(attack.bonus) || 0),
flavor: attack.name,
});
if (!params) return;
const flavor = `${attack.name}${attack.damage ? `${attack.damage}` : ''}`;
const roll = HbmTSRoll.fromParams({
pool: params.pool,
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
required: params.required ?? TS_DEFAULT_REQUIRED,
flavor,
});
await roll.evaluate();
await roll.toMessage({ flavor, speaker: ChatMessage.getSpeaker({ actor }) });
}
static async _onApplyDamage(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const params = await askApplyDamage(actor);
if (!params) return;
await applyDamage(actor, params);
}
static async _onCastSpell(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const spell = actor.items.get(itemId);
if (!spell || spell.type !== 'spell') return;
const opts = await askCastOptions(spell, actor);
if (!opts) return;
opts.speaker = ChatMessage.getSpeaker({ actor });
await castSpell(actor, spell, opts);
}
static async _onDeleteItem(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
await item.delete();
}
static async _onEditItem(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const itemId = target.dataset.itemId;
if (!itemId) return;
const actor = (this as unknown as { actor: any }).actor;
const item = actor.items.get(itemId);
if (!item) return;
item.sheet?.render(true);
}
static async _onAddArrayEntry(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
if (!path) return;
const actor = (this as unknown as { actor: any }).actor;
const current = (foundry.utils.getProperty(actor, path) ?? []) as unknown[];
const template = target.dataset.template;
const entry = template ? JSON.parse(template) : '';
await actor.update({ [path]: [...current, entry] });
}
static async _onRemoveArrayEntry(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const path = target.dataset.path;
const idx = Number(target.dataset.index);
if (!path || Number.isNaN(idx)) return;
const actor = (this as unknown as { actor: any }).actor;
const current = ([...(foundry.utils.getProperty(actor, path) ?? [])] as unknown[]);
current.splice(idx, 1);
await actor.update({ [path]: current });
}
static async _onActorEffectCreate(this: NpcSheet) {
const actor = (this as unknown as { actor: any }).actor;
const created = await actor.createEmbeddedDocuments('ActiveEffect', [{
name: game.i18n.localize('HBM.activeEffect.newEffect'),
icon: 'icons/svg/aura.svg',
disabled: false,
changes: [],
}]);
created[0]?.sheet?.render(true);
}
static async _onActorEffectToggle(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
const effect = actor.effects.get(id);
if (!effect) return;
await effect.update({ disabled: !effect.disabled });
}
static async _onActorEffectEdit(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
actor.effects.get(id)?.sheet?.render(true);
}
static async _onActorEffectDelete(this: NpcSheet, _event: PointerEvent, target: HTMLElement) {
const id = target.dataset.effectId;
if (!id) return;
const actor = (this as unknown as { actor: any }).actor;
await actor.deleteEmbeddedDocuments('ActiveEffect', [id]);
}
static async _onEditImage(this: NpcSheet, event: PointerEvent, target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const current = actor.img;
const fp = new FilePicker({
type: "image",
current: current,
callback: (path: string) => {
actor.update({ img: path });
},
top: this.position.top + 40,
left: this.position.left + 10
});
return fp.browse();
}
static async _onRecalculateMoney(this: NpcSheet, event: PointerEvent, target: HTMLElement) {
const actor = (this as unknown as { actor: any }).actor;
const pln = actor.system.details?.money ?? 0;
const currentYear = actor.system.details?.currentYear ?? 2026;
let eurRate = 4.35;
let usdRate = 4.00;
let rateSource = "Domyślne przeliczniki (brak połączenia lub rok poza zakresem API NBP)";
let isLive = false;
const currentRealYear = new Date().getFullYear();
// Helper function to fetch rate from NBP
const fetchNBP = async (currency: string, year: number): Promise<{ rate: number; date: string } | null> => {
if (year >= 2002 && year <= currentRealYear) {
// Try first 7 days of June to find a working business day (NBP doesn't publish on weekends)
for (let day = 1; day <= 7; day++) {
const dateString = `${year}-06-${String(day).padStart(2, '0')}`;
try {
const response = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/${dateString}/?format=json`);
if (response.ok) {
const data = await response.json();
const rate = data?.rates?.[0]?.mid;
if (typeof rate === 'number') {
return { rate, date: dateString };
}
}
} catch (e) {
// Ignore and try next
}
}
}
// Fallback to latest rate
try {
const response = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
if (response.ok) {
const data = await response.json();
const rate = data?.rates?.[0]?.mid;
const date = data?.rates?.[0]?.effectiveDate || "";
if (typeof rate === 'number') {
return { rate, date };
}
}
} catch (e) {
// Ignore
}
return null;
};
// Fetch rates in parallel
const [eurData, usdData] = await Promise.all([
fetchNBP("eur", currentYear),
fetchNBP("usd", currentYear)
]);
if (eurData && usdData) {
eurRate = eurData.rate;
usdRate = usdData.rate;
rateSource = `Pobrane z NBP (EUR z ${eurData.date}, USD z ${usdData.date})`;
isLive = true;
} else if (eurData) {
eurRate = eurData.rate;
rateSource = `Częściowo pobrane z NBP (EUR z ${eurData.date})`;
isLive = true;
} else if (usdData) {
usdRate = usdData.rate;
rateSource = `Częściowo pobrane z NBP (USD z ${usdData.date})`;
isLive = true;
}
// Conversion rates
const eur = (pln / eurRate).toFixed(2);
const th = (pln / eurRate).toFixed(2); // 1:1 with EUR
const usd = (pln / usdRate).toFixed(2);
const fc = (pln / 2.0).toFixed(2); // 2 PLN = 1 Credit
const st = (pln / 240).toFixed(2);
const zk = (pln / 2880).toFixed(2);
const pl = (pln / 34560).toFixed(2);
const content = `
<div class="hbm money-converter" style="padding: 10px; font-family: 'Signika', sans-serif;">
<p><strong>Bieżąca gotówka:</strong> ${pln} PLN (Rok kampanii: ${currentYear})</p>
<p style="font-size: 0.8rem; color: ${isLive ? '#2a7a3e' : '#888'}; margin-top: -5px;">
<em>Kursy: ${rateSource}</em><br/>
(1 EUR = ${eurRate.toFixed(4)} PLN, 1 USD = ${usdRate.toFixed(4)} PLN)
</p>
<hr style="border-top: 1px dashed var(--hbm-border, #ccc); margin: 10px 0;" />
<h4 style="margin: 5px 0;">Waluty Ziemskie i Międzyświatowe</h4>
<ul style="list-style: none; padding: 0; margin: 5px 0; display: flex; flex-direction: column; gap: 4px;">
<li><strong>FC (Kredyt Federacji Sol-3):</strong> ${fc} Credits <small style="color: #666;">(Waluta Federacji Sol-3, stały kurs 2 PLN = 1 Credit)</small></li>
<li><strong>EUR (Euro):</strong> ${eur} €</li>
<li><strong>USD (Dolar):</strong> ${usd} $</li>
<li><strong>Thrakka (TH):</strong> ${th} TH <small style="color: #666;">(krasnoludzka waluta rozliczeniowa, 1:1 z EUR)</small></li>
</ul>
<hr style="border-top: 1px dashed var(--hbm-border, #ccc); margin: 10px 0;" />
<h4 style="margin: 5px 0;">Krasnoludzkie Monety Klanowe (System Dwunastkowy)</h4>
<ul style="list-style: none; padding: 0; margin: 5px 0; display: flex; flex-direction: column; gap: 4px;">
<li><strong>ST (Srebrny Talent):</strong> ${st} ST <small style="color: #666;">(1 ST = 240 PLN / 55 TH)</small></li>
<li><strong>ZK (Złota Korona):</strong> ${zk} ZK <small style="color: #666;">(1 ZK = 2880 PLN / 660 TH)</small></li>
<li><strong>PL (Platynowy Lingot):</strong> ${pl} PL <small style="color: #666;">(1 PL = 34560 PLN / 7920 TH)</small></li>
</ul>
</div>
`;
await (foundry.applications.api as any).DialogV2.inform({
title: game.i18n.localize('HBM.ui.moneyConverterTitle'),
content: content,
rejectClose: false,
});
}
}