Initial commit
This commit is contained in:
@@ -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];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')} (2–6)</label>
|
||||
<input type="number" name="threshold" value="${t}" min="2" max="6"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.roll.required')} (1–10)</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"> — ${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>
|
||||
|
|
||||
${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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+193
@@ -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
@@ -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`);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Magia Otchłani — Abyss Magic logic (Klątwa Otchłani Ch. III–VIII).
|
||||
*
|
||||
* The Abyss splits into two disciplines:
|
||||
* - Magia Aspektów ("aspects") — controlled, low-risk, predictable
|
||||
* - Pierwotna Magia ("primal") — chaotic, high-power, requires `Dary Otchłani` rolls
|
||||
*
|
||||
* 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user