Implement Phase 1: Complete data model system for actors and items

Actor Data Models:
- VagabondActorBase: Shared base class with biography field
- CharacterData: Full PC schema with stats, skills, saves, resources,
  custom crit thresholds, dynamic resources, item slots, wealth tracking
- NPCData: Monster stat block with HD, HP, TL, zone, morale, actions,
  abilities, immunities/weaknesses

Item Data Models:
- VagabondItemBase: Shared base with description field
- AncestryData: Being type, size, racial traits
- ClassData: Progression tables, features, mana/casting, trained skills
- SpellData: Dynamic mana cost calculation, delivery/duration types
- PerkData: Prerequisites system, stat/skill/spell requirements
- WeaponData: Damage, grip, properties, attack types, crit thresholds
- ArmorData: Armor value, type, dodge penalty
- EquipmentData: Quantity, slots, consumables
- FeatureData: Class features with Active Effect changes

Active Effects Integration:
- Helper module for creating and managing Active Effects
- Effect key mapping for stats, saves, skills, crit thresholds
- Utilities for applying/removing item effects

Derived Value Calculations (CharacterData):
- Max HP = Might × Level
- Speed by Dexterity lookup
- Item Slots = 8 + Might - Fatigue
- Save difficulties from stat pairs
- Skill difficulties (trained doubles stat contribution)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-12 15:22:09 -06:00
parent 44dbd00e1b
commit 51f0472d99
19 changed files with 2286 additions and 19 deletions

View File

@ -77,7 +77,18 @@
{
"builtinGlobals": true,
"hoist": "all",
"allow": ["event", "name", "status", "parent", "top", "close", "open", "print"]
"allow": [
"event",
"name",
"status",
"parent",
"top",
"close",
"open",
"print",
"origin",
"CharacterData"
]
}
],
"no-var": "error",

View File

@ -97,7 +97,7 @@
"id": "1.1",
"name": "Create base Actor data model",
"description": "Shared fields for all actors: name, img, type, system data container",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["0.1", "0.2"]
@ -106,7 +106,7 @@
"id": "1.2",
"name": "Create Character (PC) data model",
"description": "Stats (MIT/DEX/AWR/RSN/PRS/LUK), derived values (HP, Speed, Saves, Skill difficulties), level, XP, ancestry, class reference",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["1.1"]
@ -115,7 +115,7 @@
"id": "1.3",
"name": "Create NPC/Monster data model",
"description": "HD, HP, TL, Zone, Morale, Appearing, Armor, Immune, Weak, Actions array, Abilities array",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["1.1"]
@ -124,7 +124,7 @@
"id": "1.4",
"name": "Create dynamic resources system",
"description": "Extensible resource tracker: HP, Mana, Luck, Fatigue, Studied Dice, custom resources with current/max/bonus fields",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["1.2"]
@ -133,7 +133,7 @@
"id": "1.5",
"name": "Create Skills data structure",
"description": "12 skills with associated stat, trained boolean, difficulty calculation, custom crit threshold",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["1.2"]
@ -142,7 +142,7 @@
"id": "1.6",
"name": "Create base Item data model",
"description": "Shared fields for all items: name, img, type, system data container, description",
"completed": false,
"completed": true,
"tested": false,
"priority": "critical",
"dependencies": ["0.1", "0.2"]
@ -151,7 +151,7 @@
"id": "1.7",
"name": "Create Ancestry item data model",
"description": "Being type, size, traits array with name/description pairs",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -160,7 +160,7 @@
"id": "1.8",
"name": "Create Class item data model",
"description": "Key stat, action style, zone, training grants, starting pack, progression table (level -> features/mana/spells), feature definitions",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -169,7 +169,7 @@
"id": "1.9",
"name": "Create Spell item data model",
"description": "Damage type, base effect, crit effect, valid delivery types, duration options, mana cost formula components",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -178,7 +178,7 @@
"id": "1.10",
"name": "Create Perk item data model",
"description": "Prerequisites (stat requirements, training requirements, spell requirements), full description, mechanical effects",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -187,7 +187,7 @@
"id": "1.11",
"name": "Create Weapon item data model",
"description": "Damage dice, grip type (1H/2H/Versatile), properties (Finesse, Thrown, Cleave, etc.), attack skill, slot cost, value",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -196,7 +196,7 @@
"id": "1.12",
"name": "Create Armor item data model",
"description": "Armor value, type (Light/Heavy/Shield), slot cost, value, dodge penalty flag",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -205,7 +205,7 @@
"id": "1.13",
"name": "Create Equipment item data model",
"description": "Generic items: slot cost, value, description, quantity, consumable flag",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -214,7 +214,7 @@
"id": "1.14",
"name": "Create Feature item data model",
"description": "Class features as separate items: source class, level gained, description, mechanical effects, passive vs active",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.6"]
@ -223,7 +223,7 @@
"id": "1.15",
"name": "Create Active Effects integration",
"description": "System for items (classes, perks, features) to modify actor stats, crit thresholds, resources",
"completed": false,
"completed": true,
"tested": false,
"priority": "high",
"dependencies": ["1.2", "1.8", "1.10", "1.14"]

27
module/data/_module.mjs Normal file
View File

@ -0,0 +1,27 @@
/**
* Vagabond RPG Data Models
*
* Central export point for all data model classes.
* These models define the schema and behavior for Actor and Item documents.
*/
// Actor data models
export * from "./actor/_module.mjs";
// Item data models
export * from "./item/_module.mjs";
// Re-export for convenience
export { VagabondActorBase, CharacterData, NPCData } from "./actor/_module.mjs";
export {
VagabondItemBase,
AncestryData,
ClassData,
SpellData,
PerkData,
WeaponData,
ArmorData,
EquipmentData,
FeatureData,
} from "./item/_module.mjs";

View File

@ -0,0 +1,9 @@
/**
* Actor Data Models
*
* Exports all actor data model classes for registration with Foundry.
*/
export { default as VagabondActorBase } from "./base-actor.mjs";
export { default as CharacterData } from "./character.mjs";
export { default as NPCData } from "./npc.mjs";

View File

@ -0,0 +1,51 @@
/**
* Base Actor Data Model
*
* Provides shared data fields and methods for all actor types in Vagabond RPG.
* This is an abstract base class - use CharacterData or NPCData for actual actors.
*
* @extends foundry.abstract.TypeDataModel
*/
export default class VagabondActorBase extends foundry.abstract.TypeDataModel {
/**
* Define the base schema shared by all actors.
* Subclasses should call super.defineSchema() and extend the result.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
return {
// Biography/description - rich text HTML content
biography: new fields.HTMLField({ required: false, blank: true }),
};
}
/**
* Prepare base data for the actor.
* Called before derived data calculation.
* Override in subclasses to add type-specific base preparation.
*/
prepareBaseData() {
// Base preparation - subclasses can override
}
/**
* Prepare derived data for the actor.
* Called after base data and before render.
* Override in subclasses to calculate derived values.
*/
prepareDerivedData() {
// Derived data calculation - subclasses should override
}
/**
* Get the roll data for this actor for use in Roll formulas.
*
* @returns {Object} Roll data object with actor's stats and values
*/
getRollData() {
const data = { ...this };
return data;
}
}

