feat: implement core Foundry VTT system framework, including actor/item data models, spellcasting logic, dice mechanics, and automation scripts
This commit is contained in:
+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(''),
|
||||
|
||||
Reference in New Issue
Block a user