feat: implement core Foundry VTT system framework, including actor/item data models, spellcasting logic, dice mechanics, and automation scripts
This commit is contained in:
+36
-35
@@ -49,29 +49,29 @@ export const SKILLS: Readonly<Record<string, AttributeKey>> = Object.freeze({
|
||||
* `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'],
|
||||
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'],
|
||||
});
|
||||
|
||||
@@ -90,17 +90,17 @@ export interface MagicPowerEntry {
|
||||
}
|
||||
|
||||
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 },
|
||||
{ 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 {
|
||||
@@ -133,6 +133,7 @@ export const SPELL_SCHOOLS = [
|
||||
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy',
|
||||
// Forbidden / extra-academic
|
||||
'blood', 'crimson', 'abyssAspects', 'abyssPrimal', 'wildWitch',
|
||||
'eldritch',
|
||||
] as const;
|
||||
export type SpellSchool = (typeof SPELL_SCHOOLS)[number];
|
||||
|
||||
@@ -144,7 +145,7 @@ export const SACRED_DEITIES = [
|
||||
export type SacredDeity = (typeof SACRED_DEITIES)[number];
|
||||
|
||||
/**
|
||||
* Witch magic symbols. EXTENSIBLE — additional content may add more.
|
||||
* 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([
|
||||
|
||||
+17
-24
@@ -15,7 +15,7 @@ import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from
|
||||
* - 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
|
||||
* - attributes.mana.max / .maxPerSpell / magic.dicePool - from MAGIC_POWER_TABLE
|
||||
*/
|
||||
export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
@@ -66,10 +66,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
maxPerSpell: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
zeal: makeValueMaxField(0, 0),
|
||||
// Blood Pool (Arcanum Sanguinis Ch. III) — experimental until full spell list ships.
|
||||
// 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;
|
||||
// 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
|
||||
@@ -90,7 +90,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
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 }),
|
||||
initiative: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
bonus: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
}),
|
||||
skills: new f.SchemaField(skillEntries),
|
||||
details: new f.SchemaField({
|
||||
@@ -101,7 +104,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
discipline: makeStringField('', { blank: true }),
|
||||
title: makeStringField('', { blank: true }),
|
||||
biography: makeHtmlField(''),
|
||||
customEquipment: makeHtmlField(''), // legacy textarea — kept for migration
|
||||
customEquipment: makeHtmlField(''), // legacy textarea - kept for migration
|
||||
customItems: makeArrayField(new f.SchemaField({
|
||||
name: makeStringField('', { blank: true }),
|
||||
qty: makeIntField(1, { min: 0 }),
|
||||
@@ -144,7 +147,7 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
magicalArmor: { value: number; max: number; runicCounter: number };
|
||||
magicalShield: { value: number };
|
||||
physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
|
||||
initiative: number;
|
||||
initiative: { value: number; bonus: number };
|
||||
};
|
||||
skills: Record<string, { value: number; defaultAttribute: AttributeKey }>;
|
||||
details: { raceId?: string; classIds?: string[] };
|
||||
@@ -214,11 +217,12 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
}
|
||||
}
|
||||
|
||||
a.initiative =
|
||||
a.initiative.value =
|
||||
a.mind.value +
|
||||
(sys.skills.reflex?.value ?? 0) +
|
||||
(sys.skills.perception?.value ?? 0) +
|
||||
initiativeBonus;
|
||||
initiativeBonus +
|
||||
(a.initiative.bonus ?? 0);
|
||||
|
||||
// Clamp magical armor / shield
|
||||
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
|
||||
@@ -261,28 +265,13 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
// 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.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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,6 +287,10 @@ export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
}
|
||||
delete initialized.attributes.armor;
|
||||
}
|
||||
if (initialized?.attributes && typeof initialized.attributes.initiative === 'number') {
|
||||
const legacy = initialized.attributes.initiative;
|
||||
initialized.attributes.initiative = { value: legacy, bonus: 0 };
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
|
||||
+43
-2
@@ -6,11 +6,11 @@ import {
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { getMagicPowerEntry } from '../constants';
|
||||
import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from '../constants';
|
||||
|
||||
/**
|
||||
* NPC / Creature data model.
|
||||
* Looser than character (no derived health formula — set directly).
|
||||
* Looser than character (no derived health formula - set directly).
|
||||
*/
|
||||
export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
@@ -29,6 +29,17 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
description: makeStringField(''),
|
||||
});
|
||||
|
||||
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[],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: new f.SchemaField({
|
||||
body: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
@@ -49,8 +60,13 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
magicalArmor: makeValueMaxField(0, 0),
|
||||
magicalShield: makeValueMaxField(0, 0),
|
||||
physicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }) }),
|
||||
initiative: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
bonus: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
speed: makeStringField('5m'),
|
||||
}),
|
||||
skills: new f.SchemaField(skillEntries),
|
||||
details: new f.SchemaField({
|
||||
type: makeStringField(''),
|
||||
size: makeStringField('medium'),
|
||||
@@ -88,11 +104,13 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
|
||||
const sys = this as unknown as {
|
||||
attributes: {
|
||||
mind: { value: number };
|
||||
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 };
|
||||
initiative: { value: number; bonus: number };
|
||||
};
|
||||
};
|
||||
const a = sys.attributes;
|
||||
@@ -120,6 +138,25 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
let initiativeBonus = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.initiative.value =
|
||||
a.mind.value +
|
||||
initiativeBonus +
|
||||
(a.initiative.bonus ?? 0);
|
||||
|
||||
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;
|
||||
@@ -135,6 +172,10 @@ export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
}
|
||||
delete initialized.attributes.armor;
|
||||
}
|
||||
if (initialized?.attributes && typeof initialized.attributes.initiative === 'number') {
|
||||
const legacy = initialized.attributes.initiative;
|
||||
initialized.attributes.initiative = { value: legacy, bonus: 0 };
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
|
||||
|
||||
/**
|
||||
* Unified Gear item — discriminated by `category`.
|
||||
* Unified Gear item - discriminated by `category`.
|
||||
* Weapon/armor/equipment all share base fields; specialised fields
|
||||
* are scoped under `weapon` and `armor` sub-schemas.
|
||||
*/
|
||||
|
||||
@@ -85,7 +85,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
|
||||
minCasters: makeIntField(1, { min: 1 }),
|
||||
nonCombatOnly: makeBoolField(false),
|
||||
|
||||
// Overcast — structured
|
||||
// Overcast - structured
|
||||
overcastOptions: makeArrayField(new f.SchemaField({
|
||||
description: makeStringField(''),
|
||||
manaPerStep: makeIntField(1, { min: 0 }),
|
||||
@@ -105,7 +105,7 @@ export class SpellData extends foundry.abstract.TypeDataModel {
|
||||
|
||||
// Description
|
||||
description: makeHtmlField(''),
|
||||
higherCircles: makeHtmlField(''), // legacy — superseded by overcastOptions
|
||||
higherCircles: makeHtmlField(''), // legacy - superseded by overcastOptions
|
||||
|
||||
// Legacy fields (kept for migration; will be removed in a later release)
|
||||
overcasting: makeStringField(''),
|
||||
|
||||
@@ -76,6 +76,12 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
|
||||
</div>`);
|
||||
}
|
||||
if (mode !== 'sacred') {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
<label><input type="checkbox" name="castAsPrayer"/> ${game.i18n.localize('HBM.spell.castingMode.prayer')} (+1 ${game.i18n.localize('HBM.resources.zealAbbr')}, ${game.i18n.localize('HBM.attributes.soul')} + ${game.i18n.localize('HBM.skills.devotion')})</label>
|
||||
</div>`);
|
||||
}
|
||||
if (showHekate) {
|
||||
fields.push(`
|
||||
<div class="form-group">
|
||||
@@ -157,6 +163,7 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
|
||||
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.castAsPrayer) opts.castAsPrayer = true;
|
||||
if (fd.bypassSuperspellWarning) opts.bypassSuperspellWarning = true;
|
||||
if (fd.bypassNonCombatBlock) opts.bypassNonCombatBlock = true;
|
||||
const groupRaw = String(fd.groupCasters ?? '').trim();
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function askApplyDamage(actor: ActorLike, presetAmount = 0): Promis
|
||||
|
||||
return new Promise((resolve) => {
|
||||
new foundry.appv1.api.Dialog({
|
||||
title: `${game.i18n.localize('HBM.damage.title')} — ${actor.name}`,
|
||||
title: `${game.i18n.localize('HBM.damage.title')} - ${actor.name}`,
|
||||
content: `
|
||||
<form class="hbm-dialog">
|
||||
<div class="form-group">
|
||||
|
||||
@@ -21,7 +21,7 @@ export function collectRollModifiers(actor: any, tags: readonly SkillTag[]): Rol
|
||||
const hbm = ef?.flags?.hbm;
|
||||
if (!hbm) continue;
|
||||
|
||||
// Threshold step modifiers — keys may be tag names or 'all'
|
||||
// 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'];
|
||||
|
||||
+20
-7
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Roll helpers — convenience wrappers around HbmTSRoll bound to actor data.
|
||||
* 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';
|
||||
import { ATTRIBUTES, AttributeKey, SKILLS, SKILL_TAGS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
|
||||
|
||||
export interface SkillRollOptions {
|
||||
/** Override the skill's default attribute (GM allows another for a specific roll). */
|
||||
@@ -26,6 +26,13 @@ export interface ActorLike {
|
||||
|
||||
function getAttributeValue(actor: ActorLike, key: AttributeKey): number {
|
||||
const a = actor.system.attributes[key];
|
||||
if (key === 'magic' && a) {
|
||||
if ('dicePool' in a && typeof a.dicePool === 'number') {
|
||||
return a.dicePool;
|
||||
}
|
||||
const actual = (a as any).actual ?? a.value ?? 0;
|
||||
return getMagicPowerEntry(actual).dicePool;
|
||||
}
|
||||
return a?.value ?? 0;
|
||||
}
|
||||
|
||||
@@ -91,12 +98,18 @@ export async function rollAttribute(
|
||||
}
|
||||
|
||||
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 sysAttr = actor.system.attributes as any;
|
||||
let mod = 0;
|
||||
if (sysAttr.initiative && typeof sysAttr.initiative.value === 'number') {
|
||||
mod = sysAttr.initiative.value;
|
||||
} else {
|
||||
const mind = getAttributeValue(actor, 'mind');
|
||||
const reflex = actor.system.skills?.reflex?.value ?? 0;
|
||||
const perception = actor.system.skills?.perception?.value ?? 0;
|
||||
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({
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface RollDialogResult {
|
||||
pool: number;
|
||||
threshold: number;
|
||||
required: number;
|
||||
modifier: number;
|
||||
flavor?: string;
|
||||
}
|
||||
|
||||
@@ -36,6 +37,10 @@ export async function askRollParams(initial: {
|
||||
<label>${game.i18n.localize('HBM.roll.required')} (1–10)</label>
|
||||
<input type="number" name="required" value="${y}" min="1" max="10"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${game.i18n.localize('HBM.roll.modifier')}</label>
|
||||
<input type="number" name="modifier" value="0"/>
|
||||
</div>
|
||||
</form>`,
|
||||
buttons: {
|
||||
roll: {
|
||||
@@ -48,6 +53,7 @@ export async function askRollParams(initial: {
|
||||
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)),
|
||||
modifier: Number(fd['modifier']) || 0,
|
||||
flavor: initial.flavor,
|
||||
});
|
||||
},
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
* otherwise face >= T → success
|
||||
* - Roll succeeds when total successes ≥ requiredSuccesses (Y).
|
||||
*
|
||||
* Formula syntax: `ts(N, T, Y)` — e.g. `/r ts(5, 4, 2)`.
|
||||
* Formula syntax: `ts(N, T, Y)` - e.g. `/r ts(5, 4, 2)`.
|
||||
* The N/T/Y parameters are also accepted via constructor data.
|
||||
*/
|
||||
|
||||
@@ -121,7 +121,7 @@ function computeTsResult(roll: Roll): HbmTsRollResult {
|
||||
// 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.
|
||||
// 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) {
|
||||
|
||||
+7
-7
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Homebrew Magic: RPG v3 — Foundry VTT system entry point.
|
||||
* 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).
|
||||
@@ -111,16 +111,16 @@ Hooks.once('init', () => {
|
||||
|
||||
// Override default initiative formula to HbM's 2d6 + initiative attribute.
|
||||
CONFIG.Combat.initiative = {
|
||||
formula: '2d6 + @attributes.initiative',
|
||||
formula: '2d6 + @attributes.initiative.value',
|
||||
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('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(''));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/**
|
||||
* Magia Otchłani — Abyss Magic logic (Klątwa Otchłani Ch. III–VIII).
|
||||
* Magia Otchłani - Abyss Magic logic (Klątwa Otchłani Ch. III–VIII).
|
||||
*
|
||||
* The Abyss splits into two disciplines:
|
||||
* - Magia Aspektów ("aspects") — controlled, low-risk, predictable
|
||||
* - Pierwotna Magia ("primal") — chaotic, high-power, requires `Dary Otchłani` rolls
|
||||
* - Magia Aspektów ("aspects") - controlled, low-risk, predictable
|
||||
* - Pierwotna Magia ("primal") - chaotic, high-power, requires `Dary Otchłani` rolls
|
||||
*
|
||||
* This module exposes:
|
||||
* - dispatchAbyssCast(spell) — returns 'aspects' | 'primal' from spell.discipline.
|
||||
* - rollAbyssGift(actor) — d100 against a roll table (resolved at runtime
|
||||
* - dispatchAbyssCast(spell) - returns 'aspects' | 'primal' from spell.discipline.
|
||||
* - rollAbyssGift(actor) - d100 against a roll table (resolved at runtime
|
||||
* from the `roll-tables-abyss.dary-otchlani` pack
|
||||
* when present; otherwise falls back to a chat
|
||||
* prompt for the GM).
|
||||
* - rollMistrzLosuPenalty(actor) — d100 penalty roll (Klątwa Otchłani VII).
|
||||
* - addInsanity(actor, n) — increments actor.system.attributes.insanity;
|
||||
* - rollMistrzLosuPenalty(actor) - d100 penalty roll (Klątwa Otchłani VII).
|
||||
* - addInsanity(actor, n) - increments actor.system.attributes.insanity;
|
||||
* when crossing a threshold, fires a hook for
|
||||
* the GM to apply a mental condition.
|
||||
*/
|
||||
@@ -69,7 +69,7 @@ async function rollAgainstAbyssTable(actor: CastableActor, slug: string, flavor:
|
||||
await roll.evaluate();
|
||||
const speaker = ChatMessage.getSpeaker({ actor: actor as unknown as Actor });
|
||||
await roll.toMessage({
|
||||
flavor: `${flavor} — ${actor.name} (${slug})`,
|
||||
flavor: `${flavor} - ${actor.name} (${slug})`,
|
||||
speaker,
|
||||
});
|
||||
Hooks.callAll('hbm.abyssTableRoll', actor, slug, roll.total);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Magia Krwi — Blood Magic logic (Arcanum Sanguinis Ch. III).
|
||||
* Magia Krwi - Blood Magic logic (Arcanum Sanguinis Ch. III).
|
||||
*
|
||||
* Three primitives:
|
||||
* - spendBlood(actor, n) : deduct n from blood pool, fail if insufficient.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Warzenie Eliksirów — Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
|
||||
* Warzenie Eliksirów - Brewing logic (Podręcznik Gry, Alchemia / Aneks C).
|
||||
*
|
||||
* Each character has `attributes.elixirTolerance` (default 0). Soft cap = body+1.
|
||||
* Drinking a potion increments the counter; exceeding the cap triggers a
|
||||
@@ -12,12 +12,12 @@
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
|
||||
import type { CastableActor } from './spell-cast';
|
||||
|
||||
export interface ElixirRecipe {
|
||||
name: string;
|
||||
/** TS test target — { threshold, successes }. */
|
||||
/** TS test target - { threshold, successes }. */
|
||||
difficulty: { threshold: number; successes: number };
|
||||
/** Ingredient names (free-form). */
|
||||
ingredients: string[];
|
||||
@@ -44,7 +44,11 @@ export async function consumeElixir(actor: CastableActor, recipe: Pick<ElixirRec
|
||||
export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Promise<{ roll: HbmTSRoll; success: boolean }> {
|
||||
const skillKey = recipe.skill ?? 'alchemyBrewing';
|
||||
const skillValue = actor.system.skills?.[skillKey]?.value ?? 0;
|
||||
const pool = actor.system.attributes.magic.actual + skillValue;
|
||||
const magicAttr = actor.system.attributes.magic as any;
|
||||
const magicDice = typeof magicAttr.dicePool === 'number'
|
||||
? magicAttr.dicePool
|
||||
: getMagicPowerEntry(magicAttr.actual ?? magicAttr.value ?? 0).dicePool;
|
||||
const pool = magicDice + skillValue;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
threshold: recipe.difficulty.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
@@ -53,7 +57,7 @@ export async function brewElixir(actor: CastableActor, recipe: ElixirRecipe): Pr
|
||||
});
|
||||
await roll.evaluate();
|
||||
await roll.toMessage({
|
||||
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '—'})`,
|
||||
flavor: `Warzenie ${recipe.name} (Składniki: ${recipe.ingredients.join(', ') || '-'})`,
|
||||
});
|
||||
return { roll, success: !!roll.ts?.isSuccess };
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Combat hooks — handle per-round Mana reset, per-turn Zeal regen,
|
||||
* Combat hooks - handle per-round Mana reset, per-turn Zeal regen,
|
||||
* and condition-driven turn behavior (skip / damage tick / death save).
|
||||
*/
|
||||
|
||||
@@ -50,14 +50,14 @@ export function registerCombatHooks(): void {
|
||||
await applyDamage(actor, { amount: 1, type: 'environmental', ignoreMagicalArmor: true, ignoreMagicalShield: true, ignorePhysicalArmor: true });
|
||||
}
|
||||
|
||||
// Dying: prompt death save (simplified — posts a chat reminder)
|
||||
// Dying: prompt death save (simplified - posts a chat reminder)
|
||||
if (hasStatus(actor, 'umierajacy')) {
|
||||
ChatMessage.create({
|
||||
content: `<strong>${actor.name}</strong>: ${game.i18n.localize('HBM.combat.deathSavePrompt')}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Per-turn Zeal regen (characters) — base 1 + talent bonus from flag.
|
||||
// Per-turn Zeal regen (characters) - base 1 + talent bonus from flag.
|
||||
if (actor.type === 'character') {
|
||||
const sys = actor.system as { attributes: { zeal: { value: number; max: number } } };
|
||||
const regen = getZealRegen(actor);
|
||||
|
||||
+26
-26
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* HbM canonical status conditions (Aneks A — Podręcznik Gry).
|
||||
* HbM canonical status conditions (Aneks A - Podręcznik Gry).
|
||||
* 15 entries with Active-Effect change ops and `flags.hbm.*` metadata
|
||||
* consumed by the roll pipeline and combat hooks.
|
||||
*/
|
||||
@@ -17,51 +17,51 @@ export interface ConditionDef {
|
||||
const CHANGE_MODE_ADD = 2;
|
||||
|
||||
export const CONDITIONS: ConditionDef[] = [
|
||||
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true } } },
|
||||
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } },
|
||||
{ id: 'obezwladniony', i18nKey: 'HBM.conditions.obezwladniony', icon: 'icons/svg/blood.svg', flags: { hbm: { blocksTags: ['attack'], defenseT: 2 } } },
|
||||
{ id: 'nieprzytomny', i18nKey: 'HBM.conditions.nieprzytomny', icon: 'icons/svg/unconscious.svg', flags: { hbm: { skipTurn: true, blocksAllActions: true } } },
|
||||
{ id: 'umierajacy', i18nKey: 'HBM.conditions.umierajacy', icon: 'icons/svg/skull.svg', flags: { hbm: { dyingState: { failures: 0, hits: 0, mortalDamage: 0 } } } },
|
||||
{ id: 'ogluszony', i18nKey: 'HBM.conditions.ogluszony', icon: 'icons/svg/deaf.svg', flags: { hbm: { blocksTags: ['hearing'] } } },
|
||||
{ id: 'oslepiony', i18nKey: 'HBM.conditions.oslepiony', icon: 'icons/svg/blind.svg', flags: { hbm: { blocksTags: ['sight'] } } },
|
||||
{ id: 'oszolomiony', i18nKey: 'HBM.conditions.oszolomiony', icon: 'icons/svg/daze.svg', flags: { hbm: { maxActions: 1, blockZeal: true, halveSpeed: true } } },
|
||||
{ id: 'przewrocony', i18nKey: 'HBM.conditions.przewrocony', icon: 'icons/svg/falling.svg', flags: { hbm: { prone: true } } },
|
||||
{ id: 'unieruchomiony', i18nKey: 'HBM.conditions.unieruchomiony', icon: 'icons/svg/net.svg', flags: { hbm: { thresholdSteps: { attack: 1 }, defenseSteps: -1 } } },
|
||||
{ id: 'obezwladniony', i18nKey: 'HBM.conditions.obezwladniony', icon: 'icons/svg/blood.svg', flags: { hbm: { blocksTags: ['attack'], defenseT: 2 } } },
|
||||
{ id: 'nieprzytomny', i18nKey: 'HBM.conditions.nieprzytomny', icon: 'icons/svg/unconscious.svg', flags: { hbm: { skipTurn: true, blocksAllActions: true } } },
|
||||
{ id: 'umierajacy', i18nKey: 'HBM.conditions.umierajacy', icon: 'icons/svg/skull.svg', flags: { hbm: { dyingState: { failures: 0, hits: 0, mortalDamage: 0 } } } },
|
||||
{ id: 'ogluszony', i18nKey: 'HBM.conditions.ogluszony', icon: 'icons/svg/deaf.svg', flags: { hbm: { blocksTags: ['hearing'] } } },
|
||||
{ id: 'oslepiony', i18nKey: 'HBM.conditions.oslepiony', icon: 'icons/svg/blind.svg', flags: { hbm: { blocksTags: ['sight'] } } },
|
||||
{ id: 'oszolomiony', i18nKey: 'HBM.conditions.oszolomiony', icon: 'icons/svg/daze.svg', flags: { hbm: { maxActions: 1, blockZeal: true, halveSpeed: true } } },
|
||||
{
|
||||
id: 'zatruty',
|
||||
i18nKey: 'HBM.conditions.zatruty',
|
||||
icon: 'icons/svg/poison.svg',
|
||||
changes: [
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
|
||||
],
|
||||
},
|
||||
{ id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } },
|
||||
{ id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } },
|
||||
{ id: 'podpalony', i18nKey: 'HBM.conditions.podpalony', icon: 'icons/svg/fire.svg', flags: { hbm: { damagePerRound: { amount: 1, type: 'environmental' } } } },
|
||||
{ id: 'spowolniony', i18nKey: 'HBM.conditions.spowolniony', icon: 'icons/svg/clockwork.svg', flags: { hbm: { halveSpeed: true } } },
|
||||
{
|
||||
id: 'przeklety',
|
||||
i18nKey: 'HBM.conditions.przeklety',
|
||||
icon: 'icons/svg/sun.svg',
|
||||
changes: [
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.body.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.mind.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.soul.value', mode: CHANGE_MODE_ADD, value: '-1' },
|
||||
{ key: 'system.attributes.magic.value', mode: CHANGE_MODE_ADD, value: '1' },
|
||||
],
|
||||
flags: { hbm: { blocksRegen: true } },
|
||||
},
|
||||
{ id: 'zauroczony', i18nKey: 'HBM.conditions.zauroczony', icon: 'icons/svg/heal.svg', flags: { hbm: { thresholdSteps: { social: 1 }, charmedBy: '' } } },
|
||||
{ id: 'koncentracja', i18nKey: 'HBM.conditions.koncentracja', icon: 'icons/svg/aura.svg', flags: { hbm: { concentration: true, persistent: true } } },
|
||||
{ id: 'zauroczony', i18nKey: 'HBM.conditions.zauroczony', icon: 'icons/svg/heal.svg', flags: { hbm: { thresholdSteps: { social: 1 }, charmedBy: '' } } },
|
||||
{ id: 'koncentracja', i18nKey: 'HBM.conditions.koncentracja', icon: 'icons/svg/aura.svg', flags: { hbm: { concentration: true, persistent: true } } },
|
||||
// Provisional: book description incomplete for Przerażony (Aneks A placeholder).
|
||||
{ id: 'przerazony', i18nKey: 'HBM.conditions.przerazony', icon: 'icons/svg/terror.svg', flags: { hbm: { thresholdSteps: { all: 1 }, todoBookGap: true } } },
|
||||
{ id: 'przerazony', i18nKey: 'HBM.conditions.przerazony', icon: 'icons/svg/terror.svg', flags: { hbm: { thresholdSteps: { all: 1 }, todoBookGap: true } } },
|
||||
|
||||
// Mental illnesses (Klątwa Otchłani Ch. VIII — book chapter currently a placeholder;
|
||||
// Mental illnesses (Klątwa Otchłani Ch. VIII - book chapter currently a placeholder;
|
||||
// these are stub registrations with `todoBookGap: true` until full mechanics drop).
|
||||
{ id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'paranoja', i18nKey: 'HBM.conditions.paranoja', icon: 'icons/svg/eye.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'fobia', i18nKey: 'HBM.conditions.fobia', icon: 'icons/svg/silenced.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'depresja', i18nKey: 'HBM.conditions.depresja', icon: 'icons/svg/sleep.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'mania', i18nKey: 'HBM.conditions.mania', icon: 'icons/svg/lightning.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
{ id: 'schizofrenia', i18nKey: 'HBM.conditions.schizofrenia', icon: 'icons/svg/stoned.svg', flags: { hbm: { mental: true, todoBookGap: true } } },
|
||||
];
|
||||
|
||||
export function registerHbmConditions(): void {
|
||||
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Damage application engine — flows through 4 layers:
|
||||
* Damage application engine - flows through 4 layers:
|
||||
* 1. Magical Armor (DR + every 5 dmg absorbed degrades value by 1 via runicCounter)
|
||||
* 2. Magical Shield (raw temp HP; cannot be healed; vanishes at 0)
|
||||
* 3. Physical Armor (flat DR; optionally degrade `condition` by 1)
|
||||
@@ -54,7 +54,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
|
||||
|
||||
let remaining = total;
|
||||
|
||||
// Layer 1: Magical Armor — DR-based; runicCounter accumulates incoming damage.
|
||||
// Layer 1: Magical Armor - DR-based; runicCounter accumulates incoming damage.
|
||||
if (!opts.ignoreMagicalArmor && a.magicalArmor) {
|
||||
const dr = Math.max(0, Number(a.magicalArmor.value) || 0);
|
||||
const absorbed = Math.min(dr, remaining);
|
||||
@@ -70,7 +70,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: Magical Shield — pure temp HP (raw subtraction).
|
||||
// Layer 2: Magical Shield - pure temp HP (raw subtraction).
|
||||
if (remaining > 0 && !opts.ignoreMagicalShield && a.magicalShield) {
|
||||
const shield = Math.max(0, Number(a.magicalShield.value) || 0);
|
||||
const taken = Math.min(shield, remaining);
|
||||
@@ -83,7 +83,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: Physical Armor — flat DR; optionally degrade condition.
|
||||
// Layer 3: Physical Armor - flat DR; optionally degrade condition.
|
||||
if (remaining > 0 && !opts.ignorePhysicalArmor && a.physicalArmor) {
|
||||
const dr = Math.max(0, Number(a.physicalArmor.value) || 0);
|
||||
const absorbed = Math.min(dr, remaining);
|
||||
@@ -118,7 +118,7 @@ export async function applyDamage(actor: ActorLike, opts: ApplyDamageOptions): P
|
||||
|
||||
if (opts.postChat ?? true) {
|
||||
const lines: string[] = [
|
||||
`<strong>${actor.name}</strong> — ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
|
||||
`<strong>${actor.name}</strong> - ${game.i18n.localize('HBM.damage.report.title')}: <strong>${total}</strong>`,
|
||||
];
|
||||
if (report.absorbed.magicalArmor) lines.push(`${game.i18n.localize('HBM.resources.magicalArmor')}: −${report.absorbed.magicalArmor}`);
|
||||
if (report.absorbed.magicalShield) lines.push(`${game.i18n.localize('HBM.resources.magicalShield')}: −${report.absorbed.magicalShield}${report.shieldDropped ? ' ✦' : ''}`);
|
||||
|
||||
+6
-6
@@ -3,14 +3,14 @@
|
||||
*
|
||||
* Short Rest (Krótki Odpoczynek):
|
||||
* - Restore HP equal to actor.body.value (capped at max).
|
||||
* - Restore mana to max-per-spell? — no: short rest does NOT restore mana.
|
||||
* - Restore mana to max-per-spell? - no: short rest does NOT restore mana.
|
||||
* - Holders of `Nadzwyczajna Odporność` (alchemy passive) restore 1 elixir tolerance.
|
||||
*
|
||||
* Long Rest (Długi Odpoczynek):
|
||||
* - Restore HP, mana, zeal to max.
|
||||
* - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? — book says
|
||||
* - Restore 1 elixir tolerance (or all, if `Nadzwyczajna Odporność`? - book says
|
||||
* elixir tolerance recovers per long rest by default; tracked here as -1).
|
||||
* - Reset blood pool to max (assumption — refine when AS spell list lands).
|
||||
* - Reset blood pool to max (assumption - refine when AS spell list lands).
|
||||
* - Clear runic counter on magical armor.
|
||||
*
|
||||
* Hooks `hbm.beforeRest` and `hbm.afterRest` fire for module integration.
|
||||
@@ -134,9 +134,9 @@ export async function rest(actor: CastableActor, kind: RestKind): Promise<RestRe
|
||||
const conditionIds = new Set(CONDITIONS.map(c => c.id));
|
||||
for (const ef of actorAny.effects ?? []) {
|
||||
const isState = conditionIds.has(ef.id) ||
|
||||
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
|
||||
conditionIds.has(ef.flags?.core?.statusId);
|
||||
|
||||
(ef.statuses && [...ef.statuses].some(s => conditionIds.has(s))) ||
|
||||
conditionIds.has(ef.flags?.core?.statusId);
|
||||
|
||||
if (isState) {
|
||||
ids.push(ef.id);
|
||||
continue;
|
||||
|
||||
+63
-23
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* Spell casting workflow — handles standard, sacred (Magia Sakralna),
|
||||
* Spell casting workflow - handles standard, sacred (Magia Sakralna),
|
||||
* witch (Wiedźmia Magia), and blood (Magia Krwi) modes.
|
||||
*
|
||||
* Pipeline per cast:
|
||||
* 1. validateCast() — gate on resources, race, talent, discipline,
|
||||
* 1. validateCast() - gate on resources, race, talent, discipline,
|
||||
* deity, group-cast, in-combat, witch symbols
|
||||
* 2. Resource deduction — mana / zeal / blood
|
||||
* 3. TS test roll — pool depends on mode
|
||||
* 4. (success) damage roll — parses spell.damageBase
|
||||
* 5. Status effect auto-apply — from spell.statusEffects[]
|
||||
* 6. Trigger registration — from spell.triggers[]
|
||||
* 2. Resource deduction - mana / zeal / blood
|
||||
* 3. TS test roll - pool depends on mode
|
||||
* 4. (success) damage roll - parses spell.damageBase
|
||||
* 5. Status effect auto-apply - from spell.statusEffects[]
|
||||
* 6. Trigger registration - from spell.triggers[]
|
||||
*
|
||||
* Bypasses for GM workflows: opts.bypassSuperspellWarning, opts.bypassNonCombatBlock.
|
||||
*/
|
||||
|
||||
import { HbmTSRoll } from '../dice/ts-roll';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD } from '../constants';
|
||||
import { TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
|
||||
import { validateCast } from './spell-validation';
|
||||
import type { ValidationResult } from './spell-validation';
|
||||
import { rollSpellDamage } from './spell-damage';
|
||||
@@ -28,7 +28,7 @@ export interface CastableActor {
|
||||
type: 'character' | 'npc';
|
||||
system: {
|
||||
attributes: {
|
||||
magic: { value: number };
|
||||
magic: { value: number; actual?: number; dicePool?: number };
|
||||
soul: { value: number };
|
||||
mana: { value: number; max: number; maxPerSpell: number };
|
||||
zeal: { value: number; max: number };
|
||||
@@ -71,6 +71,7 @@ export interface CastOptions {
|
||||
zealSpent?: number;
|
||||
bloodSpent?: number;
|
||||
hekateMode?: 'sacred' | 'witch';
|
||||
castAsPrayer?: boolean;
|
||||
bypassSuperspellWarning?: boolean;
|
||||
bypassNonCombatBlock?: boolean;
|
||||
groupCasters?: string[];
|
||||
@@ -147,12 +148,16 @@ async function renderCastCard(
|
||||
triggersRegistered: number,
|
||||
): Promise<void> {
|
||||
const success = baseRoll?.ts?.isSuccess ?? false;
|
||||
const mode = spell.system.castingMode ?? 'standard';
|
||||
const mode = opts.castAsPrayer ? 'prayer' : (spell.system.castingMode ?? 'standard');
|
||||
|
||||
// Costs ledger
|
||||
const costs: Array<{ label: string; amount: number }> = [];
|
||||
if ((opts.manaSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.manaSpent'), amount: opts.manaSpent! });
|
||||
if ((opts.zealSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
|
||||
if (opts.castAsPrayer) {
|
||||
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: 1 });
|
||||
} else if ((opts.zealSpent ?? 0) > 0) {
|
||||
costs.push({ label: game.i18n.localize('HBM.spellCast.zealSpent'), amount: opts.zealSpent! });
|
||||
}
|
||||
if ((opts.bloodSpent ?? 0) > 0) costs.push({ label: game.i18n.localize('HBM.spellCast.bloodSpent'), amount: opts.bloodSpent! });
|
||||
|
||||
// Damage block
|
||||
@@ -220,10 +225,21 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
|
||||
const manaSpent = Math.max(baseCost, Math.floor(opts.manaSpent ?? baseCost));
|
||||
const a = actor.system.attributes;
|
||||
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaSpent });
|
||||
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - manaSpent };
|
||||
if (opts.castAsPrayer) {
|
||||
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
|
||||
}
|
||||
await actor.update(updates);
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${spell.name}`;
|
||||
const magicDice = typeof a.magic.dicePool === 'number'
|
||||
? a.magic.dicePool
|
||||
: getMagicPowerEntry(a.magic.actual ?? a.magic.value ?? 0).dicePool;
|
||||
const pool = opts.castAsPrayer
|
||||
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = opts.castAsPrayer
|
||||
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
|
||||
: `${spell.name}`;
|
||||
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const baseRoll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
@@ -234,7 +250,7 @@ async function castStandard(actor: CastableActor, spell: SpellLike, opts: CastOp
|
||||
await baseRoll.evaluate();
|
||||
await baseRoll.toMessage({ flavor, speaker: opts.speaker });
|
||||
|
||||
// Overcast — extra TS test if manaSpent exceeds maxPerSpell.
|
||||
// Overcast - extra TS test if manaSpent exceeds maxPerSpell.
|
||||
if (manaSpent > a.mana.maxPerSpell && a.mana.maxPerSpell > 0) {
|
||||
const excess = manaSpent - a.mana.maxPerSpell;
|
||||
const tThreshold = Math.min(6, Math.max(2, baseCost));
|
||||
@@ -259,7 +275,7 @@ async function castSacred(actor: CastableActor, spell: SpellLike, opts: CastOpti
|
||||
await actor.update({ 'system.attributes.zeal.value': a.zeal.value - zealCost });
|
||||
|
||||
const pool = a.soul.value + (actor.system.skills?.devotion?.value ?? 0);
|
||||
const flavor = `${spell.name} — ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
|
||||
const flavor = `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.sacred')}`;
|
||||
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
@@ -279,10 +295,21 @@ async function castWitch(actor: CastableActor, spell: SpellLike, opts: CastOptio
|
||||
|
||||
// Witch magic still has a mana cost.
|
||||
const baseCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - baseCost });
|
||||
const updates: Record<string, any> = { 'system.attributes.mana.value': a.mana.value - baseCost };
|
||||
if (opts.castAsPrayer) {
|
||||
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
|
||||
}
|
||||
await actor.update(updates);
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${spell.name} — ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
|
||||
const magicDice = typeof (a.magic as any).dicePool === 'number'
|
||||
? (a.magic as any).dicePool
|
||||
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
|
||||
const pool = opts.castAsPrayer
|
||||
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = opts.castAsPrayer
|
||||
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')} (${required}/${maxSymbols})`
|
||||
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.witch')} (${required}/${maxSymbols})`;
|
||||
const reqSuccesses = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
@@ -300,18 +327,31 @@ async function castBlood(actor: CastableActor, spell: SpellLike, opts: CastOptio
|
||||
const baseCost = Math.max(0, spell.system.bloodCost ?? 0);
|
||||
const bloodSpent = Math.max(baseCost, Math.floor(opts.bloodSpent ?? baseCost));
|
||||
const blood = a.blood;
|
||||
|
||||
const updates: Record<string, any> = {};
|
||||
if (blood) {
|
||||
await actor.update({ 'system.attributes.blood.value': Math.max(0, blood.value - bloodSpent) });
|
||||
updates['system.attributes.blood.value'] = Math.max(0, blood.value - bloodSpent);
|
||||
}
|
||||
|
||||
// Mana cost (some blood spells may also have one)
|
||||
const manaCost = Math.max(0, spell.system.manaCost ?? 0);
|
||||
if (manaCost > 0) {
|
||||
await actor.update({ 'system.attributes.mana.value': a.mana.value - manaCost });
|
||||
updates['system.attributes.mana.value'] = a.mana.value - manaCost;
|
||||
}
|
||||
if (opts.castAsPrayer) {
|
||||
updates['system.attributes.zeal.value'] = a.zeal.value - 1;
|
||||
}
|
||||
await actor.update(updates);
|
||||
|
||||
const pool = a.magic.actual + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = `${spell.name} — ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
|
||||
const magicDice = typeof (a.magic as any).dicePool === 'number'
|
||||
? (a.magic as any).dicePool
|
||||
: getMagicPowerEntry((a.magic as any).actual ?? a.magic.value ?? 0).dicePool;
|
||||
const pool = opts.castAsPrayer
|
||||
? a.soul.value + (actor.system.skills?.devotion?.value ?? 0)
|
||||
: magicDice + (actor.system.skills?.magicalAbilities?.value ?? 0);
|
||||
const flavor = opts.castAsPrayer
|
||||
? `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.prayer')}`
|
||||
: `${spell.name} - ${game.i18n.localize('HBM.spell.castingMode.blood')}`;
|
||||
const required = opts.requiredOverride ?? spell.system.difficulty.successes ?? TS_DEFAULT_REQUIRED;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool,
|
||||
|
||||
@@ -25,13 +25,13 @@ function resolveToken(token: string, ctx: DamageContext): number {
|
||||
const a = (ctx.actor as any).system?.attributes ?? {};
|
||||
const s = (ctx.actor as any).system?.skills ?? {};
|
||||
switch (token) {
|
||||
case 'body': return Number(a.body?.value ?? 0);
|
||||
case 'mind': return Number(a.mind?.value ?? 0);
|
||||
case 'soul': return Number(a.soul?.value ?? 0);
|
||||
case 'magic': return Number(a.magic?.value ?? 0);
|
||||
case 'body': return Number(a.body?.value ?? 0);
|
||||
case 'mind': return Number(a.mind?.value ?? 0);
|
||||
case 'soul': return Number(a.soul?.value ?? 0);
|
||||
case 'magic': return Number(a.magic?.value ?? 0);
|
||||
case 'magicalAbilities': return Number(s.magicalAbilities?.value ?? 0);
|
||||
case 'devotion': return Number(s.devotion?.value ?? 0);
|
||||
default: return 0;
|
||||
case 'devotion': return Number(s.devotion?.value ?? 0);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export function buildDamageFormula(spellDamageBase: string, ctx: DamageContext):
|
||||
const tokenRe = /([a-zA-Z]+)(?:\s*([\/*])\s*(\d+))?/g;
|
||||
formula = formula.replace(tokenRe, (match, name: string, op: string | undefined, num: string | undefined) => {
|
||||
if (!ATTR_TOKENS.has(name)) {
|
||||
// Leave alone (likely dice notation like 1d6 — no, dice has digits before)
|
||||
// Leave alone (likely dice notation like 1d6 - no, dice has digits before)
|
||||
return match;
|
||||
}
|
||||
let v = resolveToken(name, ctx);
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
* Hooks check the matching event and prompt/resolve the trigger.
|
||||
*
|
||||
* Supported events:
|
||||
* - killWithWeapon (e.g. Szkarłatny Sztylet — free spell after kill)
|
||||
* - targetCastsSpell (e.g. Klątwa Szkarłatu — opposed save → unconscious)
|
||||
* - killWithWeapon (e.g. Szkarłatny Sztylet - free spell after kill)
|
||||
* - targetCastsSpell (e.g. Klątwa Szkarłatu - opposed save → unconscious)
|
||||
* - damageTaken (reactive shields)
|
||||
* - turnStart (per-turn drains)
|
||||
*
|
||||
@@ -86,7 +86,7 @@ export async function fireTriggers(
|
||||
fired.push(t);
|
||||
// Post chat card so GM can resolve the effect manually if no auto-handler.
|
||||
await ChatMessage.create({
|
||||
content: `<strong>${actor.name ?? ''}</strong> — Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
|
||||
content: `<strong>${actor.name ?? ''}</strong> - Wyzwalacz: <em>${t.spellName}</em><br/>Efekt: ${t.effect}`,
|
||||
whisper: ChatMessage.getWhisperRecipients?.('GM') ?? [],
|
||||
});
|
||||
if (!t.oneShot) remaining.push(t);
|
||||
|
||||
@@ -41,18 +41,42 @@ function actorHasTalent(actor: CastableActor, talentId: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
|
||||
const id = disciplineId.toLowerCase();
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
if (!items) return false;
|
||||
for (const it of items) {
|
||||
if (it.type !== 'discipline') continue;
|
||||
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '').toLowerCase();
|
||||
if (d === id) return true;
|
||||
function matchDiscipline(disciplineId: string, targetId: string): boolean {
|
||||
const d1 = disciplineId.toLowerCase().trim();
|
||||
const d2 = targetId.toLowerCase().trim();
|
||||
if (d1 === d2) return true;
|
||||
|
||||
const mappings: Record<string, string[]> = {
|
||||
crimson: ['crimson', 'magia szkarłatu', 'magia szkarłatnego kultu', 'crimson magic', 'crimson cult magic'],
|
||||
abyssaspects: ['abyssaspects', 'magia aspektów', 'aspects', 'aspect magic', 'abyss - aspects', 'magia otchłani - aspekty'],
|
||||
abyssprimal: ['abyssprimal', 'magia otchłani', 'primal', 'eldritch', 'eldritch magic', 'abyss - primal', 'magia otchłani - pierwotna magia', 'pierwotna magia'],
|
||||
witch: ['witch', 'wiedźmia magia', 'witch magic', 'dzika wiedźmia magia', 'wildwitch'],
|
||||
blood: ['blood', 'magia krwi', 'blood magic'],
|
||||
};
|
||||
|
||||
let key1: string | null = null;
|
||||
let key2: string | null = null;
|
||||
|
||||
for (const [k, aliases] of Object.entries(mappings)) {
|
||||
if (k === d1 || aliases.includes(d1)) key1 = k;
|
||||
if (k === d2 || aliases.includes(d2)) key2 = k;
|
||||
}
|
||||
// Also accept characters whose details.discipline matches
|
||||
const detail = String((actor as any).system?.details?.discipline ?? '').toLowerCase();
|
||||
return detail === id;
|
||||
|
||||
if (key1 && key2 && key1 === key2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function actorHasDiscipline(actor: CastableActor, disciplineId: string): boolean {
|
||||
const items = (actor as any).items as Iterable<any> | undefined;
|
||||
if (items) {
|
||||
for (const it of items) {
|
||||
if (it.type !== 'discipline') continue;
|
||||
const d = String(it.system?.disciplineId ?? it.system?.id ?? it.name ?? '');
|
||||
if (matchDiscipline(d, disciplineId)) return true;
|
||||
}
|
||||
}
|
||||
const detail = String((actor as any).system?.details?.discipline ?? '');
|
||||
return matchDiscipline(detail, disciplineId);
|
||||
}
|
||||
|
||||
function actorIsInCombat(actor: CastableActor): boolean {
|
||||
@@ -79,7 +103,11 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
|
||||
errors.push({ code: 'no-mana', message: 'Not enough mana', i18nKey: 'HBM.spellCast.notEnoughMana' });
|
||||
}
|
||||
}
|
||||
if (mode === 'sacred' && opts.hekateMode !== 'witch') {
|
||||
if (opts.castAsPrayer) {
|
||||
if (1 > Number(a.zeal?.value ?? 0)) {
|
||||
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
|
||||
}
|
||||
} else if (mode === 'sacred' && opts.hekateMode !== 'witch') {
|
||||
const zealNeeded = Math.max(1, opts.zealSpent ?? 1);
|
||||
if (zealNeeded > Number(a.zeal?.value ?? 0)) {
|
||||
errors.push({ code: 'no-zeal', message: 'Not enough zeal', i18nKey: 'HBM.spellCast.notEnoughZeal' });
|
||||
@@ -122,7 +150,23 @@ export function validateCast(actor: CastableActor, spell: SpellLike, opts: CastO
|
||||
// Discipline gate (any-of)
|
||||
const discReq: string[] = Array.isArray(sys.requirements?.discipline) ? sys.requirements.discipline : [];
|
||||
if (discReq.length > 0) {
|
||||
const ok = discReq.some((d) => actorHasDiscipline(actor, d));
|
||||
const hasEldritchMagic = actorHasDiscipline(actor, 'abyssPrimal');
|
||||
const hasAspectMagic = actorHasDiscipline(actor, 'abyssAspects');
|
||||
const hasWitchMagic = actorHasDiscipline(actor, 'witch');
|
||||
|
||||
// Is the spell an Eldritch Spell?
|
||||
const isEldritchSpell = discReq.some((d) => matchDiscipline(d, 'abyssPrimal')) ||
|
||||
matchDiscipline(sys.school ?? '', 'abyssPrimal') ||
|
||||
matchDiscipline(sys.discipline ?? '', 'abyssPrimal');
|
||||
|
||||
let bypassDisc = false;
|
||||
if (hasEldritchMagic) {
|
||||
bypassDisc = true; // Eldritch Magic allows casting ANY spells
|
||||
} else if ((hasWitchMagic || hasAspectMagic) && !isEldritchSpell) {
|
||||
bypassDisc = true; // Witch/Aspect Magic allows casting any spells EXCEPT Eldritch spells
|
||||
}
|
||||
|
||||
const ok = bypassDisc || discReq.some((d) => actorHasDiscipline(actor, d));
|
||||
if (!ok) {
|
||||
const translatedDisciplines = discReq.map(d => {
|
||||
return game.i18n.has(`HBM.spellSchool.${d}`) ? game.i18n.localize(`HBM.spellSchool.${d}`) : d;
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Trade logic — founding companies, transactions, smuggling.
|
||||
* Trade logic - founding companies, transactions, smuggling.
|
||||
* Source: Złoto Stal i Magia, Rozdział IX.
|
||||
*
|
||||
* The book's exact difficulty formulas depend on commodity, route, and party
|
||||
@@ -45,7 +45,7 @@ function rollTrade(actor: CastableActor, label: string, params: TradeTestParams)
|
||||
});
|
||||
}
|
||||
|
||||
/** Founding a trading company — usually a single TS test plus capital. */
|
||||
/** Founding a trading company - usually a single TS test plus capital. */
|
||||
export function foundCompany(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
return rollTrade(actor, 'Założenie Firmy Handlowej', params);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function transaction(actor: CastableActor, params: TradeTestParams = {}):
|
||||
return rollTrade(actor, 'Transakcja', params);
|
||||
}
|
||||
|
||||
/** Smuggling — illegal transaction; failure should fire `hbm.smugglingFailed`. */
|
||||
/** Smuggling - illegal transaction; failure should fire `hbm.smugglingFailed`. */
|
||||
export async function smuggling(actor: CastableActor, params: TradeTestParams = {}): Promise<HbmTSRoll> {
|
||||
const roll = await rollTrade(actor, 'Przemyt', params);
|
||||
if (!roll.ts?.isSuccess) {
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function runPendingMigrations(): Promise<void> {
|
||||
await step.run();
|
||||
} catch (err) {
|
||||
console.error(`${SYSTEM_ID} | Migration ${step.version} failed`, err);
|
||||
ui.notifications?.error(`HbM migration ${step.version} failed — see console.`);
|
||||
ui.notifications?.error(`HbM migration ${step.version} failed - see console.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Character sheet — Foundry v13 ApplicationV2 + Handlebars.
|
||||
* Character sheet - Foundry v13 ApplicationV2 + Handlebars.
|
||||
* Features: tabs, roll-dialogs, drag-and-drop item creation.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,7 @@ 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';
|
||||
import { ATTRIBUTES, AttributeKey, SKILL_KEYS, getMagicPowerEntry } from '../constants';
|
||||
|
||||
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
|
||||
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
|
||||
@@ -25,29 +25,29 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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,
|
||||
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,
|
||||
closeOnSubmit: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,16 +55,16 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
main: { template: 'systems/hbm-rpg-v3/templates/actor/character.hbs' },
|
||||
};
|
||||
|
||||
/** Active tab per group — persists across re-renders. */
|
||||
/** 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 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');
|
||||
const talents = actor.items.filter((it: any) => it.type === 'talent');
|
||||
|
||||
// Group spells by school for sheet display
|
||||
const spellsBySchool = new Map<string, any[]>();
|
||||
@@ -125,7 +125,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
'alchemyTransmutation', 'alchemyBrewing', 'botany',
|
||||
'elementsAir', 'elementsWater', 'elementsFire', 'elementsEarth',
|
||||
'artifice', 'golemancy', 'runes', 'manaSourceMage',
|
||||
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood'
|
||||
'illusion', 'sacred', 'sacredExorcism', 'witch', 'necromancy', 'blood',
|
||||
'crimson', 'abyssAspects', 'abyssPrimal', 'eldritch'
|
||||
].map(key => ({
|
||||
key,
|
||||
label: game.i18n.localize(`HBM.spellSchool.${key}`)
|
||||
@@ -136,18 +137,18 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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)
|
||||
// Localized talents list no longer needed (dropdown removed - drag from compendium)
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
system: actor.system,
|
||||
system: actor.system,
|
||||
attributes,
|
||||
skills,
|
||||
races,
|
||||
disciplines,
|
||||
tabGroups: this.tabGroups,
|
||||
attributeKeys: ATTRIBUTES,
|
||||
skillKeys: SKILL_KEYS,
|
||||
tabGroups: this.tabGroups,
|
||||
attributeKeys: ATTRIBUTES,
|
||||
skillKeys: SKILL_KEYS,
|
||||
spells,
|
||||
spellSchoolGroups,
|
||||
hasBloodMagic,
|
||||
@@ -159,7 +160,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
abilities,
|
||||
talents,
|
||||
actorEffects,
|
||||
tabs: this._prepareTabs(),
|
||||
tabs: this._prepareTabs(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,8 +169,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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,
|
||||
label: `HBM.ui.tabs.${id}`,
|
||||
active: active === id,
|
||||
cssClass: active === id ? 'active' : '',
|
||||
}));
|
||||
}
|
||||
@@ -211,7 +212,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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.
|
||||
// Auto-link Race / Class items by UUID - do NOT create embedded copies.
|
||||
if (item.type === 'race') {
|
||||
await actor.update({ 'system.details.raceId': uuid });
|
||||
return;
|
||||
@@ -258,8 +259,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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();
|
||||
callback: (_ev: Event, _btn: any, dialog: any) => {
|
||||
const val = (dialog.element.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
|
||||
specified = val || null;
|
||||
},
|
||||
},
|
||||
@@ -290,17 +291,22 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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 skill = actor.system.skills?.[skillKey];
|
||||
const attrKey = (skill?.defaultAttribute ?? 'body') as AttributeKey;
|
||||
const pool = (actor.system.attributes[attrKey]?.value ?? 0) + (skill?.value ?? 0);
|
||||
const attr = actor.system.attributes[attrKey];
|
||||
const attrVal = (attrKey === 'magic' && attr)
|
||||
? (attr.dicePool ?? getMagicPowerEntry(attr.actual ?? attr.value ?? 0).dicePool)
|
||||
: (attr?.value ?? 0);
|
||||
const pool = attrVal + (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,
|
||||
required: params.required,
|
||||
modifier: params.modifier,
|
||||
flavor: params.flavor,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,13 +315,17 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
if (!attr) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
|
||||
const pool = actor.system.attributes[attr]?.value ?? 0;
|
||||
const a = actor.system.attributes[attr];
|
||||
const pool = (attr === 'magic' && a)
|
||||
? (a.dicePool ?? getMagicPowerEntry(a.actual ?? a.value ?? 0).dicePool)
|
||||
: (a?.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,
|
||||
required: params.required,
|
||||
modifier: params.modifier,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -323,7 +333,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const spell = actor.items.get(itemId);
|
||||
const spell = actor.items.get(itemId);
|
||||
if (!spell || spell.type !== 'spell') return;
|
||||
|
||||
const opts = await askCastOptions(spell, actor);
|
||||
@@ -337,7 +347,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
const itemId = target.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
const actor = (this as unknown as { actor: any }).actor;
|
||||
const item = actor.items.get(itemId);
|
||||
const item = actor.items.get(itemId);
|
||||
if (!item) return;
|
||||
await item.delete();
|
||||
}
|
||||
@@ -367,7 +377,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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`,
|
||||
content: `<strong>${actor.name}</strong> - Chwila Wytchnienia: +${r.hpRestored} ŻYW · +${r.manaRestored} MN`,
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
});
|
||||
}
|
||||
@@ -376,7 +386,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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` : ''}`,
|
||||
content: `<strong>${actor.name}</strong> - Krótki Odpoczynek: +${r.hpRestored} ŻYW · +${r.manaRestored} MN${r.toleranceRecovered ? `, −${r.toleranceRecovered} tolerancja` : ''}`,
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
});
|
||||
}
|
||||
@@ -385,7 +395,7 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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`,
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
@@ -500,14 +510,14 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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' },
|
||||
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 =>
|
||||
@@ -562,8 +572,8 @@ export class CharacterSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
</div>
|
||||
<script>window._hbmRates=${ratesJson};</script>`;
|
||||
|
||||
await (foundry.applications.api as any).DialogV2.inform({
|
||||
title: game.i18n.localize('HBM.ui.moneyConverterTitle'),
|
||||
await (foundry.applications.api as any).DialogV2.prompt({
|
||||
window: { title: game.i18n.localize('HBM.ui.moneyConverterTitle') },
|
||||
content,
|
||||
rejectClose: false,
|
||||
});
|
||||
|
||||
+69
-27
@@ -1,11 +1,11 @@
|
||||
import { rollAttribute, rollInitiative } from '../dice/macros';
|
||||
import { rollSkill, 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';
|
||||
import { ATTRIBUTES, AttributeKey, SKILL_KEYS, TS_DEFAULT_REQUIRED, TS_DEFAULT_THRESHOLD, getMagicPowerEntry } from '../constants';
|
||||
|
||||
const { ActorSheetV2 } = foundry.applications.sheets as unknown as {
|
||||
ActorSheetV2: typeof foundry.applications.sheets.ActorSheetV2;
|
||||
@@ -20,21 +20,22 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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,
|
||||
rollSkill: NpcSheet._onRollSkill,
|
||||
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 },
|
||||
};
|
||||
@@ -73,7 +74,13 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
};
|
||||
});
|
||||
|
||||
return { ...ctx, system: actor.system, attributes, spells, actorEffects };
|
||||
const skills = SKILL_KEYS.map(key => ({
|
||||
key,
|
||||
value: sys.skills?.[key]?.value ?? 0,
|
||||
label: game.i18n.localize(`HBM.skills.${key}`)
|
||||
}));
|
||||
|
||||
return { ...ctx, system: actor.system, attributes, skills, spells, actorEffects };
|
||||
}
|
||||
|
||||
override _onRender(context: unknown, options: unknown) {
|
||||
@@ -110,7 +117,7 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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,
|
||||
@@ -124,8 +131,8 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
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();
|
||||
callback: (_ev: Event, _btn: any, dialog: any) => {
|
||||
const val = (dialog.element.querySelector('#hbm-talent-param-input') as HTMLInputElement)?.value?.trim();
|
||||
specified = val || null;
|
||||
},
|
||||
},
|
||||
@@ -148,17 +155,52 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
await actor.createEmbeddedDocuments('Item', [item.toObject()]);
|
||||
}
|
||||
|
||||
static async _onRollSkill(this: NpcSheet, _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 attr = actor.system.attributes[attrKey];
|
||||
const attrVal = (attrKey === 'magic' && attr)
|
||||
? ((attr as any).dicePool ?? getMagicPowerEntry((attr as any).actual ?? attr.value ?? 0).dicePool)
|
||||
: (attr?.value ?? 0);
|
||||
const pool = attrVal + (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,
|
||||
modifier: params.modifier,
|
||||
flavor: params.flavor,
|
||||
});
|
||||
}
|
||||
|
||||
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 a = actor.system.attributes[attr];
|
||||
const pool = (attr === 'magic' && a)
|
||||
? ((a as any).dicePool ?? getMagicPowerEntry((a as any).actual ?? a.value ?? 0).dicePool)
|
||||
: (a?.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 });
|
||||
await rollAttribute(actor, attr, {
|
||||
threshold: params.threshold,
|
||||
required: params.required,
|
||||
modifier: params.modifier,
|
||||
});
|
||||
}
|
||||
|
||||
static async _onRollInitiative(this: NpcSheet, _event: PointerEvent, _target: HTMLElement) {
|
||||
@@ -177,7 +219,7 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
flavor: attack.name,
|
||||
});
|
||||
if (!params) return;
|
||||
const flavor = `${attack.name}${attack.damage ? ` — ${attack.damage}` : ''}`;
|
||||
const flavor = `${attack.name}${attack.damage ? ` - ${attack.damage}` : ''}`;
|
||||
const roll = HbmTSRoll.fromParams({
|
||||
pool: params.pool,
|
||||
threshold: params.threshold ?? TS_DEFAULT_THRESHOLD,
|
||||
@@ -326,7 +368,7 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback to latest rate
|
||||
try {
|
||||
const response = await fetch(`https://api.nbp.pl/api/exchangerates/rates/a/${currency}/?format=json`);
|
||||
@@ -399,8 +441,8 @@ export class NpcSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
await (foundry.applications.api as any).DialogV2.inform({
|
||||
title: game.i18n.localize('HBM.ui.moneyConverterTitle'),
|
||||
await (foundry.applications.api as any).DialogV2.prompt({
|
||||
window: { title: game.i18n.localize('HBM.ui.moneyConverterTitle') },
|
||||
content: content,
|
||||
rejectClose: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user