View File

@ -0,0 +1,356 @@
/**
* Character (PC) Data Model
*
* Defines the data schema for player characters in Vagabond RPG.
* Includes stats, skills, saves, resources, and derived value calculations.
*
* Key Mechanics:
* - Six stats: Might, Dexterity, Awareness, Reason, Presence, Luck
* - Derived values: Max HP = Might × Level, Speed based on DEX
* - 12 skills with trained/untrained status affecting difficulty
* - 3 saves (Reflex, Endure, Will) calculated from stat pairs
* - Resources: HP, Mana, Luck pool, Fatigue
* - Item slots: 8 + Might - Fatigue
*
* @extends VagabondActorBase
*/
import VagabondActorBase from "./base-actor.mjs";
export default class CharacterData extends VagabondActorBase {
/**
* Define the schema for character actors.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Character level (1-10)
level: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 1,
min: 1,
max: 10,
}),
// Experience points
xp: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 0,
min: 0,
}),
// Six core stats
stats: new fields.SchemaField({
might: new fields.SchemaField({
value: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 2,
min: 1,
max: 10,
}),
}),
dexterity: new fields.SchemaField({
value: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 2,
min: 1,
max: 10,
}),
}),
awareness: new fields.SchemaField({
value: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 2,
min: 1,
max: 10,
}),
}),
reason: new fields.SchemaField({
value: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 2,
min: 1,
max: 10,
}),
}),
presence: new fields.SchemaField({
value: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 2,
min: 1,
max: 10,
}),
}),
luck: new fields.SchemaField({
value: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 2,
min: 1,
max: 10,
}),
}),
}),
// 12 skills with training and custom crit thresholds
skills: new fields.SchemaField({
arcana: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
brawl: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
craft: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
detect: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
finesse: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
influence: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
leadership: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
medicine: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
mysticism: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
performance: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
sneak: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
survival: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
}),
// Attack skills with crit thresholds
attacks: new fields.SchemaField({
melee: new fields.SchemaField({
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
brawl: new fields.SchemaField({
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
ranged: new fields.SchemaField({
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
finesse: new fields.SchemaField({
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
}),
// Resources (HP, Mana, Luck, Fatigue)
resources: new fields.SchemaField({
hp: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
max: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
mana: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
max: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
castingMax: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
luck: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
max: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
}),
fatigue: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0, min: 0, max: 5 }),
}),
}),
// Custom resources (for class-specific tracking like Studied Dice)
customResources: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true }),
value: new fields.NumberField({ integer: true, initial: 0 }),
max: new fields.NumberField({ integer: true, initial: 0 }),
})
),
// Armor value (from equipped armor)
armor: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Item slots tracking
itemSlots: new fields.SchemaField({
used: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
max: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
// Movement speed
speed: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 30 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
// Saves - difficulties will be calculated
saves: new fields.SchemaField({
reflex: new fields.SchemaField({
difficulty: new fields.NumberField({ integer: true, initial: 20 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
endure: new fields.SchemaField({
difficulty: new fields.NumberField({ integer: true, initial: 20 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
will: new fields.SchemaField({
difficulty: new fields.NumberField({ integer: true, initial: 20 }),
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
}),
// Wealth tracking (Gold, Silver, Copper)
wealth: new fields.SchemaField({
gold: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
silver: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
copper: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
}),
// Character details
details: new fields.SchemaField({
size: new fields.StringField({ initial: "medium" }),
beingType: new fields.StringField({ initial: "humanlike" }),
}),
};
}
/**
* Prepare base data for the character.
* Sets up initial values before items are processed.
*/
prepareBaseData() {
super.prepareBaseData();
}
/**
* Prepare derived data for the character.
* Calculates all values that depend on stats, level, and other factors.
*/
prepareDerivedData() {
super.prepareDerivedData();
const stats = this.stats;
const level = this.level;
// Calculate Max HP: Might × Level + bonus
this.resources.hp.max = stats.might.value * level + this.resources.hp.bonus;
// Calculate Speed based on Dexterity
const speedByDex = CONFIG.VAGABOND?.speedByDex || { 2: 25, 3: 25, 4: 30, 5: 30, 6: 35, 7: 35 };
const dexValue = Math.max(2, Math.min(7, stats.dexterity.value));
this.speed.value = (speedByDex[dexValue] || 30) + this.speed.bonus;
// Calculate Item Slots: 8 + Might - Fatigue + bonus
const baseSlots = CONFIG.VAGABOND?.baseItemSlots || 8;
this.itemSlots.max =
baseSlots + stats.might.value - this.resources.fatigue.value + this.itemSlots.bonus;
// Calculate Luck pool max (equals Luck stat)
this.resources.luck.max = stats.luck.value;
// Calculate Save Difficulties
// Reflex = DEX + AWR, Difficulty = 20 - (DEX + AWR) + bonus
this.saves.reflex.difficulty =
20 - (stats.dexterity.value + stats.awareness.value) - this.saves.reflex.bonus;
// Endure = MIT + MIT, Difficulty = 20 - (MIT × 2) + bonus
this.saves.endure.difficulty = 20 - stats.might.value * 2 - this.saves.endure.bonus;
// Will = RSN + PRS, Difficulty = 20 - (RSN + PRS) + bonus
this.saves.will.difficulty =
20 - (stats.reason.value + stats.presence.value) - this.saves.will.bonus;
// Calculate Skill Difficulties
this._calculateSkillDifficulties();
}
/**
* Calculate difficulty values for all skills.
* Untrained: 20 - stat
* Trained: 20 - (stat × 2)
*
* @private
*/
_calculateSkillDifficulties() {
const skillStats = CONFIG.VAGABOND?.skills || {};
for (const [skillId, skillData] of Object.entries(this.skills)) {
const skillConfig = skillStats[skillId];
if (!skillConfig) continue;
const statKey = skillConfig.stat;
const statValue = this.stats[statKey]?.value || 0;
const trained = skillData.trained;
// Calculate difficulty: 20 - stat (untrained) or 20 - stat×2 (trained)
skillData.difficulty = trained ? 20 - statValue * 2 : 20 - statValue;
// Store the associated stat for reference
skillData.stat = statKey;
}
}
/**
* Get the roll data for this character.
* Includes all stats, skills, and resources for use in Roll formulas.
*
* @returns {Object} Roll data object
*/
getRollData() {
const data = super.getRollData();
// Add stat values at top level for easy access in formulas
for (const [key, stat] of Object.entries(this.stats)) {
data[key] = stat.value;
}
// Add level
data.level = this.level;
return data;
}
}

199
module/data/actor/npc.mjs Normal file
View File

