vagabond-rpg-foundryvtt/module/applications/level-up-dialog.mjs
Cal Corum 06e0dc01c0 Complete P2-P5: Perks, Feature Choices, Ancestry Traits, Caster Progression
- P2: Perks with changes[] arrays create Active Effects on drop/delete
- P3: Feature choice UI for Fighting Style (auto-grants Situational
  Awareness + selected training perk, ignoring prerequisites)
- P4: Ancestry traits apply Active Effects (Dwarf Darksight/Tough working)
- P5: Caster progression accumulates mana from class progression table

Key patterns:
- Manual UUID construction: Compendium.${pack.collection}.Item.${entry._id}
- ignorePrereqs flag for specific choices bypassing all prerequisites
- Mode 5 (OVERRIDE) for boolean senses like darkvision
- Form data merging with direct DOM reading for reliable selection capture

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

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

657 lines
21 KiB
JavaScript

/**
* Level Up Dialog for Vagabond RPG
*
* Displays features gained when leveling up and handles choices:
* - Shows automatic features with Active Effects
* - Presents Perk selection when "Perk" appears in progression
* - Handles choice features (e.g., Fighting Style)
*
* Uses Foundry VTT v13 ApplicationV2 API.
*
* @extends ApplicationV2
* @mixes HandlebarsApplicationMixin
*/
// Debug logging for level-up workflow - set to false to disable
const DEBUG_LEVELUP = false;
const debugLog = (...args) => {
if (DEBUG_LEVELUP) console.log("[LevelUpDialog]", ...args);
};
const debugWarn = (...args) => {
if (DEBUG_LEVELUP) console.warn("[LevelUpDialog]", ...args);
};
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export default class LevelUpDialog extends HandlebarsApplicationMixin(ApplicationV2) {
/**
* @param {VagabondActor} actor - The actor leveling up
* @param {number} newLevel - The new level
* @param {number} oldLevel - The previous level
* @param {Object} options - Dialog options
*/
constructor(actor, newLevel, oldLevel, options = {}) {
super(options);
this.actor = actor;
this.newLevel = newLevel;
this.oldLevel = oldLevel;
// Collected choices (perk selections, etc.)
this.choices = {
perks: [],
featureChoices: {},
};
debugLog(`Constructor called`, {
actorName: actor.name,
oldLevel,
newLevel,
});
}
/* -------------------------------------------- */
/* Static Properties */
/* -------------------------------------------- */
/** @override */
static DEFAULT_OPTIONS = {
id: "vagabond-level-up-dialog",
classes: ["vagabond", "level-up-dialog", "themed"],
tag: "form",
window: {
title: "VAGABOND.LevelUp",
icon: "fa-solid fa-arrow-up",
resizable: false,
},
position: {
width: 500,
height: "auto",
},
form: {
handler: LevelUpDialog.#onSubmit,
submitOnChange: false,
closeOnSubmit: true,
},
};
/** @override */
static PARTS = {
form: {
template: "systems/vagabond/templates/dialog/level-up.hbs",
},
};
/* -------------------------------------------- */
/* Getters */
/* -------------------------------------------- */
/**
* Get the title for this dialog.
* @returns {string}
*/
get title() {
return `${this.actor.name} - Level ${this.newLevel}`;
}
/* -------------------------------------------- */
/* Data Preparation */
/* -------------------------------------------- */
/** @override */
async _prepareContext(options) {
debugLog("_prepareContext called");
const context = await super._prepareContext(options);
context.actor = this.actor;
context.newLevel = this.newLevel;
context.oldLevel = this.oldLevel;
// Get all class items
const classes = this.actor.items.filter((i) => i.type === "class");
debugLog(
`Found ${classes.length} class(es):`,
classes.map((c) => c.name)
);
// Gather features from all classes
const allFeatures = [];
const perkSlots = [];
const choiceFeatures = [];
for (const classItem of classes) {
const features = classItem.system.features || [];
const progression = classItem.system.progression || [];
debugLog(`Processing class "${classItem.name}"`, {
featuresCount: features.length,
progressionLevels: progression.map((p) => p.level),
});
// Get progression data for the new level
const levelProgression = progression.find((p) => p.level === this.newLevel);
debugLog(`Level ${this.newLevel} progression:`, levelProgression);
// Check for "Perk" in progression
if (levelProgression?.features?.includes("Perk")) {
debugLog(`Perk slot found at level ${this.newLevel}`);
perkSlots.push({
className: classItem.name,
classId: classItem.id,
});
}
// Get features gained at this level
for (const feature of features) {
if (feature.level > this.oldLevel && feature.level <= this.newLevel) {
debugLog(`Feature gained: "${feature.name}" (level ${feature.level})`, {
hasChanges: feature.changes?.length > 0,
requiresChoice: feature.requiresChoice,
});
const featureData = {
...feature,
className: classItem.name,
classId: classItem.id,
hasChanges: feature.changes?.length > 0,
};
// Check if this feature requires a choice
if (feature.requiresChoice) {
choiceFeatures.push(featureData);
} else {
allFeatures.push(featureData);
}
}
}
}
context.features = allFeatures;
context.perkSlots = perkSlots;
context.choiceFeatures = choiceFeatures;
context.hasPerks = perkSlots.length > 0;
context.hasChoices = choiceFeatures.length > 0;
debugLog("Context prepared", {
featuresCount: allFeatures.length,
features: allFeatures.map((f) => f.name),
perkSlotsCount: perkSlots.length,
choiceFeaturesCount: choiceFeatures.length,
choiceFeatures: choiceFeatures.map((f) => f.name),
});
// Get available perks for selection
if (perkSlots.length > 0) {
debugLog("Loading available perks...");
context.availablePerks = await this._getAvailablePerks();
debugLog(`Loaded ${context.availablePerks?.length || 0} available perks`);
}
// Load filtered perks for each choice feature
for (const choiceFeature of choiceFeatures) {
if (choiceFeature.choiceType === "perk" && choiceFeature.choiceFilter) {
debugLog(`Loading filtered perks for "${choiceFeature.name}"...`);
// Fighting Style ignores all prerequisites per the feature rules
const ignorePrereqs = choiceFeature.name === "Fighting Style";
choiceFeature.filteredPerks = await this._getFilteredPerksForChoice(
choiceFeature.choiceFilter,
ignorePrereqs
);
debugLog(
`Loaded ${choiceFeature.filteredPerks?.length || 0} filtered perks for "${choiceFeature.name}"`,
choiceFeature.filteredPerks?.map((p) => ({ name: p.name, uuid: p.uuid, id: p.id }))
);
}
}
return context;
}
/**
* Get perks available for selection (from compendium, filtered by prerequisites).
*
* @returns {Promise<Object[]>} Available perk data
* @private
*/
async _getAvailablePerks() {
// Try to get perks from compendium
const pack = game.packs.get("vagabond.perks");
if (!pack) {
// No compendium, return empty
return [];
}
const index = await pack.getIndex();
const perks = [];
for (const entry of index) {
const perk = await pack.getDocument(entry._id);
if (!perk) continue;
// Check prerequisites
const prereqResult = perk.checkPrerequisites?.(this.actor);
const met = prereqResult?.met ?? true;
// Check if actor already has this perk
const alreadyHas = this.actor.items.some((i) => i.type === "perk" && i.name === perk.name);
if (!alreadyHas) {
perks.push({
id: perk.id,
uuid: perk.uuid,
name: perk.name,
description: perk.system.description,
prerequisites: perk.system.prerequisites || {},
prerequisitesMet: met,
missing: prereqResult?.missing || [],
});
}
}
// Sort: prerequisites met first, then alphabetically
perks.sort((a, b) => {
if (a.prerequisitesMet && !b.prerequisitesMet) return -1;
if (!a.prerequisitesMet && b.prerequisitesMet) return 1;
return a.name.localeCompare(b.name);
});
return perks;
}
/**
* Get perks filtered for a specific choice feature.
* Filters by the choiceFilter.prerequisite array (matches against perk's custom prerequisite text).
*
* @param {Object} choiceFilter - The filter from the feature (e.g., { prerequisite: ["Melee Training", "Ranged Training"] })
* @param {boolean} ignorePrereqs - If true, mark all filtered perks as available (for features like Fighting Style)
* @returns {Promise<Object[]>} Filtered perk data
* @private
*/
async _getFilteredPerksForChoice(choiceFilter, ignorePrereqs = false) {
const pack = game.packs.get("vagabond.perks");
if (!pack) return [];
const index = await pack.getIndex();
const perks = [];
const filterValues = choiceFilter?.prerequisite || [];
debugLog("Filtering perks for choice", { filterValues, ignorePrereqs });
for (const entry of index) {
const perk = await pack.getDocument(entry._id);
if (!perk) continue;
// Build the UUID from pack and entry info (more reliable than perk.uuid)
const perkUuid = `Compendium.${pack.collection}.Item.${entry._id}`;
// Check if perk matches the filter (by custom prerequisite text)
const customPrereq = perk.system.prerequisites?.custom || "";
const matchesFilter = filterValues.length === 0 || filterValues.includes(customPrereq);
if (!matchesFilter) {
debugLog(`Perk "${perk.name}" filtered out (custom: "${customPrereq}")`);
continue;
}
// Check prerequisites (optionally ignoring all for features like Fighting Style)
let met = true;
let missing = [];
if (ignorePrereqs) {
// Feature allows ignoring prerequisites - all filtered perks are selectable
met = true;
missing = [];
} else {
const prereqResult = perk.checkPrerequisites?.(this.actor);
met = prereqResult?.met ?? true;
missing = prereqResult?.missing || [];
}
// Check if actor already has this perk
const alreadyHas = this.actor.items.some((i) => i.type === "perk" && i.name === perk.name);
if (!alreadyHas) {
perks.push({
id: entry._id,
uuid: perkUuid,
name: perk.name,
description: perk.system.description,
prerequisites: perk.system.prerequisites || {},
prerequisitesMet: met,
missing,
customPrereq,
});
debugLog(`Perk "${perk.name}" added to choice list (met: ${met}, uuid: ${perkUuid})`);
}
}
// Sort alphabetically (all should be selectable for Fighting Style)
perks.sort((a, b) => a.name.localeCompare(b.name));
return perks;
}
/* -------------------------------------------- */
/* Event Handlers */
/* -------------------------------------------- */
/** @override */
_onRender(context, options) {
debugLog("_onRender called");
super._onRender(context, options);
// Apply theme class
this._applyThemeClass();
// Handle perk selection changes
const perkSelects = this.element.querySelectorAll("[data-perk-select]");
debugLog(`Found ${perkSelects.length} perk select elements`);
for (const select of perkSelects) {
select.addEventListener("change", (event) => {
const slotIndex = parseInt(event.currentTarget.dataset.perkSelect, 10);
this.choices.perks[slotIndex] = event.currentTarget.value;
debugLog(`Perk selection changed: slot ${slotIndex} = ${event.currentTarget.value}`);
});
}
// Handle feature choice changes - use 'input' event as well as 'change' for better capture
const choiceSelects = this.element.querySelectorAll("[data-feature-choice]");
debugLog(`Found ${choiceSelects.length} feature choice select elements`);
for (const select of choiceSelects) {
// Log initial state
debugLog(`Select initial state for "${select.dataset.featureChoice}":`, {
value: select.value,
selectedIndex: select.selectedIndex,
optionsCount: select.options.length,
});
// Track both change and input events
const handleSelection = (event) => {
const featureName = event.currentTarget.dataset.featureChoice;
const newValue = event.currentTarget.value;
const selectedIndex = event.currentTarget.selectedIndex;
const selectedText = event.currentTarget.options[selectedIndex]?.textContent;
this.choices.featureChoices[featureName] = newValue;
debugLog(
`Feature choice ${event.type}: "${featureName}" = "${newValue}" (index: ${selectedIndex}, text: "${selectedText}")`
);
};
select.addEventListener("change", handleSelection);
select.addEventListener("input", handleSelection);
// Also track focus/blur to see if something is resetting
select.addEventListener("blur", (event) => {
debugLog(
`Select blur for "${event.currentTarget.dataset.featureChoice}": value="${event.currentTarget.value}"`
);
});
}
}
/**
* Apply the configured theme class to the dialog element.
* @protected
*/
_applyThemeClass() {
if (!this.element) return;
this.element.classList.remove("theme-light", "theme-dark");
let theme = null;
try {
const uiConfig = game.settings.get("core", "uiConfig");
const colorScheme = uiConfig?.colorScheme?.applications;
if (colorScheme === "dark") {
theme = "dark";
} else if (colorScheme === "light") {
theme = "light";
}
} catch {
// Settings not available
}
if (theme === "dark") {
this.element.classList.add("theme-dark");
} else if (theme === "light") {
this.element.classList.add("theme-light");
}
}
/**
* Handle form submission (confirm level up).
* @param {Event} event - The form submission event
* @param {HTMLFormElement} form - The form element
* @param {FormDataExtended} formData - The form data
* @private
*/
static async #onSubmit(event, form, formData) {
debugLog("#onSubmit called", {
formData: formData.object,
});
const dialog = this;
const data = foundry.utils.expandObject(formData.object);
debugLog("Expanded form data:", data);
// Also read feature choice selects directly from the form (backup method)
const featureChoiceSelects = form.querySelectorAll("[data-feature-choice]");
const directChoices = {};
let missingRequiredChoice = false;
for (const select of featureChoiceSelects) {
const featureName = select.dataset.featureChoice;
const selectedValue = select.value;
const selectedIndex = select.selectedIndex;
const selectedOption = select.options[selectedIndex];
debugLog(`Direct select read: "${featureName}"`, {
value: selectedValue,
selectedIndex,
selectedOptionText: selectedOption?.textContent,
optionsCount: select.options.length,
});
directChoices[featureName] = selectedValue;
// Check if this is a required choice with no selection
if (!selectedValue) {
missingRequiredChoice = true;
}
}
// Warn if no choice was made
if (missingRequiredChoice) {
ui.notifications.warn("Please select a perk for Feature Choices before confirming.");
return; // Don't proceed with level up
}
data.directFeatureChoices = directChoices;
// Apply the level up
await dialog._applyLevelUp(data);
}
/**
* Apply the level up, including class features and chosen perks.
*
* @param {Object} formData - Form data with choices
* @returns {Promise<void>}
* @private
*/
async _applyLevelUp(formData) {
// Merge all sources of feature choices (direct DOM read takes priority)
const featureChoicesFromForm = formData.featureChoice || {};
const directFeatureChoices = formData.directFeatureChoices || {};
const mergedFeatureChoices = {
...this.choices.featureChoices,
...featureChoicesFromForm,
...directFeatureChoices, // Direct DOM read is most reliable
};
debugLog("_applyLevelUp called", {
actorName: this.actor.name,
oldLevel: this.oldLevel,
newLevel: this.newLevel,
choices: this.choices,
featureChoicesFromForm,
directFeatureChoices,
mergedFeatureChoices,
});
// 1. Update class features for all classes
const classes = this.actor.items.filter((i) => i.type === "class");
debugLog(`Updating features for ${classes.length} class(es)`);
for (const classItem of classes) {
debugLog(`Calling updateClassFeatures for "${classItem.name}"`);
const effects = await classItem.updateClassFeatures(this.newLevel, this.oldLevel);
debugLog(`updateClassFeatures returned ${effects.length} new effects`);
}
// 2. Add selected perks
debugLog(`Processing ${this.choices.perks.length} perk selections:`, this.choices.perks);
for (const perkUuid of this.choices.perks) {
if (!perkUuid) {
debugLog("Skipping empty perk selection");
continue;
}
debugLog(`Adding perk from UUID: ${perkUuid}`);
try {
const perkDoc = await fromUuid(perkUuid);
if (perkDoc) {
debugLog(`Found perk document: "${perkDoc.name}"`);
// Create a copy of the perk on the actor
const created = await this.actor.createEmbeddedDocuments("Item", [perkDoc.toObject()]);
debugLog(
`Created perk on actor:`,
created.map((i) => i.name)
);
} else {
debugWarn(`Perk document not found for UUID: ${perkUuid}`);
}
} catch (err) {
console.error(`Failed to add perk ${perkUuid}:`, err);
}
}
// 3. Handle feature choices (using merged form data + event-tracked choices)
debugLog(`Processing feature choices:`, mergedFeatureChoices);
for (const [featureName, choice] of Object.entries(mergedFeatureChoices)) {
debugLog(`Feature choice for "${featureName}": ${choice}`);
// Handle Fighting Style specifically
if (featureName === "Fighting Style") {
await this._applyFightingStyle(choice);
} else if (choice) {
// Generic perk choice handling - add the selected perk
try {
const perkDoc = await fromUuid(choice);
if (perkDoc) {
debugLog(`Adding choice perk: "${perkDoc.name}"`);
await this.actor.createEmbeddedDocuments("Item", [perkDoc.toObject()]);
}
} catch (err) {
console.error(`Failed to add choice perk ${choice}:`, err);
}
}
}
// Log final actor state
debugLog(
"Level up complete. Actor effects:",
this.actor.effects.map((e) => ({
name: e.name,
origin: e.origin,
changes: e.changes,
}))
);
// Notify user
ui.notifications.info(`${this.actor.name} advanced to level ${this.newLevel}!`);
}
/**
* Apply the Fighting Style feature.
* Grants Situational Awareness perk AND the selected training perk.
*
* @param {string} selectedPerkUuid - UUID of the selected training perk
* @returns {Promise<void>}
* @private
*/
async _applyFightingStyle(selectedPerkUuid) {
debugLog("Applying Fighting Style feature");
const pack = game.packs.get("vagabond.perks");
if (!pack) {
debugWarn("Perks compendium not found, cannot apply Fighting Style");
return;
}
// 1. Auto-grant Situational Awareness
const index = await pack.getIndex();
const saEntry = index.find((e) => e.name === "Situational Awareness");
if (saEntry) {
const saPerk = await pack.getDocument(saEntry._id);
if (saPerk) {
// Check if actor already has it
const alreadyHas = this.actor.items.some(
(i) => i.type === "perk" && i.name === "Situational Awareness"
);
if (!alreadyHas) {
debugLog("Granting Situational Awareness perk");
await this.actor.createEmbeddedDocuments("Item", [saPerk.toObject()]);
} else {
debugLog("Actor already has Situational Awareness");
}
}
} else {
debugWarn("Situational Awareness perk not found in compendium");
}
// 2. Add the selected training perk
if (selectedPerkUuid) {
try {
const perkDoc = await fromUuid(selectedPerkUuid);
if (perkDoc) {
debugLog(`Granting selected training perk: "${perkDoc.name}"`);
await this.actor.createEmbeddedDocuments("Item", [perkDoc.toObject()]);
}
} catch (err) {
console.error(`Failed to add training perk ${selectedPerkUuid}:`, err);
}
} else {
debugWarn("No training perk selected for Fighting Style");
}
}
/* -------------------------------------------- */
/* Static Methods */
/* -------------------------------------------- */
/**
* Create and render a level up dialog.
*
* @param {VagabondActor} actor - The actor leveling up
* @param {number} newLevel - The new level
* @param {number} oldLevel - The previous level
* @param {Object} options - Dialog options
* @returns {Promise<LevelUpDialog>} The rendered dialog
*/
static async create(actor, newLevel, oldLevel, options = {}) {
debugLog(`static create() called`, {
actorName: actor.name,
oldLevel,
newLevel,
});
const dialog = new this(actor, newLevel, oldLevel, options);
debugLog("Rendering dialog...");
const rendered = await dialog.render(true);
debugLog("Dialog rendered successfully");
return rendered;
}
}