vagabond-rpg-foundryvtt/module/data/actor/character.mjs
Cal Corum 694b11f423 Implement movement capability system with boolean toggles
Movement types (Climb, Cling, Fly, Phase, Swim) now use boolean
toggles instead of separate speed values, matching RAW where all
special movement uses base speed.

Changes:
- Update NPC and Character data models with movement schema
- Add movement section to NPC stats template (grid layout)
- Add movement section to character biography template
- Add localization strings with tooltip hints for each type
- Style movement grid to match senses section pattern
- Add rollable # Appearing label for NPC sheets
- Fix NPC sheet scrollbar visibility

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

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

717 lines
29 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, difficulty (computed), and custom crit thresholds
skills: new fields.SchemaField({
arcana: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
brawl: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
craft: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
detect: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
finesse: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
influence: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
leadership: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
medicine: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
mysticism: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
performance: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
sneak: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
critThreshold: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
}),
survival: new fields.SchemaField({
trained: new fields.BooleanField({ initial: false }),
difficulty: new fields.NumberField({ integer: true, initial: 20, min: 1, max: 20 }),
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 (null = manual/never)
resetOn: new fields.StringField({
required: false,
nullable: true,
blank: false,
initial: null,
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 speed - base walking speed plus bonus
speed: new fields.SchemaField({
// Walking speed (base from DEX, calculated in prepareDerivedData)
walk: new fields.NumberField({ integer: true, initial: 30, min: 0 }),
// Bonus to walking speed from effects
bonus: new fields.NumberField({ integer: true, initial: 0 }),
}),
// Movement capabilities - boolean toggles for special movement types
// All use base speed value when enabled per RAW
movement: new fields.SchemaField({
climb: new fields.BooleanField({ initial: false }), // Full Speed while climbing
cling: new fields.BooleanField({ initial: false }), // As Climb, but can Move on ceilings
fly: new fields.BooleanField({ initial: false }), // Move through the air at full Speed
phase: new fields.BooleanField({ initial: false }), // Move in occupied space (5 dmg if ends turn there)
swim: new fields.BooleanField({ initial: false }), // Full Speed while swimming
}),
// 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 category with mechanical effects
size: new fields.StringField({
initial: "medium",
choices: ["small", "medium", "large", "huge", "giant", "colossal"],
}),
// Units occupied on grid (derived from size)
unitsOccupied: new fields.NumberField({ integer: true, initial: 1, min: 1 }),
// Small creatures don't block movement through their space
allowsMovementThrough: new fields.BooleanField({ initial: false }),
// Being type for targeting effects
beingType: new fields.StringField({
initial: "humanlike",
choices: [
"humanlike",
"fae",
"cryptid",
"artificial",
"undead",
"primordial",
"hellspawn",
"beast",
],
}),
}),
// NOTE: Favor/Hinder is now handled via Active Effects flags instead of a data schema.
// See DEVELOPMENT.md "Favor/Hinder via Active Effects" for the flag convention:
// - flags.vagabond.favor.skills.<skillId>
// - flags.vagabond.hinder.skills.<skillId>
// - flags.vagabond.favor.attacks
// - flags.vagabond.hinder.attacks
// - flags.vagabond.favor.saves.<saveType>
// - flags.vagabond.hinder.saves.<saveType>
// 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" }
}),
// Senses - binary toggles, may be granted by perks/traits
senses: new fields.SchemaField({
allsight: new fields.BooleanField({ initial: false }),
blindsight: new fields.BooleanField({ initial: false }),
darkvision: new fields.BooleanField({ initial: false }), // Dwarf, Goblin, Orc, Infravision perk
echolocation: new fields.BooleanField({ initial: false }),
seismicsense: new fields.BooleanField({ initial: false }),
telepathy: new fields.BooleanField({ initial: false }),
}),
// Known languages
languages: new fields.ArrayField(new fields.StringField(), { initial: ["Common"] }),
// Rest and breather tracking
restTracking: new fields.SchemaField({
// Timestamp of last full rest
lastRest: new fields.StringField({ required: false, blank: true }),
// Number of breathers taken in current combat/scene
breathersTaken: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Bonuses to rest (Song of Rest, Tricksy, etc.)
restBonuses: new fields.ArrayField(
new fields.SchemaField({
source: new fields.StringField({ required: true }), // "Song of Rest", "Tricksy"
effect: new fields.StringField({ required: true }), // "+PRS HP", "+1 Luck"
}),
{ initial: [] }
),
}),
// Travel and exploration tracking
travel: new fields.SchemaField({
// Miles traveled today (for Padfoot perk)
milesThisDay: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Current travel pace
pace: new fields.StringField({
initial: "normal",
choices: ["slow", "normal", "fast"],
}),
// Can forage at normal pace (Hunter Survivalist)
canForage: new fields.BooleanField({ initial: false }),
// Shifts elapsed (time tracking for cooldowns)
shiftsElapsed: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
}),
// Crafting projects in progress
crafting: new fields.SchemaField({
activeProjects: new fields.ArrayField(
new fields.SchemaField({
itemName: new fields.StringField({ required: true }),
targetValue: new fields.NumberField({ integer: true, initial: 0 }), // In silver
materialsCost: new fields.NumberField({ integer: true, initial: 0 }),
shiftsRequired: new fields.NumberField({ integer: true, initial: 1 }),
shiftsCompleted: new fields.NumberField({ integer: true, initial: 0 }),
bonuses: new fields.ArrayField(
new fields.SchemaField({
source: new fields.StringField({ required: true }), // "Master Artisan"
effect: new fields.StringField({ required: true }), // "2× Shifts"
}),
{ initial: [] }
),
}),
{ initial: [] }
),
}),
// Combat positioning and flanking
combat: new fields.SchemaField({
// Is this character currently flanked?
isFlanked: new fields.BooleanField({ initial: false }),
// IDs of allies providing flanking
flankingAllies: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// Ignores flanking penalties (Situational Awareness perk)
ignoresFlankingPenalty: new fields.BooleanField({ initial: false }),
// Current combat zone (nullable - not in combat if null)
currentZone: new fields.StringField({
required: false,
nullable: true,
blank: false,
initial: null,
choices: ["frontline", "midline", "backline"],
}),
// Is dual-wielding?
isDualWielding: new fields.BooleanField({ initial: false }),
// Main hand weapon ID
mainHandWeapon: new fields.StringField({ required: false, blank: true }),
// Off hand weapon/shield ID
offHandWeapon: new fields.StringField({ required: false, blank: true }),
}),
// Casting and spell component tracking
casting: new fields.SchemaField({
// Currently equipped trinket item ID
equippedTrinket: new fields.StringField({ required: false, blank: true }),
// Can cast through weapon (Gish perk)
canCastThroughWeapon: new fields.BooleanField({ initial: false }),
// Can cast through musical instrument (Harmonic Resonance)
canCastThroughInstrument: new fields.BooleanField({ initial: false }),
}),
// Downtime activity tracking
downtime: new fields.SchemaField({
activities: new fields.ArrayField(
new fields.SchemaField({
type: new fields.StringField({
initial: "work",
choices: ["craft", "study", "carouse", "work", "research"],
}),
shiftsSpent: new fields.NumberField({ integer: true, initial: 0 }),
result: new fields.StringField({ required: false, blank: true }),
}),
{ initial: [] }
),
}),
// Quest tracking (for cooldowns like Medium perk)
quests: new fields.SchemaField({
activeQuests: new fields.ArrayField(new fields.StringField(), { initial: [] }),
completedQuests: new fields.ArrayField(new fields.StringField(), { initial: [] }),
// For Medium perk cooldown
lastQuestCompleted: new fields.StringField({ required: false, blank: true }),
}),
// Death and dying state
death: new fields.SchemaField({
isDead: new fields.BooleanField({ initial: false }),
deathCause: new fields.StringField({
required: false,
nullable: true,
blank: false,
initial: null,
choices: ["hp-zero", "body-destroyed", "fatigue-five"],
}),
canBeRevived: new fields.BooleanField({ initial: true }),
revivedCount: new fields.NumberField({ integer: true, initial: 0, min: 0 }),
// Luminary Revivify used today?
luminaryRevivifyUsed: new fields.BooleanField({ initial: false }),
// Force of Nature used this combat?
forceOfNatureUsed: new fields.BooleanField({ initial: false }),
}),
// Summoned creatures tracking
summons: new fields.SchemaField({
active: new fields.ArrayField(
new fields.SchemaField({
id: new fields.StringField({ required: true }),
name: new fields.StringField({ required: true }),
type: new fields.StringField({
initial: "beast",
choices: ["companion", "familiar", "primordial", "beast", "undead"],
}),
source: new fields.StringField({ required: true }), // "Animal Companion", "Familiar perk"
hd: new fields.NumberField({ integer: true, initial: 1 }),
currentHP: new fields.NumberField({ integer: true, initial: 4 }),
maxHP: new fields.NumberField({ integer: true, initial: 4 }),
usesSkill: new fields.StringField({ initial: "survival" }), // Which skill for checks
commandMethod: new fields.StringField({
initial: "action",
choices: ["action", "skip-move", "automatic"],
}),
duration: new fields.StringField({
initial: "permanent",
choices: ["permanent", "focus", "shift", "scene"],
}),
}),
{ initial: [] }
),
maxConcurrent: new fields.NumberField({ integer: true, initial: 1, min: 1 }),
}),
// Preferred combat zone (from class)
preferredZone: new fields.StringField({
initial: "frontline",
choices: ["frontline", "midline", "backline", "flexible"],
}),
};
}
/**
* 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 (also clamps skill crit thresholds)
this._calculateSkillDifficulties();
// Clamp attack crit thresholds after Active Effects
this._clampAttackCritThresholds();
}
/**
* 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;
// Clamp crit threshold after Active Effects (schema min doesn't apply to effect-modified data)
skillData.critThreshold = Math.max(1, Math.min(20, skillData.critThreshold));
}
}
/**
* Clamp attack crit thresholds after Active Effects are applied.
* Schema constraints don't apply to effect-modified data.
*
* @private
*/
_clampAttackCritThresholds() {
for (const attackData of Object.values(this.attacks)) {
attackData.critThreshold = Math.max(1, Math.min(20, attackData.critThreshold));
}
}
/**
* 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;
}
}