@ -0,0 +1,199 @@
/**
* NPC/Monster Data Model
*
* Defines the data schema for non-player characters and monsters in Vagabond RPG.
* Uses a simplified stat block format common in OSR-style games.
*
* Key Attributes:
* - HD (Hit Dice): Determines combat prowess
* - HP: Hit points (separate from HD for flexibility)
* - TL (Threat Level): Encounter balancing value (0.1 to 10+)
* - Zone: AI behavior hint (Frontline, Midline, Backline)
* - Morale: 2d6 check threshold for fleeing
* - Actions: Attack actions with names and damage
* - Abilities: Special abilities and traits
*
* @extends VagabondActorBase
*/
import VagabondActorBase from "./base-actor.mjs";
export default class NPCData extends VagabondActorBase {
/**
* Define the schema for NPC actors.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Hit Dice - represents overall power level
hd: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 1,
min: 0,
}),
// Hit Points
hp: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 4, min: 0 }),
max: new fields.NumberField({ integer: true, initial: 4, min: 1 }),
}),
// Threat Level - used for encounter balancing
// 0.1 = minion, 1.0 = standard, 2.0+ = elite/boss
tl: new fields.NumberField({
required: true,
nullable: false,
initial: 1,
min: 0,
}),
// Armor value
armor: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 0,
min: 0,
}),
// Morale score - flee on 2d6 > Morale when triggered
morale: new fields.NumberField({
required: true,
nullable: false,
integer: true,
initial: 7,
min: 2,
max: 12,
}),
// Number appearing (for random encounters)
appearing: new fields.StringField({ initial: "1d6" }),
// Preferred combat zone (AI hint)
zone: new fields.StringField({
required: true,
initial: "frontline",
choices: ["frontline", "midline", "backline"],
}),
// Size category
size: new fields.StringField({ initial: "medium" }),
// Being type (for targeting by certain effects)
beingType: new fields.StringField({ initial: "beast" }),
// Movement speed
speed: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 30 }),
fly: new fields.NumberField({ integer: true, initial: 0 }),
swim: new fields.NumberField({ integer: true, initial: 0 }),
climb: new fields.NumberField({ integer: true, initial: 0 }),
}),
// Damage immunities
immunities: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Damage vulnerabilities (weaknesses)
weaknesses: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Damage resistances
resistances: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Attack actions
actions: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true }),
description: new fields.HTMLField({ required: false, blank: true }),
attackType: new fields.StringField({ initial: "melee" }),
damage: new fields.StringField({ initial: "1d6" }),
damageType: new fields.StringField({ initial: "blunt" }),
range: new fields.StringField({ required: false }),
properties: new fields.ArrayField(new fields.StringField(), { initial: [] }),
}),
{ initial: [] }
),
// Special abilities
abilities: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true }),
description: new fields.HTMLField({ required: true }),
passive: new fields.BooleanField({ initial: true }),
}),
{ initial: [] }
),
// Loot/treasure
loot: new fields.HTMLField({ required: false, blank: true }),
// Notes for GM
gmNotes: new fields.HTMLField({ required: false, blank: true }),
};
}
/**
* Prepare derived data for the NPC.
*/
prepareDerivedData() {
super.prepareDerivedData();
// Ensure HP max is at least HD if not set higher
if (this.hp.max < this.hd) {
this.hp.max = this.hd * 4; // Default HD to HP conversion
}
}
/**
* Get zone behavior description for the GM.
*
* @returns {string} Description of typical behavior for this zone
*/
getZoneBehavior() {
const behaviors = {
frontline:
"Engages in melee combat. Moves toward nearest enemy. Prioritizes blocking access to allies.",
midline:
"Maintains medium range. Uses ranged attacks or support abilities. Retreats if engaged in melee.",
backline:
"Stays at maximum range. Uses ranged attacks, magic, or support. Flees if enemies approach.",
};
return behaviors[this.zone] || behaviors.frontline;
}
/**
* Check if morale check is needed.
* Typically triggered when:
* - First ally dies
* - NPC reduced to half HP
* - Leader dies
*
* @returns {boolean} True if morale should be checked
*/
shouldCheckMorale() {
// Check if at or below half HP
return this.hp.value <= Math.floor(this.hp.max / 2);
}
/**
* Get the roll data for this NPC.
*
* @returns {Object} Roll data object
*/
getRollData() {
const data = super.getRollData();
// Add core stats for formulas
data.hd = this.hd;
data.armor = this.armor;
data.morale = this.morale;
return data;
}
}

View File

@ -0,0 +1,15 @@
/**
* Item Data Models
*
* Exports all item data model classes for registration with Foundry.
*/
export { default as VagabondItemBase } from "./base-item.mjs";
export { default as AncestryData } from "./ancestry.mjs";
export { default as ClassData } from "./class.mjs";
export { default as SpellData } from "./spell.mjs";
export { default as PerkData } from "./perk.mjs";
export { default as WeaponData } from "./weapon.mjs";
export { default as ArmorData } from "./armor.mjs";
export { default as EquipmentData } from "./equipment.mjs";
export { default as FeatureData } from "./feature.mjs";

View File

