feat: implement core Foundry VTT system framework, including actor/item data models, spellcasting logic, dice mechanics, and automation scripts

This commit is contained in:
Octoturge
2026-06-14 10:12:47 +02:00
parent fb42c6e8cc
commit 624fe2ee1a
70 changed files with 1645 additions and 1146 deletions
+36 -35
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+1 -1
View File
@@ -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.
*/
+2 -2
View File
@@ -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(''),
+7
View File
@@ -76,6 +76,12 @@ export async function askCastOptions(spell: SpellForDialog, actor: ActorForDialo
<input type="number" name="bloodSpent" value="${baseBlood}" min="${baseBlood}" max="${max}"/>
</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();
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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
View File
@@ -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({
+6
View File
@@ -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')} (110)</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
View File
@@ -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
View File
@@ -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(''));
});
+8 -8
View File
@@ -1,18 +1,18 @@
/**
* Magia Otchłani Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII).
* Magia Otchłani - Abyss Magic logic (Klątwa Otchłani Ch. IIIVIII).
*
* The Abyss splits into two disciplines:
* - Magia Aspektów ("aspects") controlled, low-risk, predictable
* - Pierwotna Magia ("primal") chaotic, high-power, requires `Dary Otchłani` rolls
* - 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 -1
View File
@@ -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.
+9 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+7 -7
View File
@@ -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);
+3 -3
View File
@@ -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);
+57 -13
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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;
}
}
+69 -59
View File
@@ -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
View File
@@ -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,
});