vagabond-rpg-foundryvtt/module/data/actor/character.mjs
Cal Corum 466581efd5 Add important data model enhancements from rulebook audit
CharacterData enhancements:
- Enhanced customResources with type/subtype/resetOn/data fields for
  class-specific tracking (Alchemist Formulae, Hunter's Mark, etc.)
- Added favorHinder tracking for d20 +/- d6 modifiers with source/duration
- Added movement types (walk/fly/swim/climb/burrow) matching NPC structure
- Added focus tracking for maintained spells with manaCostPerRound
- Added progression tracking (xpPacing, perksGainedByLevel, statIncreasesByLevel)
- Enhanced itemSlots with bonuses array, auto-sum, and overburdened status
- Updated prepareDerivedData to calculate bonuses and overburdened status

WeaponData enhancements:
- Added material property (mundane/silvered/adamantine/magical)

Effects helper:
- Added effect keys for all movement types (fly/swim/climb/burrow)

Note: Difficulty auto-calculation and wealth gold/silver/copper were
already implemented in Phase 1.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 15:55:25 -06:00

507 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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,
// Reference to equipped ancestry item (UUID)
ancestryId: new fields.StringField({
required: false,
nullable: true,
blank: true,
initial: null,
}),
// 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 }),
}),
// Studied Dice pool (Scholar class feature - d8s that can replace d20 rolls)
studiedDice: new fields.SchemaField({
value: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
max: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
}),
}),
// Custom resources (for class-specific tracking like Alchemist Formulae, Hunter's Mark, etc.)
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 }),
// Resource type for different tracking behaviors
type: new fields.StringField({
initial: "counter",
choices: ["counter", "tracker", "toggle", "list"],
}),
// Subtype for specific mechanics (formulae, marked-target, crit-reduction, etc.)
subtype: new fields.StringField({ required: false, blank: true }),
// When this resource resets
resetOn: new fields.StringField({
initial: "",
choices: ["", "rest", "turn", "round", "day", "combat"],
}),
// Flexible data storage for complex resources (formulae lists, target IDs, etc.)
data: new fields.ObjectField({ initial: {} }),
})
),
// Status Effects with Countdown Dice support
// Countdown Dice: Track duration with shrinking dice (d6 -> d4 -> ends)
statusEffects: new fields.ArrayField(
new fields.SchemaField({
// Effect name (e.g., "Burning", "Poisoned", "Blessed")
name: new fields.StringField({ required: true }),
// Effect description
description: new fields.StringField({ required: false, blank: true }),
// Source of the effect (item UUID, spell name, etc.)
source: new fields.StringField({ required: false, blank: true }),
// Icon path for display
icon: new fields.StringField({ initial: "icons/svg/aura.svg" }),
// Is this a beneficial or harmful effect?
beneficial: new fields.BooleanField({ initial: false }),
// Duration type: "countdown" (Cd4/Cd6), "rounds", "turns", "permanent"
durationType: new fields.StringField({
initial: "countdown",
choices: ["countdown", "rounds", "turns", "permanent"],
}),
// Current countdown die size (6 = d6, 4 = d4, 0 = ended)
countdownDie: new fields.NumberField({ integer: true, initial: 6, min: 0, max: 12 }),
// For rounds/turns duration: remaining count
durationValue: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Active Effect changes to apply while this status is active
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: [] }
),
// Armor value (from equipped armor)
armor: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Item slots tracking (8 + Might - Fatigue + bonuses)
itemSlots: new fields.SchemaField({
// Currently used slots (auto-calculated from inventory)
used: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Maximum available slots (auto-calculated)
max: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Total bonus from all sources (auto-calculated from bonuses array)
bonus: new fields.NumberField({ integer: true, initial: 0 }),
// Individual bonus sources for tracking (Orc Hulking, Pack Mule, etc.)
bonuses: new fields.ArrayField(
new fields.SchemaField({
source: new fields.StringField({ required: true }), // "Orc Hulking", "Pack Mule"
value: new fields.NumberField({ integer: true, initial: 0 }),
}),
{ initial: [] }
),
// Is the character overburdened (used > max)?
overburdened: new fields.BooleanField({ initial: false }),
}),
// Movement speeds (multiple types like NPCs)
speed: new fields.SchemaField({
// Walking speed (base from DEX)
walk: new fields.NumberField({ integer: true, initial: 30, min: 0 }),
// Flying speed (from spells, ancestry, features)
fly: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Swimming speed (Hunter Rover, some ancestries)
swim: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Climbing speed (Hunter Rover, some ancestries)
climb: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Burrowing speed (rare, some beasts)
burrow: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Bonus to walking speed from effects
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" }),
}),
// Favor/Hinder tracking (d20 +/- d6 modifiers)
// Cancel each other 1-for-1, don't stack
favorHinder: new fields.SchemaField({
favor: new fields.ArrayField(
new fields.SchemaField({
source: new fields.StringField({ required: true }), // "Flanking", "Virtuoso", etc.
appliesTo: new fields.ArrayField(new fields.StringField()), // ["Attack Checks"], ["Reflex Saves"]
duration: new fields.StringField({
initial: "instant",
choices: ["instant", "until-next-turn", "focus", "continual", "permanent"],
}),
}),
{ initial: [] }
),
hinder: new fields.ArrayField(
new fields.SchemaField({
source: new fields.StringField({ required: true }), // "Heavy Armor", "Fog spell", etc.
appliesTo: new fields.ArrayField(new fields.StringField()), // ["Dodge Saves"], ["sight-based checks"]
duration: new fields.StringField({
initial: "instant",
choices: ["instant", "until-next-turn", "focus", "continual", "permanent"],
}),
}),
{ initial: [] }
),
}),
// Focus tracking for maintained spells
focus: new fields.SchemaField({
// Currently focused spell/effect
active: new fields.ArrayField(
new fields.SchemaField({
spellId: new fields.StringField({ required: false, blank: true }),
spellName: new fields.StringField({ required: true }),
target: new fields.StringField({ required: false, blank: true }), // Target ID or description
manaCostPerRound: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
requiresSaveCheck: new fields.BooleanField({ initial: false }), // Cast Check each Round?
canBeBroken: new fields.BooleanField({ initial: true }), // Can Focus be broken by damage?
}),
{ initial: [] }
),
// Maximum concurrent focus (usually 1, Ancient Growth = 2)
maxConcurrent: new fields.NumberField({ integer: true, initial: 1, min: 1 }),
}),
// Progression tracking for leveling
progression: new fields.SchemaField({
// XP pacing determines XP required per level
xpPacing: new fields.StringField({
initial: "normal",
choices: ["quick", "normal", "epic", "saga"],
}),
// Track which perks were gained at which level
perksGainedByLevel: new fields.ObjectField({ initial: {} }), // { "3": ["perkId1"], "5": ["perkId2"] }
// Track which stats were increased at which level
statIncreasesByLevel: new fields.ObjectField({ initial: {} }), // { "2": "might", "4": "dexterity" }
}),
};
}
/**
* 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 Walking 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.walk = (speedByDex[dexValue] || 30) + this.speed.bonus;
// Calculate Item Slots: 8 + Might - Fatigue + bonus
const baseSlots = CONFIG.VAGABOND?.baseItemSlots || 8;
// Sum up all bonus sources
const totalBonus = this.itemSlots.bonuses.reduce((sum, b) => sum + b.value, 0);
this.itemSlots.bonus = totalBonus;
this.itemSlots.max =
baseSlots + stats.might.value - this.resources.fatigue.value + this.itemSlots.bonus;
// Check if overburdened
this.itemSlots.overburdened = this.itemSlots.used > this.itemSlots.max;
// 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;
}
}