@ -0,0 +1,66 @@
/**
* Ancestry Item Data Model
*
* Defines the data schema for character ancestries (races) in Vagabond RPG.
* Examples: Human, Dwarf, Elf, Halfling, Draken, Goblin, Orc
*
* Ancestries provide:
* - Being type classification
* - Size category
* - Racial traits (abilities/features)
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class AncestryData extends VagabondItemBase {
/**
* Define the schema for ancestry items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Being type (Humanlike, Fae, Cryptid, etc.)
beingType: new fields.StringField({
required: true,
initial: "humanlike",
}),
// Size category
size: new fields.StringField({
required: true,
initial: "medium",
}),
// Racial traits - abilities granted by this ancestry
traits: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true }),
description: new fields.HTMLField({ required: true }),
}),
{ initial: [] }
),
};
}
/**
* Get chat card data for displaying ancestry information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.beingType = this.beingType;
data.size = this.size;
data.traits = this.traits;
return data;
}
}

125
module/data/item/armor.mjs Normal file
View File

@ -0,0 +1,125 @@
/**
* Armor Item Data Model
*
* Defines the data schema for armor in Vagabond RPG.
*
* Armor Types:
* - Light: Basic protection, no dodge penalty
* - Heavy: Better protection, may have dodge penalty
* - Shield: Adds to armor, held in hand
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class ArmorData extends VagabondItemBase {
/**
* Define the schema for armor items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Armor value provided
armorValue: new fields.NumberField({
required: true,
integer: true,
initial: 1,
min: 0,
}),
// Armor type (light, heavy, shield)
armorType: new fields.StringField({
required: true,
initial: "light",
choices: ["light", "heavy", "shield"],
}),
// Does this armor impose a dodge penalty?
dodgePenalty: new fields.BooleanField({ initial: false }),
// Inventory slot cost
slots: new fields.NumberField({
integer: true,
initial: 1,
min: 0,
}),
// Monetary value (in copper)
value: new fields.NumberField({
integer: true,
initial: 0,
min: 0,
}),
// Is this armor equipped?
equipped: new fields.BooleanField({ initial: false }),
// Bonus effects (magical armor)
magicBonus: new fields.NumberField({
integer: true,
initial: 0,
}),
// Special properties or enchantments
properties: new fields.ArrayField(new fields.StringField(), { initial: [] }),
};
}
/**
* Get the total armor value including magic bonus.
*
* @returns {number} Total armor value
*/
getTotalArmorValue() {
return this.armorValue + this.magicBonus;
}
/**
* Get the effective armor when equipped.
*
* @returns {number} Armor value if equipped, 0 otherwise
*/
getEquippedArmor() {
return this.equipped ? this.getTotalArmorValue() : 0;
}
/**
* Calculate slot cost when equipped.
*
* @returns {number} Slot cost
*/
getEquippedSlots() {
return this.equipped ? this.slots : 0;
}
/**
* Check if this armor imposes dodge penalty when equipped.
*
* @returns {boolean} True if equipped and has dodge penalty
*/
hasActiveDodgePenalty() {
return this.equipped && this.dodgePenalty;
}
/**
* Get chat card data for displaying armor information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.armorValue = this.getTotalArmorValue();
data.armorType = this.armorType;
data.dodgePenalty = this.dodgePenalty;
data.properties = this.properties;
return data;
}
}

View File

@ -0,0 +1,67 @@
/**
* Base Item Data Model
*
* Provides shared data fields and methods for all item types in Vagabond RPG.
* This is an abstract base class - use specific item data models for actual items.
*
* @extends foundry.abstract.TypeDataModel
*/
export default class VagabondItemBase extends foundry.abstract.TypeDataModel {
/**
* Define the base schema shared by all items.
* Subclasses should call super.defineSchema() and extend the result.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
return {
// Rich text description
description: new fields.HTMLField({ required: false, blank: true }),
};
}
/**
* Prepare base data for the item.
* Called before derived data calculation.
*/
prepareBaseData() {
// Base preparation - subclasses can override
}
/**
* Prepare derived data for the item.
* Called after base data and before render.
*/
prepareDerivedData() {
// Derived data calculation - subclasses should override
}
/**
* Get the roll data for this item for use in Roll formulas.
*
* @returns {Object} Roll data object with item's values
*/
getRollData() {
const data = { ...this };
// Include parent actor's roll data if available
if (this.parent?.actor) {
data.actor = this.parent.actor.getRollData();
}
return data;
}
/**
* Get a chat card data object for this item.
* Override in subclasses for type-specific chat output.
*
* @returns {Object} Chat card data
*/
getChatData() {
return {
description: this.description,
};
}
}

168
module/data/item/class.mjs Normal file
View File

