Initial commit
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeHtmlField,
|
||||
makeValueMaxField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { ATTRIBUTES, SKILL_KEYS, SKILLS, AttributeKey, getMagicPowerEntry } from '../constants';
|
||||
|
||||
/**
|
||||
* Player Character data model.
|
||||
*
|
||||
* Derived stats (computed in prepareDerivedData):
|
||||
* - attributes.health.max = 3 * (body + mind + soul)
|
||||
* - attributes.zeal.max = ceil(soul / 2)
|
||||
* - attributes.initiative = mind + skills.reflex.value + skills.perception.value
|
||||
* - attributes.mana.max / .maxPerSpell / magic.dicePool — from MAGIC_POWER_TABLE
|
||||
*/
|
||||
export class CharacterData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
|
||||
const attributeSchema = () => new f.SchemaField({
|
||||
value: makeIntField(1, { min: 1, max: 8 }),
|
||||
});
|
||||
|
||||
const skillEntries: Record<string, foundry.data.fields.DataField.Any> = {};
|
||||
for (const key of SKILL_KEYS) {
|
||||
skillEntries[key] = new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 8 }),
|
||||
defaultAttribute: makeStringField(SKILLS[key] as AttributeKey, {
|
||||
blank: false,
|
||||
choices: ATTRIBUTES as unknown as readonly string[],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const scheduleRows: Record<string, foundry.data.fields.DataField.Any> = {};
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
scheduleRows[`row${i}`] = new f.SchemaField({
|
||||
hours: makeStringField('', { blank: true }),
|
||||
mon: makeStringField('', { blank: true }),
|
||||
tue: makeStringField('', { blank: true }),
|
||||
wed: makeStringField('', { blank: true }),
|
||||
thu: makeStringField('', { blank: true }),
|
||||
fri: makeStringField('', { blank: true }),
|
||||
sat: makeStringField('', { blank: true }),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: new f.SchemaField({
|
||||
body: attributeSchema(),
|
||||
mind: attributeSchema(),
|
||||
soul: attributeSchema(),
|
||||
magic: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 10 }),
|
||||
actual: makeIntField(0, { min: 0, max: 10 }), // derived
|
||||
dicePool: makeIntField(0, { min: 0 }), // derived
|
||||
}),
|
||||
health: makeValueMaxField(0, 0),
|
||||
mana: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
maxPerSpell: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
zeal: makeValueMaxField(0, 0),
|
||||
// Blood Pool (Arcanum Sanguinis Ch. III) — experimental until full spell list ships.
|
||||
// Placeholder formula: max = body + soul.
|
||||
blood: makeValueMaxField(0, 0),
|
||||
// Elixir tolerance (Podręcznik Gry — brewing). Each potion consumed adds 1;
|
||||
// recovers 1 per long rest. Soft cap = body + 1; over cap → poisoning.
|
||||
elixirTolerance: makeIntField(0, { min: 0 }),
|
||||
// Insanity points (Klątwa Otchłani VIII). Each Abyss exposure may add 1; rolls
|
||||
// mental-condition table when reaching thresholds.
|
||||
insanity: makeIntField(0, { min: 0 }),
|
||||
magicalArmor: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
runicCounter: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
magicalShield: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
physicalArmor: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }), // derived
|
||||
max: makeIntField(0, { min: 0 }), // derived: base DR from equipment
|
||||
bonus: makeIntField(0, { min: 0 }), // editable bonus (flat +DR)
|
||||
condition: makeIntField(0, { min: 0 }), // derived: sum of armor conditions
|
||||
conditionMax: makeIntField(0, { min: 0 }), // derived: sum of armor conditionMax
|
||||
}),
|
||||
initiative: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
skills: new f.SchemaField(skillEntries),
|
||||
details: new f.SchemaField({
|
||||
race: makeStringField('', { blank: true }),
|
||||
raceId: makeStringField('', { blank: true }),
|
||||
classIds: makeArrayField(new f.StringField({ blank: true })),
|
||||
year: makeIntField(1, { min: 1, max: 4 }),
|
||||
discipline: makeStringField('', { blank: true }),
|
||||
title: makeStringField('', { blank: true }),
|
||||
biography: makeHtmlField(''),
|
||||
customEquipment: makeHtmlField(''), // legacy textarea — kept for migration
|
||||
customItems: makeArrayField(new f.SchemaField({
|
||||
name: makeStringField('', { blank: true }),
|
||||
qty: makeIntField(1, { min: 0 }),
|
||||
note: makeStringField('', { blank: true }),
|
||||
})),
|
||||
experience: makeIntField(0, { min: 0 }),
|
||||
money: makeIntField(0, { min: 0 }),
|
||||
classSchedule: makeStringField('', { blank: true }),
|
||||
schedule: new f.SchemaField(scheduleRows),
|
||||
personalDetails: makeHtmlField(''),
|
||||
startingYear: makeIntField(2026, { min: 1900 }),
|
||||
currentYear: makeIntField(2026, { min: 1900 }),
|
||||
}),
|
||||
advancement: new f.SchemaField({
|
||||
attributePointsAvailable: makeIntField(0, { min: 0 }),
|
||||
skillPointsAvailable: makeIntField(0, { min: 0 }),
|
||||
freeTalentsAvailable: makeIntField(0, { min: 0 }), // derived
|
||||
bonusAttributePoints: makeIntField(0, { min: 0 }),
|
||||
bonusSkillPoints: makeIntField(0, { min: 0 }),
|
||||
bonusFreeTalents: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** prepareDerivedData runs after source data loads; compute formulas here. */
|
||||
override prepareDerivedData() {
|
||||
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
|
||||
const sys = this as unknown as {
|
||||
attributes: {
|
||||
body: { value: number };
|
||||
mind: { value: number };
|
||||
soul: { value: number };
|
||||
magic: { value: number; actual: number; dicePool: number };
|
||||
health: { value: number; max: number };
|
||||
mana: { value: number; max: number; maxPerSpell: number };
|
||||
zeal: { value: number; max: number };
|
||||
blood: { value: number; max: number };
|
||||
elixirTolerance: number;
|
||||
insanity: number;
|
||||
magicalArmor: { value: number; max: number; runicCounter: number };
|
||||
magicalShield: { value: number };
|
||||
physicalArmor: { value: number; max: number; condition: number; conditionMax: number };
|
||||
initiative: number;
|
||||
};
|
||||
skills: Record<string, { value: number; defaultAttribute: AttributeKey }>;
|
||||
details: { raceId?: string; classIds?: string[] };
|
||||
advancement: {
|
||||
attributePointsAvailable: number;
|
||||
skillPointsAvailable: number;
|
||||
freeTalentsAvailable: number;
|
||||
bonusAttributePoints: number;
|
||||
bonusSkillPoints: number;
|
||||
bonusFreeTalents: number;
|
||||
};
|
||||
};
|
||||
|
||||
const a = sys.attributes;
|
||||
|
||||
a.health.max = 3 * (a.body.value + a.mind.value + a.soul.value);
|
||||
if (a.health.value > a.health.max) a.health.value = a.health.max;
|
||||
|
||||
a.zeal.max = Math.ceil(a.soul.value / 2);
|
||||
if (a.zeal.value > a.zeal.max) a.zeal.value = a.zeal.max;
|
||||
|
||||
// Compute physical armor DR from equipped gear + talents
|
||||
const items = (self.parent?.items ? Array.from(self.parent.items as Iterable<any>) : []) as any[];
|
||||
|
||||
// Calculate actual magic circle based on talents
|
||||
let magicMod = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'talent') {
|
||||
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
|
||||
const name = it.name?.toLowerCase() || '';
|
||||
if (slug === 'jednosc-z-magia' || slug === 'unity-with-magic' || name === 'jedność z magią' || name === 'unity with magic') {
|
||||
magicMod -= 1;
|
||||
} else if (slug === 'odpornosc-na-magie' || slug === 'magic-resistance' || name === 'odporność na magię' || name === 'magic resistance') {
|
||||
magicMod += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const actualMagic = a.magic.value + magicMod;
|
||||
a.magic.actual = Math.max(0, Math.min(10, actualMagic));
|
||||
|
||||
const mp = getMagicPowerEntry(a.magic.actual);
|
||||
a.magic.dicePool = mp.dicePool;
|
||||
a.mana.max = mp.manaPerRound;
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
// Count battle-readiness stacks for initiative bonus (+2 per copy)
|
||||
let initiativeBonus = 0;
|
||||
// Count Man of Iron copies for passive armor (only applies when no physical armor is worn)
|
||||
let manOfIronStacks = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'talent') {
|
||||
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
|
||||
const name = it.name?.toLowerCase() || '';
|
||||
if (
|
||||
slug === 'battle-readiness' || slug === 'gotowosc-do-walki' ||
|
||||
name === 'gotowo\u015b\u0107 do walki' || name === 'battle readiness'
|
||||
) {
|
||||
initiativeBonus += 2;
|
||||
}
|
||||
if (
|
||||
slug === 'man-of-iron' || slug === 'czlowiek-z-zelaza' ||
|
||||
name === 'cz\u0142owiek z \u017celaza' || name === 'man of iron'
|
||||
) {
|
||||
manOfIronStacks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.initiative =
|
||||
a.mind.value +
|
||||
(sys.skills.reflex?.value ?? 0) +
|
||||
(sys.skills.perception?.value ?? 0) +
|
||||
initiativeBonus;
|
||||
|
||||
// Clamp magical armor / shield
|
||||
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
|
||||
|
||||
let armorDR = 0;
|
||||
let armorPieces = 0;
|
||||
let totalArmorCondition = 0;
|
||||
let totalArmorConditionMax = 0;
|
||||
// Compute effective DR per piece using condition scaling
|
||||
let effectiveDR = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'gear') {
|
||||
const g = it.system;
|
||||
if (!g) continue;
|
||||
if (g.equipped && g.category === 'armor') {
|
||||
const baseDR = Number(g.armor?.damageReduction ?? 0);
|
||||
const cond = Number(g.armor?.condition ?? 0);
|
||||
const condMax = Number(g.armor?.conditionMax ?? 1);
|
||||
armorDR += baseDR;
|
||||
armorPieces += 1;
|
||||
totalArmorCondition += cond;
|
||||
totalArmorConditionMax += condMax;
|
||||
// Condition-scaled DR: broken armor (cond=0) gives 0 DR
|
||||
if (condMax > 0) {
|
||||
effectiveDR += Math.floor(baseDR * (cond / condMax));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// physicalArmorBonus is stored/editable; add it to effective DR
|
||||
const paBonus = (sys as any).attributes?.physicalArmor?.bonus ?? 0;
|
||||
a.physicalArmor.max = Math.max(0, armorDR + paBonus);
|
||||
a.physicalArmor.value = Math.max(0, effectiveDR + paBonus);
|
||||
|
||||
if (armorPieces > 0) {
|
||||
a.physicalArmor.condition = totalArmorCondition;
|
||||
a.physicalArmor.conditionMax = totalArmorConditionMax;
|
||||
}
|
||||
|
||||
// Man of Iron: +1 passive armor per stack, only when wearing no physical armor
|
||||
if (manOfIronStacks > 0 && armorPieces === 0) {
|
||||
a.physicalArmor.max = Math.max(a.physicalArmor.max, manOfIronStacks);
|
||||
a.physicalArmor.value = Math.max(a.physicalArmor.value, manOfIronStacks);
|
||||
}
|
||||
|
||||
// Blood Pool (placeholder formula): max = body + soul.
|
||||
a.blood.max = a.body.value + a.soul.value;
|
||||
if (a.blood.value > a.blood.max) a.blood.value = a.blood.max;
|
||||
|
||||
// Advancement points (UUID-based race/class lookup; falls back to 0 if not set)
|
||||
const raceItem = items.find((i) => i.type === 'race');
|
||||
const classItems = items.filter((i) => i.type === 'class');
|
||||
const startingAttr = Number(raceItem?.system?.attributePoints ?? 0);
|
||||
const startingSkill = Number(raceItem?.system?.skillPoints ?? 0);
|
||||
const startingFreeTalents = raceItem?.system?.freeTalents?.length ?? 0;
|
||||
const classAttr = classItems.reduce((sum, c) => sum + Number(c.system?.attributePoints ?? 0), 0);
|
||||
const classSkill = classItems.reduce((sum, c) => sum + Number(c.system?.skillPoints ?? 0), 0);
|
||||
const spentAttr = (a.body.value - 1) + (a.mind.value - 1) + (a.soul.value - 1) + a.magic.value;
|
||||
const spentSkill = Object.values(sys.skills).reduce((sum, s) => sum + (s.value ?? 0), 0);
|
||||
const spentFreeTalents = items.filter((i) => i.type === 'talent').length;
|
||||
sys.advancement.attributePointsAvailable = Math.max(0, startingAttr + classAttr + (sys.advancement.bonusAttributePoints ?? 0) - spentAttr);
|
||||
sys.advancement.skillPointsAvailable = Math.max(0, startingSkill + classSkill + (sys.advancement.bonusSkillPoints ?? 0) - spentSkill);
|
||||
sys.advancement.freeTalentsAvailable = Math.max(0, startingFreeTalents + (sys.advancement.bonusFreeTalents ?? 0) - spentFreeTalents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration shim: map legacy `attributes.armor` (single number) to new
|
||||
* `physicalArmor.value` so existing characters do not crash on load.
|
||||
*/
|
||||
_initializeSource(data: any, options: any): any {
|
||||
const initialized = super._initializeSource(data, options);
|
||||
if (initialized?.attributes && typeof initialized.attributes.armor === 'number') {
|
||||
const legacy = initialized.attributes.armor;
|
||||
if (!initialized.attributes.physicalArmor) {
|
||||
initialized.attributes.physicalArmor = { value: legacy, condition: 1, conditionMax: 1 };
|
||||
}
|
||||
delete initialized.attributes.armor;
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeHtmlField,
|
||||
makeValueMaxField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { getMagicPowerEntry } from '../constants';
|
||||
|
||||
/**
|
||||
* NPC / Creature data model.
|
||||
* Looser than character (no derived health formula — set directly).
|
||||
*/
|
||||
export class NpcData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
|
||||
const attackSchema = () => new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
damage: makeStringField(''),
|
||||
damageType: makeStringField('physical'),
|
||||
bonus: makeIntField(0),
|
||||
});
|
||||
|
||||
const namedEntry = () => new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
});
|
||||
|
||||
return {
|
||||
attributes: new f.SchemaField({
|
||||
body: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
mind: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
soul: new f.SchemaField({ value: makeIntField(1, { min: 1, max: 12 }) }),
|
||||
magic: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0, max: 10 }),
|
||||
actual: makeIntField(0, { min: 0, max: 10 }), // derived
|
||||
}),
|
||||
mana: new f.SchemaField({
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
max: makeIntField(0, { min: 0 }),
|
||||
maxPerSpell: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
zeal: makeValueMaxField(0, 0),
|
||||
blood: makeValueMaxField(0, 0),
|
||||
health: makeValueMaxField(1, 1),
|
||||
magicalArmor: makeValueMaxField(0, 0),
|
||||
magicalShield: makeValueMaxField(0, 0),
|
||||
physicalArmor: new f.SchemaField({ value: makeIntField(0, { min: 0 }) }),
|
||||
speed: makeStringField('5m'),
|
||||
}),
|
||||
details: new f.SchemaField({
|
||||
type: makeStringField(''),
|
||||
size: makeStringField('medium'),
|
||||
alignment: makeStringField(''),
|
||||
xp: makeIntField(0, { min: 0 }),
|
||||
senses: makeStringField(''),
|
||||
languages: makeStringField(''),
|
||||
customEquipment: makeHtmlField(''),
|
||||
money: makeIntField(0, { min: 0 }),
|
||||
startingYear: makeIntField(2026, { min: 1900 }),
|
||||
currentYear: makeIntField(2026, { min: 1900 }),
|
||||
}),
|
||||
combat: new f.SchemaField({
|
||||
attacks: makeArrayField(attackSchema()),
|
||||
specialAbilities: makeArrayField(namedEntry()),
|
||||
reactions: makeArrayField(namedEntry()),
|
||||
legendaryActions: makeArrayField(namedEntry()),
|
||||
}),
|
||||
defenses: new f.SchemaField({
|
||||
savingThrows: makeArrayField(new f.StringField({ blank: false })),
|
||||
damageResistances: makeArrayField(new f.StringField({ blank: false })),
|
||||
damageImmunities: makeArrayField(new f.StringField({ blank: false })),
|
||||
conditionImmunities: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
lore: new f.SchemaField({
|
||||
description: makeHtmlField(''),
|
||||
habitat: makeStringField(''),
|
||||
behavior: makeStringField(''),
|
||||
history: makeStringField(''),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
override prepareDerivedData() {
|
||||
const self = this as unknown as { parent?: { items?: Iterable<any> } } & Record<string, any>;
|
||||
const sys = this as unknown as {
|
||||
attributes: {
|
||||
magic: { value: number; actual: number };
|
||||
mana: { value: number; max: number; maxPerSpell: number };
|
||||
health: { value: number; max: number };
|
||||
magicalArmor: { value: number; max: number };
|
||||
magicalShield: { value: number; max: number };
|
||||
};
|
||||
};
|
||||
const a = sys.attributes;
|
||||
|
||||
// Compute magic.actual based on NPC talents
|
||||
const items = (self.parent?.items ? Array.from(self.parent.items as Iterable<any>) : []) as any[];
|
||||
let magicMod = 0;
|
||||
for (const it of items) {
|
||||
if (it.type === 'talent') {
|
||||
const slug = it.flags?.['hbm-rpg-v3']?.slug || '';
|
||||
const name = it.name?.toLowerCase() || '';
|
||||
if (slug === 'jednosc-z-magia' || slug === 'unity-with-magic' || name === 'jedność z magią' || name === 'unity with magic') {
|
||||
magicMod -= 1;
|
||||
} else if (slug === 'odpornosc-na-magie' || slug === 'magic-resistance' || name === 'odporność na magię' || name === 'magic resistance') {
|
||||
magicMod += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
const actualMagic = a.magic.value + magicMod;
|
||||
a.magic.actual = Math.max(0, Math.min(10, actualMagic));
|
||||
|
||||
// Derive NPC mana from MAGIC_POWER_TABLE
|
||||
const mp = getMagicPowerEntry(a.magic.actual);
|
||||
a.mana.max = mp.manaPerRound;
|
||||
a.mana.maxPerSpell = mp.maxPerSpell;
|
||||
if (a.mana.value > a.mana.max) a.mana.value = a.mana.max;
|
||||
|
||||
if (a.health.value > a.health.max) a.health.value = a.health.max;
|
||||
if (a.magicalArmor.value > a.magicalArmor.max) a.magicalArmor.value = a.magicalArmor.max;
|
||||
if (a.magicalShield.value > a.magicalShield.max) a.magicalShield.value = a.magicalShield.max;
|
||||
}
|
||||
|
||||
/** Migrate legacy `attributes.armor` → `physicalArmor.value` */
|
||||
_initializeSource(data: any, options: any): any {
|
||||
const initialized = super._initializeSource(data, options);
|
||||
if (initialized?.attributes && typeof initialized.attributes.armor === 'number') {
|
||||
const legacy = initialized.attributes.armor;
|
||||
if (!initialized.attributes.physicalArmor) {
|
||||
initialized.attributes.physicalArmor = { value: legacy };
|
||||
}
|
||||
delete initialized.attributes.armor;
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Shared DataModel field helpers. Foundry's data model field constructors
|
||||
* live under foundry.data.fields at runtime; we re-export local aliases
|
||||
* to keep the data model files terse.
|
||||
*/
|
||||
|
||||
// Foundry exposes fields globally; declared via fvtt-types.
|
||||
export const fields = (): typeof foundry.data.fields => foundry.data.fields;
|
||||
|
||||
export function makeIntField(initial = 0, options: Partial<{ min: number; max: number; nullable: boolean }> = {}) {
|
||||
const f = fields();
|
||||
return new f.NumberField({
|
||||
required: true,
|
||||
nullable: options.nullable ?? false,
|
||||
integer: true,
|
||||
initial,
|
||||
min: options.min,
|
||||
max: options.max,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeStringField(initial = '', options: Partial<{ blank: boolean; choices: readonly string[] }> = {}) {
|
||||
const f = fields();
|
||||
return new f.StringField({
|
||||
required: true,
|
||||
blank: options.blank ?? true,
|
||||
initial,
|
||||
choices: options.choices as string[] | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function makeBoolField(initial = false) {
|
||||
const f = fields();
|
||||
return new f.BooleanField({ required: true, initial });
|
||||
}
|
||||
|
||||
export function makeHtmlField(initial = '') {
|
||||
const f = fields();
|
||||
return new f.HTMLField({ required: true, blank: true, initial });
|
||||
}
|
||||
|
||||
export function makeArrayField(element: foundry.data.fields.DataField.Any) {
|
||||
const f = fields();
|
||||
return new f.ArrayField(element);
|
||||
}
|
||||
|
||||
export function makeValueMaxField(initial = 0, max = 0) {
|
||||
const f = fields();
|
||||
return new f.SchemaField({
|
||||
value: makeIntField(initial, { min: 0 }),
|
||||
max: makeIntField(max, { min: 0 }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { makeStringField, makeHtmlField, makeIntField } from './fields';
|
||||
import { ABILITY_TYPES } from '../constants';
|
||||
|
||||
export class AbilityData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
type: makeStringField('passive', {
|
||||
blank: false,
|
||||
choices: ABILITY_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
prerequisite: makeStringField(''),
|
||||
cost: makeStringField(''),
|
||||
manaCost: makeIntField(0, { min: 0 }),
|
||||
zealCost: makeIntField(0, { min: 0 }),
|
||||
description: makeHtmlField(''),
|
||||
mechanics: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { makeIntField, makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
|
||||
|
||||
export class ClassData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
year: makeIntField(1, { min: 1, max: 4 }),
|
||||
attributePoints: makeIntField(0, { min: 0 }),
|
||||
skillPoints: makeIntField(0, { min: 0 }),
|
||||
talents: makeArrayField(new f.StringField({ blank: false })),
|
||||
spells: makeArrayField(new f.StringField({ blank: false })),
|
||||
features: makeArrayField(
|
||||
new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
}),
|
||||
),
|
||||
description: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
|
||||
import { ATTRIBUTES } from '../constants';
|
||||
|
||||
export class DisciplineData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
color: makeStringField(''),
|
||||
suggestedAttribute: makeStringField('magic', {
|
||||
blank: false,
|
||||
choices: ATTRIBUTES as unknown as readonly string[],
|
||||
}),
|
||||
suggestedSkills: makeArrayField(new f.StringField({ blank: false })),
|
||||
passiveAbility: makeHtmlField(''),
|
||||
availableSpells: makeArrayField(new f.StringField({ blank: false })),
|
||||
description: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeBoolField,
|
||||
makeHtmlField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import { GEAR_CATEGORIES, DAMAGE_TYPES, ARMOR_TYPES, RARITIES } from '../constants';
|
||||
|
||||
/**
|
||||
* Unified Gear item — discriminated by `category`.
|
||||
* Weapon/armor/equipment all share base fields; specialised fields
|
||||
* are scoped under `weapon` and `armor` sub-schemas.
|
||||
*/
|
||||
export class GearData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
category: makeStringField('equipment', {
|
||||
blank: false,
|
||||
choices: GEAR_CATEGORIES as unknown as readonly string[],
|
||||
}),
|
||||
rarity: makeStringField('common', {
|
||||
blank: false,
|
||||
choices: RARITIES as unknown as readonly string[],
|
||||
}),
|
||||
quantity: makeIntField(1, { min: 0 }),
|
||||
weight: makeIntField(0, { min: 0 }),
|
||||
value: makeIntField(0, { min: 0 }),
|
||||
equipped: makeBoolField(false),
|
||||
damageReductionBonus: makeIntField(0, { min: 0 }),
|
||||
description: makeHtmlField(''),
|
||||
|
||||
weapon: new f.SchemaField({
|
||||
damage: makeStringField(''),
|
||||
damageType: makeStringField('physical', {
|
||||
blank: false,
|
||||
choices: DAMAGE_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
properties: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
|
||||
armor: new f.SchemaField({
|
||||
damageReduction: makeIntField(0, { min: 0 }),
|
||||
condition: makeIntField(1, { min: 0 }),
|
||||
conditionMax: makeIntField(1, { min: 0 }),
|
||||
armorType: makeStringField('light', {
|
||||
blank: false,
|
||||
choices: ARMOR_TYPES as unknown as readonly string[],
|
||||
}),
|
||||
stealthDisadvantage: makeBoolField(false),
|
||||
strengthRequirement: makeIntField(0, { min: 0 }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Migrate legacy `armor.armorClass` → `armor.damageReduction`. */
|
||||
_initializeSource(data: any, options: any): any {
|
||||
const initialized = super._initializeSource(data, options);
|
||||
if (initialized?.armor && typeof initialized.armor.armorClass === 'number' && initialized.armor.damageReduction == null) {
|
||||
initialized.armor.damageReduction = initialized.armor.armorClass;
|
||||
delete initialized.armor.armorClass;
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { makeIntField, makeStringField, makeHtmlField, makeArrayField, fields } from './fields';
|
||||
|
||||
export class RaceData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
availableDisciplines: makeArrayField(new f.StringField({ blank: false })),
|
||||
attributePoints: makeIntField(0, { min: 0 }),
|
||||
skillPoints: makeIntField(0, { min: 0 }),
|
||||
freeTalents: makeArrayField(new f.StringField({ blank: false })),
|
||||
racialAbilities: makeArrayField(
|
||||
new f.SchemaField({
|
||||
name: makeStringField(''),
|
||||
description: makeStringField(''),
|
||||
}),
|
||||
),
|
||||
physicalDescription: makeStringField(''),
|
||||
description: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
makeIntField,
|
||||
makeStringField,
|
||||
makeBoolField,
|
||||
makeHtmlField,
|
||||
makeArrayField,
|
||||
fields,
|
||||
} from './fields';
|
||||
import {
|
||||
CASTING_MODES,
|
||||
SPELL_SCHOOLS,
|
||||
SACRED_DEITIES,
|
||||
SOURCE_BOOKS,
|
||||
AOE_SHAPES,
|
||||
SPELL_DAMAGE_TYPES,
|
||||
TRIGGER_EVENTS,
|
||||
TS_DEFAULT_THRESHOLD,
|
||||
TS_DEFAULT_REQUIRED,
|
||||
} from '../constants';
|
||||
|
||||
export class SpellData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
// Identification
|
||||
circle: makeIntField(1, { min: 0, max: 9 }),
|
||||
school: makeStringField('', { blank: true, choices: SPELL_SCHOOLS as unknown as readonly string[] }),
|
||||
discipline: makeStringField(''),
|
||||
deity: makeStringField('', { blank: true, choices: SACRED_DEITIES as unknown as readonly string[] }),
|
||||
sourceBook: makeStringField('', { blank: true, choices: SOURCE_BOOKS as unknown as readonly string[] }),
|
||||
|
||||
// Casting mode + resources
|
||||
castingMode: makeStringField('standard', { blank: false, choices: CASTING_MODES as unknown as readonly string[] }),
|
||||
manaCost: makeIntField(1, { min: 0 }),
|
||||
bloodCost: makeIntField(0, { min: 0 }),
|
||||
|
||||
// Difficulty
|
||||
difficulty: new f.SchemaField({
|
||||
threshold: makeIntField(TS_DEFAULT_THRESHOLD, { min: 2, max: 6 }),
|
||||
successes: makeIntField(TS_DEFAULT_REQUIRED, { min: 1, max: 10 }),
|
||||
}),
|
||||
|
||||
// Components
|
||||
components: new f.SchemaField({
|
||||
verbal: makeBoolField(false),
|
||||
somatic: makeBoolField(false),
|
||||
material: makeStringField(''),
|
||||
symbols: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
|
||||
// Timing & geometry
|
||||
castingTime: makeStringField('1 akcja'),
|
||||
castingTimeRounds: makeIntField(0, { min: 0 }),
|
||||
castingTimeMinutes: makeIntField(0, { min: 0 }),
|
||||
range: makeStringField(''),
|
||||
targets: makeStringField(''),
|
||||
duration: makeStringField(''),
|
||||
areaOfEffect: new f.SchemaField({
|
||||
shape: makeStringField('point', { blank: false, choices: AOE_SHAPES as unknown as readonly string[] }),
|
||||
x: makeIntField(0, { min: 0 }),
|
||||
y: makeIntField(0, { min: 0 }),
|
||||
unit: makeStringField('m'),
|
||||
}),
|
||||
|
||||
// Damage
|
||||
damageBase: makeStringField(''),
|
||||
damageType: makeStringField('magical', { blank: false, choices: SPELL_DAMAGE_TYPES as unknown as readonly string[] }),
|
||||
ignoresArmor: makeBoolField(false),
|
||||
|
||||
// Status effects on hit
|
||||
statusEffects: makeArrayField(new f.StringField({ blank: false })),
|
||||
saveAttribute: makeStringField(''),
|
||||
saveSkill: makeStringField(''),
|
||||
|
||||
// Requirements
|
||||
requirements: new f.SchemaField({
|
||||
race: makeArrayField(new f.StringField({ blank: false })),
|
||||
talent: makeArrayField(new f.StringField({ blank: false })),
|
||||
discipline: makeArrayField(new f.StringField({ blank: false })),
|
||||
}),
|
||||
|
||||
// Flags
|
||||
isSuperspell: makeBoolField(false),
|
||||
requiresGroupCast: makeBoolField(false),
|
||||
minCasters: makeIntField(1, { min: 1 }),
|
||||
nonCombatOnly: makeBoolField(false),
|
||||
|
||||
// Overcast — structured
|
||||
overcastOptions: makeArrayField(new f.SchemaField({
|
||||
description: makeStringField(''),
|
||||
manaPerStep: makeIntField(1, { min: 0 }),
|
||||
})),
|
||||
|
||||
// Reactive triggers
|
||||
triggers: makeArrayField(new f.SchemaField({
|
||||
event: makeStringField('killWithWeapon', { blank: false, choices: TRIGGER_EVENTS as unknown as readonly string[] }),
|
||||
effect: makeStringField(''),
|
||||
})),
|
||||
|
||||
// Variable success summons (Przywołanie Istoty z Otchłani)
|
||||
variableSuccesses: makeArrayField(new f.SchemaField({
|
||||
label: makeStringField(''),
|
||||
successes: makeIntField(1, { min: 1, max: 10 }),
|
||||
})),
|
||||
|
||||
// Description
|
||||
description: makeHtmlField(''),
|
||||
higherCircles: makeHtmlField(''), // legacy — superseded by overcastOptions
|
||||
|
||||
// Legacy fields (kept for migration; will be removed in a later release)
|
||||
overcasting: makeStringField(''),
|
||||
complexityLevel: makeIntField(0, { min: 0, max: 10 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { makeIntField, makeStringField, makeHtmlField, makeBoolField, fields } from './fields';
|
||||
|
||||
export class TalentData extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const f = fields();
|
||||
return {
|
||||
requirements: new f.SchemaField({
|
||||
race: makeStringField(''),
|
||||
attribute: makeStringField(''),
|
||||
skill: makeStringField(''),
|
||||
talent: makeStringField(''),
|
||||
title: makeStringField(''),
|
||||
discipline: makeStringField(''),
|
||||
}),
|
||||
multiSelect: makeBoolField(false),
|
||||
cost: makeStringField(''),
|
||||
damageReductionBonus: makeIntField(0, { min: 0 }),
|
||||
description: makeHtmlField(''),
|
||||
effect: makeHtmlField(''),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user