@ -0,0 +1,168 @@
/**
* Class Item Data Model
*
* Defines the data schema for character classes in Vagabond RPG.
* Classes are designed as draggable items that can be added to characters.
*
* Each class provides:
* - Key stat recommendation
* - Action style (casting skill for casters)
* - Preferred combat zone
* - Training grants (skills trained)
* - Starting equipment pack
* - Progression table (level 1-10)
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class ClassData extends VagabondItemBase {
/**
* Define the schema for class items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Key stat recommendation for this class
keyStat: new fields.StringField({
required: true,
initial: "might",
}),
// Action style / casting skill (for casters)
// e.g., "arcana" for Wizard, "mysticism" for Druid, "influence" for Sorcerer
actionStyle: new fields.StringField({
required: false,
blank: true,
}),
// Preferred combat zone
zone: new fields.StringField({
required: true,
initial: "frontline",
choices: ["frontline", "midline", "backline"],
}),
// Skills trained by this class (array of skill IDs)
trainedSkills: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Starting equipment pack description
startingPack: new fields.HTMLField({ required: false, blank: true }),
// Is this a spellcasting class?
isCaster: new fields.BooleanField({ initial: false }),
// Progression table - level-by-level benefits
progression: new fields.ArrayField(
new fields.SchemaField({
level: new fields.NumberField({ integer: true, min: 1, max: 10 }),
mana: new fields.NumberField({ integer: true, initial: 0 }),
castingMax: new fields.NumberField({ integer: true, initial: 0 }),
spellsKnown: new fields.NumberField({ integer: true, initial: 0 }),
features: new fields.ArrayField(new fields.StringField(), { initial: [] }),
}),
{ initial: [] }
),
// Class features - detailed definitions
features: new fields.ArrayField(
new fields.SchemaField({
name: new fields.StringField({ required: true }),
level: new fields.NumberField({ integer: true, min: 1, max: 10 }),
description: new fields.HTMLField({ required: true }),
passive: new fields.BooleanField({ initial: true }),
// Active Effect changes this feature applies
changes: new fields.ArrayField(
new fields.SchemaField({
key: new fields.StringField({ required: true }),
mode: new fields.NumberField({ integer: true, initial: 2 }),
value: new fields.StringField({ required: true }),
}),
{ initial: [] }
),
}),
{ initial: [] }
),
// Resource this class uses (if any) - e.g., "Studied Dice" for Alchemist
customResource: new fields.SchemaField({
name: new fields.StringField({ required: false }),
max: new fields.StringField({ required: false }), // Can be a formula like "@level"
}),
};
}
/**
* Get the features available at a given level.
*
* @param {number} level - Character level to check
* @returns {Array} Array of features available at or before this level
*/
getFeaturesAtLevel(level) {
return this.features.filter((f) => f.level <= level);
}
/**
* Get the progression entry for a given level.
*
* @param {number} level - Character level to check
* @returns {Object|null} Progression data for the level
*/
getProgressionAtLevel(level) {
return this.progression.find((p) => p.level === level) || null;
}
/**
* Get cumulative mana pool at a given level.
*
* @param {number} level - Character level
* @returns {number} Total mana pool
*/
getManaAtLevel(level) {
let totalMana = 0;
for (const prog of this.progression) {
if (prog.level <= level) {
totalMana += prog.mana || 0;
}
}
return totalMana;
}
/**
* Get casting max at a given level.
*
* @param {number} level - Character level
* @returns {number} Casting max (max mana per spell)
*/
getCastingMaxAtLevel(level) {
let castingMax = 0;
for (const prog of this.progression) {
if (prog.level <= level && prog.castingMax > castingMax) {
castingMax = prog.castingMax;
}
}
return castingMax;
}
/**
* Get chat card data for displaying class information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.keyStat = this.keyStat;
data.zone = this.zone;
data.isCaster = this.isCaster;
data.trainedSkills = this.trainedSkills;
return data;
}
}

View File

@ -0,0 +1,153 @@
/**
* Equipment Item Data Model
*
* Defines the data schema for generic equipment/gear in Vagabond RPG.
* This covers adventuring gear, tools, consumables, and miscellaneous items.
*
* Examples: Rope, Torches, Potions, Lockpicks, Rations
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class EquipmentData extends VagabondItemBase {
/**
* Define the schema for equipment items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Item quantity
quantity: new fields.NumberField({
required: true,
integer: true,
initial: 1,
min: 0,
}),
// Inventory slot cost (per item or per stack)
slots: new fields.NumberField({
integer: true,
initial: 1,
min: 0,
}),
// Whether slots are per-item or for the whole stack
slotsPerItem: new fields.BooleanField({ initial: false }),
// Monetary value per item (in copper)
value: new fields.NumberField({
integer: true,
initial: 0,
min: 0,
}),
// Is this a consumable item?
consumable: new fields.BooleanField({ initial: false }),
// For consumables: uses remaining
uses: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0 }),
max: new fields.NumberField({ integer: true, initial: 0 }),
autoDestroy: new fields.BooleanField({ initial: true }),
}),
// Equipment category for organization
category: new fields.StringField({
initial: "gear",
choices: ["gear", "tool", "consumable", "container", "treasure", "misc"],
}),
// Is this item currently equipped/active?
equipped: new fields.BooleanField({ initial: false }),
// Container capacity (if this is a container)
containerCapacity: new fields.NumberField({
integer: true,
initial: 0,
min: 0,
}),
// Tags for filtering/searching
tags: new fields.ArrayField(new fields.StringField(), { initial: [] }),
};
}
/**
* Calculate the total slot cost for this item stack.
*
* @returns {number} Total slots used
*/
getTotalSlots() {
if (this.slotsPerItem) {
return this.slots * this.quantity;
}
return this.slots;
}
/**
* Calculate the total value of this item stack.
*
* @returns {number} Total value in copper
*/
getTotalValue() {
return this.value * this.quantity;
}
/**
* Consume one use of a consumable item.
*
* @returns {Object} Result with new values
*/
consume() {
if (!this.consumable) {
return { consumed: false, reason: "Not consumable" };
}
if (this.uses.max > 0) {
// Uses-based consumable
if (this.uses.value <= 0) {
return { consumed: false, reason: "No uses remaining" };
}
return {
consumed: true,
newUses: this.uses.value - 1,
depleted: this.uses.value - 1 <= 0,
};
}
// Quantity-based consumable
if (this.quantity <= 0) {
return { consumed: false, reason: "No items remaining" };
}
return {
consumed: true,
newQuantity: this.quantity - 1,
depleted: this.quantity - 1 <= 0,
};
}
/**
* Get chat card data for displaying equipment information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.quantity = this.quantity;
data.category = this.category;
data.consumable = this.consumable;
if (this.consumable && this.uses.max > 0) {
data.uses = `${this.uses.value}/${this.uses.max}`;
}
return data;
}
}

View File

@ -0,0 +1,167 @@
/**
* Feature Item Data Model
*
* Defines the data schema for class features and abilities in Vagabond RPG.
* Features are granted by classes at specific levels and can provide
* passive bonuses or active abilities.
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class FeatureData extends VagabondItemBase {
/**
* Define the schema for feature items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Source class that grants this feature
sourceClass: new fields.StringField({
required: false,
blank: true,
}),
// Level at which this feature is gained
level: new fields.NumberField({
required: true,
integer: true,
initial: 1,
min: 1,
max: 10,
}),
// Is this a passive or active feature?
passive: new fields.BooleanField({ initial: true }),
// For active features: activation type
activation: new fields.SchemaField({
type: new fields.StringField({
initial: "",
choices: ["", "action", "bonus", "reaction", "free", "special"],
}),
cost: new fields.StringField({ required: false, blank: true }),
}),
// Usage tracking (for limited-use features)
uses: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0 }),
max: new fields.NumberField({ integer: true, initial: 0 }),
per: new fields.StringField({
initial: "",
choices: ["", "short", "long", "day", "encounter"],
}),
}),
// Active Effect changes this feature applies
changes: new fields.ArrayField(
new fields.SchemaField({
key: new fields.StringField({ required: true }),
mode: new fields.NumberField({
integer: true,
initial: 2, // CONST.ACTIVE_EFFECT_MODES.ADD
}),
value: new fields.StringField({ required: true }),
priority: new fields.NumberField({ integer: true, nullable: true }),
}),
{ initial: [] }
),
// Requirements beyond level (e.g., specific class choices)
requirements: new fields.StringField({
required: false,
blank: true,
}),
// Tags for categorization
tags: new fields.ArrayField(new fields.StringField(), { initial: [] }),
};
}
/**
* Check if this feature has uses that can be tracked.
*
* @returns {boolean} True if feature has limited uses
*/
hasUses() {
return this.uses.max > 0;
}
/**
* Check if this feature has remaining uses.
*
* @returns {boolean} True if uses remain or feature is unlimited
*/
hasRemainingUses() {
if (!this.hasUses()) return true;
return this.uses.value > 0;
}
/**
* Use one charge of this feature.
*
* @returns {Object} Result with success and new value
*/
use() {
if (!this.hasUses()) {
return { success: true, unlimited: true };
}
if (this.uses.value <= 0) {
return { success: false, reason: "No uses remaining" };
}
return {
success: true,
newValue: this.uses.value - 1,
remaining: this.uses.value - 1,
};
}
/**
* Get the recovery text for this feature's uses.
*
* @returns {string} Recovery description
*/
getRecoveryText() {
if (!this.hasUses()) return "";
const perText = {
short: "per short rest",
long: "per long rest",
day: "per day",
encounter: "per encounter",
};
return perText[this.uses.per] || "";
}
/**
* Get chat card data for displaying feature information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.sourceClass = this.sourceClass;
data.level = this.level;
data.passive = this.passive;
if (!this.passive && this.activation.type) {
data.activation = this.activation.type;
}
if (this.hasUses()) {
data.uses = `${this.uses.value}/${this.uses.max} ${this.getRecoveryText()}`;
}
return data;
}
}

180
module/data/item/perk.mjs Normal file
View File

@ -0,0 +1,180 @@
/**
* Perk Item Data Model
*
* Defines the data schema for perks (feats/talents) in Vagabond RPG.
* Characters gain 1 perk at odd levels (1, 3, 5, 7, 9).
*
* Perks have prerequisites that may include:
* - Minimum stat values
* - Training in specific skills
* - Knowledge of specific spells
* - Other perks
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class PerkData extends VagabondItemBase {
/**
* Define the schema for perk items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Prerequisites
prerequisites: new fields.SchemaField({
// Stat requirements (e.g., { might: 4, dexterity: 3 })
stats: new fields.SchemaField({
might: new fields.NumberField({ integer: true, nullable: true, initial: null }),
dexterity: new fields.NumberField({ integer: true, nullable: true, initial: null }),
awareness: new fields.NumberField({ integer: true, nullable: true, initial: null }),
reason: new fields.NumberField({ integer: true, nullable: true, initial: null }),
presence: new fields.NumberField({ integer: true, nullable: true, initial: null }),
luck: new fields.NumberField({ integer: true, nullable: true, initial: null }),
}),
// Required skill training (array of skill IDs)
trainedSkills: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Required spells (array of spell names)
spells: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Required other perks (array of perk names)
perks: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Custom prerequisite text (for complex requirements)
custom: new fields.StringField({ required: false, blank: true }),
}),
// Mechanical effects (as Active Effect changes)
changes: new fields.ArrayField(
new fields.SchemaField({
key: new fields.StringField({ required: true }),
mode: new fields.NumberField({ integer: true, initial: 2 }),
value: new fields.StringField({ required: true }),
}),
{ initial: [] }
),
// Is this perk passive or does it require activation?
passive: new fields.BooleanField({ initial: true }),
// Usage tracking (for limited-use perks)
uses: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0 }),
max: new fields.NumberField({ integer: true, initial: 0 }),
per: new fields.StringField({ initial: "" }), // "short", "long", "day", ""
}),
// Tags for categorization
tags: new fields.ArrayField(new fields.StringField(), { initial: [] }),
};
}
/**
* Check if a character meets this perk's prerequisites.
*
* @param {Object} actorData - The actor's system data
* @returns {Object} Result with 'met' boolean and 'missing' array of unmet requirements
*/
checkPrerequisites(actorData) {
const missing = [];
// Check stat requirements
for (const [stat, required] of Object.entries(this.prerequisites.stats)) {
if (required !== null && required > 0) {
const actorStat = actorData.stats?.[stat]?.value || 0;
if (actorStat < required) {
missing.push(`${stat.charAt(0).toUpperCase() + stat.slice(1)} ${required}`);
}
}
}
// Check skill training requirements
for (const skillId of this.prerequisites.trainedSkills) {
const skill = actorData.skills?.[skillId];
if (!skill?.trained) {
missing.push(`Trained in ${skillId}`);
}
}
// Note: Spell and perk prerequisites would need item checks on the parent actor
// These are tracked but validation requires access to actor items
if (this.prerequisites.spells.length > 0) {
missing.push(`Spells: ${this.prerequisites.spells.join(", ")}`);
}
if (this.prerequisites.perks.length > 0) {
missing.push(`Perks: ${this.prerequisites.perks.join(", ")}`);
}
if (this.prerequisites.custom) {
missing.push(this.prerequisites.custom);
}
return {
met: missing.length === 0,
missing,
};
}
/**
* Get a formatted string of prerequisites for display.
*
* @returns {string} Formatted prerequisite string
*/
getPrerequisiteString() {
const parts = [];
// Stat requirements
for (const [stat, required] of Object.entries(this.prerequisites.stats)) {
if (required !== null && required > 0) {
const abbr = stat.substring(0, 3).toUpperCase();
parts.push(`${abbr} ${required}`);
}
}
// Skill training
for (const skill of this.prerequisites.trainedSkills) {
parts.push(`Trained: ${skill}`);
}
// Spells
for (const spell of this.prerequisites.spells) {
parts.push(`Spell: ${spell}`);
}
// Perks
for (const perk of this.prerequisites.perks) {
parts.push(`Perk: ${perk}`);
}
// Custom
if (this.prerequisites.custom) {
parts.push(this.prerequisites.custom);
}
return parts.length > 0 ? parts.join(", ") : "None";
}
/**
* Get chat card data for displaying perk information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.prerequisites = this.getPrerequisiteString();
data.passive = this.passive;
return data;
}
}

179
module/data/item/spell.mjs Normal file
View File

@ -0,0 +1,179 @@
/**
* Spell Item Data Model
*
* Defines the data schema for spells in Vagabond RPG.
* Spells have dynamic mana costs based on:
* - Damage dice selected (each d6 costs 1 mana)
* - Delivery type (touch=0, remote=0, cube=1, aura/sphere/cone/line/glyph=2)
* - Duration (instant=0, focus=0, continual=+2 if >0 damage)
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class SpellData extends VagabondItemBase {
/**
* Define the schema for spell items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Damage type (fire, cold, shock, etc.) - null for non-damaging spells
damageType: new fields.StringField({
required: false,
blank: true,
initial: "",
}),
// Base damage dice (e.g., "d6" - number selected at cast time)
damageBase: new fields.StringField({
required: false,
blank: true,
initial: "d6",
}),
// Maximum damage dice that can be used
maxDice: new fields.NumberField({
integer: true,
initial: 0, // 0 = no limit (use casting max)
min: 0,
}),
// Base effect description (what the spell does)
effect: new fields.HTMLField({
required: true,
blank: true,
}),
// Critical effect bonus
critEffect: new fields.HTMLField({
required: false,
blank: true,
}),
// Valid delivery types for this spell
deliveryTypes: new fields.SchemaField({
touch: new fields.BooleanField({ initial: false }),
remote: new fields.BooleanField({ initial: false }),
imbue: new fields.BooleanField({ initial: false }),
cube: new fields.BooleanField({ initial: false }),
aura: new fields.BooleanField({ initial: false }),
cone: new fields.BooleanField({ initial: false }),
glyph: new fields.BooleanField({ initial: false }),
line: new fields.BooleanField({ initial: false }),
sphere: new fields.BooleanField({ initial: false }),
}),
// Valid duration types for this spell
durationTypes: new fields.SchemaField({
instant: new fields.BooleanField({ initial: true }),
focus: new fields.BooleanField({ initial: false }),
continual: new fields.BooleanField({ initial: false }),
}),
// Casting skill used (can be overridden per-spell if different from class)
castingSkill: new fields.StringField({
required: false,
blank: true, // Empty = use class's actionStyle
}),
// Is this spell currently being focused?
focusing: new fields.BooleanField({ initial: false }),
// Tags for categorization/filtering
tags: new fields.ArrayField(new fields.StringField(), { initial: [] }),
};
}
/**
* Calculate the mana cost for casting this spell with given options.
*
* @param {Object} options - Casting options
* @param {number} options.damageDice - Number of damage dice (default 0)
* @param {string} options.delivery - Delivery type (default "touch")
* @param {string} options.duration - Duration type (default "instant")
* @returns {number} Total mana cost
*/
calculateManaCost({ damageDice = 0, delivery = "touch", duration = "instant" } = {}) {
let cost = 0;
// Damage dice cost (1 mana per die)
cost += damageDice;
// Delivery cost
const deliveryCosts = {
touch: 0,
remote: 0,
imbue: 0,
cube: 1,
aura: 2,
cone: 2,
glyph: 2,
line: 2,
sphere: 2,
};
cost += deliveryCosts[delivery] || 0;
// Duration cost (continual adds +2 if dealing damage)
if (duration === "continual" && damageDice > 0) {
cost += 2;
}
return cost;
}
/**
* Get valid delivery types as an array.
*
* @returns {Array<string>} Array of valid delivery type keys
*/
getValidDeliveryTypes() {
return Object.entries(this.deliveryTypes)
.filter(([, valid]) => valid)
.map(([type]) => type);
}
/**
* Get valid duration types as an array.
*
* @returns {Array<string>} Array of valid duration type keys
*/
getValidDurationTypes() {
return Object.entries(this.durationTypes)
.filter(([, valid]) => valid)
.map(([type]) => type);
}
/**
* Check if this is a damaging spell.
*
* @returns {boolean} True if spell deals damage
*/
isDamaging() {
return Boolean(this.damageType && this.damageBase);
}
/**
* Get chat card data for displaying spell information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.damageType = this.damageType;
data.effect = this.effect;
data.critEffect = this.critEffect;
data.validDelivery = this.getValidDeliveryTypes();
data.validDuration = this.getValidDurationTypes();
data.isDamaging = this.isDamaging();
return data;
}
}

203
module/data/item/weapon.mjs Normal file
View File

@ -0,0 +1,203 @@
/**
* Weapon Item Data Model
*
* Defines the data schema for weapons in Vagabond RPG.
*
* Key properties:
* - Damage dice (e.g., "1d6", "2d6")
* - Grip type (1H, 2H, Versatile)
* - Attack skill (Melee, Brawl, Ranged, Finesse)
* - Weapon properties (Finesse, Thrown, Cleave, Reach, etc.)
* - Slot cost for inventory management
*
* @extends VagabondItemBase
*/
import VagabondItemBase from "./base-item.mjs";
export default class WeaponData extends VagabondItemBase {
/**
* Define the schema for weapon items.
*
* @returns {Object} The schema definition
*/
static defineSchema() {
const fields = foundry.data.fields;
const baseSchema = super.defineSchema();
return {
...baseSchema,
// Damage dice formula
damage: new fields.StringField({
required: true,
initial: "1d6",
}),
// Damage type
damageType: new fields.StringField({
required: true,
initial: "blunt",
}),
// Bonus damage (from magic, etc.)
bonusDamage: new fields.NumberField({
integer: true,
initial: 0,
}),
// Grip type (1h, 2h, versatile, fist)
grip: new fields.StringField({
required: true,
initial: "1h",
choices: ["1h", "2h", "versatile", "fist"],
}),
// Versatile damage (when wielded 2H)
versatileDamage: new fields.StringField({
required: false,
blank: true,
}),
// Attack skill used
attackType: new fields.StringField({
required: true,
initial: "melee",
choices: ["melee", "brawl", "ranged", "finesse"],
}),
// Range (for ranged/thrown weapons)
range: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0 }),
units: new fields.StringField({ initial: "ft" }),
}),
// Weapon properties
properties: new fields.SchemaField({
finesse: new fields.BooleanField({ initial: false }),
thrown: new fields.BooleanField({ initial: false }),
cleave: new fields.BooleanField({ initial: false }),
reach: new fields.BooleanField({ initial: false }),
loading: new fields.BooleanField({ initial: false }),
brawl: new fields.BooleanField({ initial: false }),
crude: new fields.BooleanField({ initial: false }),
versatile: new fields.BooleanField({ initial: false }),
}),
// Inventory slot cost
slots: new fields.NumberField({
integer: true,
initial: 1,
min: 0,
}),
// Monetary value (in copper)
value: new fields.NumberField({
integer: true,
initial: 0,
min: 0,
}),
// Is this weapon equipped?
equipped: new fields.BooleanField({ initial: false }),
// Quantity (for ammunition, thrown weapons)
quantity: new fields.NumberField({
integer: true,
initial: 1,
min: 0,
}),
// Attack bonus (from magic, etc.)
attackBonus: new fields.NumberField({
integer: true,
initial: 0,
}),
// Critical threshold override (if different from skill default)
critThreshold: new fields.NumberField({
integer: true,
nullable: true,
initial: null,
min: 1,
max: 20,
}),
};
}
/**
* Get the effective attack stat for this weapon.
* Accounts for Finesse property allowing DEX for melee.
*
* @returns {string} The stat key to use for attack rolls
*/
getAttackStat() {
const attackStats = {
melee: "might",
brawl: "might",
ranged: "awareness",
finesse: "dexterity",
};
// Finesse weapons can use DEX for melee
if (this.properties.finesse && this.attackType === "melee") {
return "dexterity";
}
return attackStats[this.attackType] || "might";
}
/**
* Get the active weapon properties as an array.
*
* @returns {Array<string>} Array of active property keys
*/
getActiveProperties() {
return Object.entries(this.properties)
.filter(([, active]) => active)
.map(([prop]) => prop);
}
/**
* Get the effective damage formula.
*
* @param {boolean} twoHanded - Whether using two-handed grip for versatile
* @returns {string} Damage formula
*/
getDamageFormula(twoHanded = false) {
// Use versatile damage if 2H and available
if (twoHanded && this.properties.versatile && this.versatileDamage) {
return this.bonusDamage > 0
? `${this.versatileDamage}+${this.bonusDamage}`
: this.versatileDamage;
}
return this.bonusDamage > 0 ? `${this.damage}+${this.bonusDamage}` : this.damage;
}
/**
* Calculate the slot cost when equipped.
*
* @returns {number} Slot cost
*/
getEquippedSlots() {
return this.equipped ? this.slots : 0;
}
/**
* Get chat card data for displaying weapon information.
*
* @returns {Object} Chat card data
*/
getChatData() {
const data = super.getChatData();
data.damage = this.damage;
data.damageType = this.damageType;
data.grip = this.grip;
data.attackType = this.attackType;
data.properties = this.getActiveProperties();
data.range = this.range.value > 0 ? `${this.range.value} ${this.range.units}` : null;
return data;
}
}

260
module/helpers/effects.mjs Normal file
View File

@ -0,0 +1,260 @@
/**
* Active Effects Helper Module
*
* Provides utilities for managing Active Effects in Vagabond RPG.
* Active Effects allow items (classes, perks, features) to modify actor stats,
* crit thresholds, resources, and other values.
*
* Key Use Cases:
* - Class features modifying crit thresholds for specific skills
* - Perks adding bonuses to stats or saves
* - Equipment providing armor or stat bonuses
* - Conditions applying temporary penalties
*/
/**
* Effect modes matching Foundry's CONST.ACTIVE_EFFECT_MODES
*/
export const EFFECT_MODES = {
CUSTOM: 0,
MULTIPLY: 1,
ADD: 2,
DOWNGRADE: 3,
UPGRADE: 4,
OVERRIDE: 5,
};
/**
* Common effect change keys for Vagabond RPG
* Maps human-readable names to data paths
*/
export const EFFECT_KEYS = {
// Stats
"stat.might": "system.stats.might.value",
"stat.dexterity": "system.stats.dexterity.value",
"stat.awareness": "system.stats.awareness.value",
"stat.reason": "system.stats.reason.value",
"stat.presence": "system.stats.presence.value",
"stat.luck": "system.stats.luck.value",
// Resources
"hp.bonus": "system.resources.hp.bonus",
"mana.bonus": "system.resources.mana.bonus",
"mana.castingMax": "system.resources.mana.castingMax",
"speed.bonus": "system.speed.bonus",
armor: "system.armor",
"itemSlots.bonus": "system.itemSlots.bonus",
// Save bonuses (reduce difficulty)
"save.reflex": "system.saves.reflex.bonus",
"save.endure": "system.saves.endure.bonus",
"save.will": "system.saves.will.bonus",
// Skill crit thresholds
"crit.arcana": "system.skills.arcana.critThreshold",
"crit.brawl": "system.skills.brawl.critThreshold",
"crit.craft": "system.skills.craft.critThreshold",
"crit.detect": "system.skills.detect.critThreshold",
"crit.finesse": "system.skills.finesse.critThreshold",
"crit.influence": "system.skills.influence.critThreshold",
"crit.leadership": "system.skills.leadership.critThreshold",
"crit.medicine": "system.skills.medicine.critThreshold",
"crit.mysticism": "system.skills.mysticism.critThreshold",
"crit.performance": "system.skills.performance.critThreshold",
"crit.sneak": "system.skills.sneak.critThreshold",
"crit.survival": "system.skills.survival.critThreshold",
// Attack crit thresholds
"crit.attack.melee": "system.attacks.melee.critThreshold",
"crit.attack.brawl": "system.attacks.brawl.critThreshold",
"crit.attack.ranged": "system.attacks.ranged.critThreshold",
"crit.attack.finesse": "system.attacks.finesse.critThreshold",
};
/**
* Create an Active Effect data object from a simplified definition.
*
* @param {Object} options - Effect options
* @param {string} options.name - Display name of the effect
* @param {string} options.icon - Icon path
* @param {Array} options.changes - Array of {key, value, mode} objects
* @param {boolean} options.disabled - Whether effect starts disabled
* @param {string} options.origin - UUID of the source item
* @returns {Object} Active Effect data object
*/
export function createEffectData({
name,
icon = "icons/svg/aura.svg",
changes = [],
disabled = false,
origin = null,
}) {
// Convert simplified keys to full data paths
const mappedChanges = changes.map((change) => ({
key: EFFECT_KEYS[change.key] || change.key,
mode: change.mode ?? EFFECT_MODES.ADD,
value: String(change.value),
priority: change.priority ?? null,
}));
return {
name,
icon,
changes: mappedChanges,
disabled,
origin,
transfer: true, // Transfer to actor when item is owned
};
}
/**
* Create a crit threshold reduction effect.
* Common for class features that improve crits on specific skills.
*
* @param {string} skillOrAttack - Skill ID or "attack.type"
* @param {number} reduction - Amount to reduce crit threshold (positive number)
* @param {string} name - Display name
* @param {string} origin - Source item UUID
* @returns {Object} Active Effect data
*/
export function createCritReductionEffect(skillOrAttack, reduction, name, origin = null) {
const key = skillOrAttack.startsWith("attack.")
? `crit.${skillOrAttack}`
: `crit.${skillOrAttack}`;
return createEffectData({
name,
icon: "icons/svg/sword.svg",
changes: [
{
key,
value: -Math.abs(reduction), // Negative to reduce threshold
mode: EFFECT_MODES.ADD,
},
],
origin,
});
}
/**
* Create a stat bonus effect.
*
* @param {string} stat - Stat ID (might, dexterity, etc.)
* @param {number} bonus - Bonus amount
* @param {string} name - Display name
* @param {string} origin - Source item UUID
* @returns {Object} Active Effect data
*/
export function createStatBonusEffect(stat, bonus, name, origin = null) {
return createEffectData({
name,
icon: "icons/svg/upgrade.svg",
changes: [
{
key: `stat.${stat}`,
value: bonus,
mode: EFFECT_MODES.ADD,
},
],
origin,
});
}
/**
* Create a save bonus effect.
*
* @param {string} save - Save type (reflex, endure, will)
* @param {number} bonus - Bonus amount (reduces difficulty)
* @param {string} name - Display name
* @param {string} origin - Source item UUID
* @returns {Object} Active Effect data
*/
export function createSaveBonusEffect(save, bonus, name, origin = null) {
return createEffectData({
name,
icon: "icons/svg/shield.svg",
changes: [
{
key: `save.${save}`,
value: bonus,
mode: EFFECT_MODES.ADD,
},
],
origin,
});
}
/**
* Apply effects from an item to its parent actor.
* Called when items with changes are added to an actor.
*
* @param {Item} item - The item with effects to apply
* @returns {Promise<ActiveEffect[]>} Created effects
*/
export async function applyItemEffects(item) {
const actor = item.parent;
if (!actor || !item.system.changes?.length) return [];
const effectData = createEffectData({
name: item.name,
icon: item.img,
changes: item.system.changes,
origin: item.uuid,
});
return actor.createEmbeddedDocuments("ActiveEffect", [effectData]);
}
/**
* Remove effects originating from a specific item.
*
* @param {Actor} actor - The actor to remove effects from
* @param {string} itemUuid - UUID of the source item
* @returns {Promise<void>}
*/
export async function removeItemEffects(actor, itemUuid) {
const effects = actor.effects.filter((e) => e.origin === itemUuid);
if (effects.length) {
const ids = effects.map((e) => e.id);
await actor.deleteEmbeddedDocuments("ActiveEffect", ids);
}
}
/**
* Get all effects on an actor grouped by source type.
*
* @param {Actor} actor - The actor to analyze
* @returns {Object} Effects grouped by source (class, perk, feature, equipment, other)
*/
export function getEffectsBySource(actor) {
const grouped = {
class: [],
perk: [],
feature: [],
equipment: [],
temporary: [],
other: [],
};
for (const effect of actor.effects) {
if (!effect.origin) {
grouped.temporary.push(effect);
continue;
}
// Try to determine source type from origin UUID
const sourceItem = fromUuidSync(effect.origin);
if (sourceItem) {
const type = sourceItem.type;
if (grouped[type]) {
grouped[type].push(effect);
} else {
grouped.other.push(effect);
}
} else {
grouped.other.push(effect);
}
}
return grouped;
}

View File

@ -6,6 +6,19 @@
// Import configuration
import { VAGABOND } from "./helpers/config.mjs";
// Import data models
import { CharacterData, NPCData } from "./data/actor/_module.mjs";
import {
AncestryData,
ClassData,
SpellData,
PerkData,
WeaponData,
ArmorData,
EquipmentData,
FeatureData,
} from "./data/item/_module.mjs";
// Import document classes
// import { VagabondActor } from "./documents/actor.mjs";
// import { VagabondItem } from "./documents/item.mjs";
@ -34,11 +47,29 @@ Hooks.once("init", () => {
// Add custom constants for configuration
CONFIG.VAGABOND = VAGABOND;
// Define custom Document classes
// Register Actor data models
CONFIG.Actor.dataModels = {
character: CharacterData,
npc: NPCData,
};
// Register Item data models
CONFIG.Item.dataModels = {
ancestry: AncestryData,
class: ClassData,
spell: SpellData,
perk: PerkData,
weapon: WeaponData,
armor: ArmorData,
equipment: EquipmentData,
feature: FeatureData,
};
// Define custom Document classes (for future use)
// CONFIG.Actor.documentClass = VagabondActor;
// CONFIG.Item.documentClass = VagabondItem;
// Register sheet application classes
// Register sheet application classes (TODO: Phase 3-4)
// Actors.unregisterSheet("core", ActorSheet);
// Actors.registerSheet("vagabond", VagabondCharacterSheet, {
// types: ["character"],
@ -52,7 +83,7 @@ Hooks.once("init", () => {
// label: "VAGABOND.SheetItem"
// });
// Preload Handlebars templates
// Preload Handlebars templates (TODO: Phase 3)
// return preloadHandlebarsTemplates();
});