diff --git a/PROJECT_ROADMAP.json b/PROJECT_ROADMAP.json index 05f7b7f..2d472cd 100644 --- a/PROJECT_ROADMAP.json +++ b/PROJECT_ROADMAP.json @@ -346,7 +346,7 @@ "tested": true, "priority": "high", "dependencies": ["2.2", "2.3", "1.8", "1.15"], - "notes": "Implemented via _onCreate/_preDelete lifecycle methods and updateActor hook. Made applyClassFeatures() idempotent. Commit 8afcf8c." + "notes": "Implemented via _onCreate lifecycle method and LevelUpDialog. Features with changes[] arrays auto-create ActiveEffects. applyClassFeatures() is idempotent, updateClassFeatures() handles level-up incrementally. Fixed duplicate item creation bug (dragDrop config). Design doc: NoteDiscovery gaming/vagabond-rpg/class-level-system-design.md. Prototype branch: prototype/class-level-system commit a7862be." }, { "id": "2.13", diff --git a/PROTOTYPE_PLAN.json b/PROTOTYPE_PLAN.json new file mode 100644 index 0000000..fd0330a --- /dev/null +++ b/PROTOTYPE_PLAN.json @@ -0,0 +1,124 @@ +{ + "prototype": { + "name": "Class & Character Progression Prototype", + "branch": "prototype/class-level-system", + "description": "Proof-of-concept implementations for Active Effects automation across classes, perks, ancestries, and caster progression", + "created": "2024-12-16", + "status": "in_progress", + "design_doc": "NoteDiscovery: gaming/vagabond-rpg/class-level-system-design.md" + }, + "tasks": [ + { + "id": "P1", + "name": "Class Level-Up with Active Effects", + "description": "Class features with changes[] arrays automatically create Active Effects on drop and level-up", + "status": "complete", + "commit": "a7862be", + "files_changed": [ + "module/documents/item.mjs", + "module/documents/actor.mjs", + "module/applications/level-up-dialog.mjs", + "module/sheets/base-actor-sheet.mjs", + "packs/_source/classes/fighter.json" + ], + "patterns_established": [ + "Features with changes[] become ActiveEffects via applyClassFeatures()", + "updateClassFeatures() handles level-up incrementally", + "Idempotency via checking existing effect flags", + "dragDrop config in DEFAULT_OPTIONS (not manual listeners)", + "ActiveEffect flags: vagabond.classFeature, className, featureName, featureLevel" + ], + "tested": true, + "notes": "Fighter Valor I/II/III reduces crit threshold cumulatively. Fixed duplicate item creation bug." + }, + { + "id": "P2", + "name": "Perk Active Effects", + "description": "Perks apply Active Effects when added to character, using same changes[] pattern as class features", + "status": "complete", + "dependencies": ["P1"], + "implementation_plan": [ + "Add changes[] array to PerkData schema if not present", + "Update VagabondItem._onCreate() to handle type === 'perk'", + "Create test perk with mechanical effect (e.g., 'Tough' adds +5 HP)", + "Verify effect applies on drop and removes on delete", + "Update level-up dialog perk selection to show effect preview" + ], + "files_to_modify": [ + "module/documents/item.mjs", + "module/data/perk.mjs", + "packs/_source/perks/ (create test perks)" + ], + "acceptance_criteria": [ + "Dropping perk on character creates ActiveEffect", + "Deleting perk removes its ActiveEffect", + "Perk effects stack with class effects", + "Level-up dialog perk selection works end-to-end" + ] + }, + { + "id": "P3", + "name": "Feature Choices (Fighting Style)", + "description": "Implement UI for features that require player choice, starting with Fighter's Fighting Style perk selection", + "status": "complete", + "dependencies": ["P2"], + "files_changed": [ + "module/applications/level-up-dialog.mjs", + "module/documents/item.mjs", + "module/data/item/class.mjs", + "templates/dialog/level-up.hbs" + ], + "patterns_established": [ + "Class features schema includes requiresChoice, choiceType, choiceFilter fields", + "_getFilteredPerksForChoice() filters perks by custom prerequisite text", + "ignorePrereqs flag allows bypassing all prerequisites for specific choices", + "Classes with choice features at level 1 show dialog on initial drop", + "_applyFightingStyle() auto-grants Situational Awareness + selected training perk", + "UUID must be constructed manually: Compendium.${pack.collection}.Item.${entry._id}" + ], + "tested": true, + "notes": "Fighting Style grants Situational Awareness AND one Melee/Ranged Training perk (ignoring prereqs). Fixed UUID null issue by constructing UUID from pack collection and entry ID." + }, + { + "id": "P4", + "name": "Ancestry Traits as Active Effects", + "description": "Ancestry traits with mechanical effects apply as Active Effects when ancestry is added to character", + "status": "complete", + "dependencies": ["P1"], + "files_changed": [ + "module/documents/item.mjs", + "module/data/item/ancestry.mjs", + "packs/_source/ancestries/dwarf.json" + ], + "patterns_established": [ + "Ancestry traits use same changes[] pattern as class features", + "applyAncestryTraits() creates effects with vagabond.ancestryTrait flag", + "_removeAncestryEffects() cleans up on ancestry deletion/replacement", + "Mode 5 (OVERRIDE) for boolean senses like darkvision" + ], + "tested": true, + "notes": "Dwarf Darksight (+darkvision) and Tough (+3 HP bonus) traits verified working" + }, + { + "id": "P5", + "name": "Caster Class Progression", + "description": "Verify mana and castingMax from class progression apply correctly for caster classes", + "status": "complete", + "dependencies": ["P1"], + "files_changed": ["module/sheets/base-actor-sheet.mjs"], + "patterns_established": [ + "_applyClassProgression() accumulates mana from progression entries", + "Mana.value set to max only on initial grant (when value === 0)", + "Form submission cleanup via #cleanNumericFields() prevents empty string validation errors" + ], + "tested": true, + "notes": "Wizard Level 1 = 4 mana, Level 2 = 8 mana. Fixed validation error for empty numeric inputs." + } + ], + "merge_criteria": [ + "All tasks complete and tested", + "No console errors in normal workflows", + "Design doc updated with new patterns", + "Compendiums rebuilt with test content" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index c38d104..ec7568c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,15 @@ services: - FOUNDRY_ADMIN_KEY=${FOUNDRY_ADMIN_KEY:-vagabond-dev} - FOUNDRY_LICENSE_KEY=${FOUNDRY_LICENSE_KEY} - CONTAINER_PRESERVE_CONFIG=true + # Run as host user to avoid permission issues with bind-mounted packs + - PUID=1000 + - PGID=1001 volumes: # Foundry data directory - ./foundrydata:/data # Mount system directly for hot reload development - - ./:/data/Data/systems/vagabond:ro + # Note: Removed :ro because LevelDB packs need write access for temp files + - ./:/data/Data/systems/vagabond ports: - "30000:30000" # Required for Docker-in-LXC or rootless podman diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs index 32c83e4..2f91ad9 100644 --- a/module/applications/_module.mjs +++ b/module/applications/_module.mjs @@ -9,3 +9,4 @@ export { default as AttackRollDialog } from "./attack-roll-dialog.mjs"; export { default as SaveRollDialog } from "./save-roll-dialog.mjs"; export { default as SpellCastDialog } from "./spell-cast-dialog.mjs"; export { default as FavorHinderDebug } from "./favor-hinder-debug.mjs"; +export { default as LevelUpDialog } from "./level-up-dialog.mjs"; diff --git a/module/applications/level-up-dialog.mjs b/module/applications/level-up-dialog.mjs new file mode 100644 index 0000000..e099c3a --- /dev/null +++ b/module/applications/level-up-dialog.mjs @@ -0,0 +1,656 @@ +/** + * 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} 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} 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} + * @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} + * @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} 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; + } +} diff --git a/module/data/item/ancestry.mjs b/module/data/item/ancestry.mjs index d6f7696..b9d16a9 100644 --- a/module/data/item/ancestry.mjs +++ b/module/data/item/ancestry.mjs @@ -43,6 +43,15 @@ export default class AncestryData extends VagabondItemBase { new fields.SchemaField({ name: new fields.StringField({ required: true }), description: new fields.HTMLField({ required: 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: [] } + ), }), { initial: [] } ), diff --git a/module/data/item/class.mjs b/module/data/item/class.mjs index 80bc50e..d6e1899 100644 --- a/module/data/item/class.mjs +++ b/module/data/item/class.mjs @@ -86,6 +86,10 @@ export default class ClassData extends VagabondItemBase { }), { initial: [] } ), + // Choice features - features that require player selection + requiresChoice: new fields.BooleanField({ initial: false }), + choiceType: new fields.StringField({ required: false, blank: true }), // "perk", "spell", etc. + choiceFilter: new fields.ObjectField({ required: false }), // Filter criteria for choices }), { initial: [] } ), diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index 8abe1bb..8939536 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -1,3 +1,14 @@ +import { LevelUpDialog } from "../applications/_module.mjs"; + +// Debug logging for level-up workflow - set to false to disable +const DEBUG_LEVELUP = true; +const debugLog = (...args) => { + if (DEBUG_LEVELUP) console.log("[VagabondActor]", ...args); +}; +const debugWarn = (...args) => { + if (DEBUG_LEVELUP) console.warn("[VagabondActor]", ...args); +}; + /** * VagabondActor Document Class * @@ -24,18 +35,33 @@ export default class VagabondActor extends Actor { * @override */ async _onUpdate(changed, options, userId) { + debugLog(`_onUpdate called for "${this.name}"`, { + changed, + options, + userId, + currentUserId: game.user.id, + }); + await super._onUpdate(changed, options, userId); // Only process for the updating user - if (game.user.id !== userId) return; + if (game.user.id !== userId) { + debugLog("Skipping - not the updating user"); + return; + } // Check for level change on characters if (this.type === "character" && changed.system?.level !== undefined) { const newLevel = changed.system.level; const oldLevel = options._previousLevel ?? 1; + debugLog(`Level change detected: ${oldLevel} -> ${newLevel}`); + if (newLevel !== oldLevel) { + debugLog("Calling _onLevelChange..."); await this._onLevelChange(newLevel, oldLevel); + } else { + debugLog("Level unchanged, skipping level change handling"); } } } @@ -46,30 +72,69 @@ export default class VagabondActor extends Actor { * @override */ async _preUpdate(changed, options, userId) { + debugLog(`_preUpdate called for "${this.name}"`, { + changed, + currentLevel: this.system?.level, + }); + await super._preUpdate(changed, options, userId); // Store current level for level change detection if (this.type === "character" && changed.system?.level !== undefined) { options._previousLevel = this.system.level; + debugLog(`Stored previous level: ${options._previousLevel}`); } } /** * Handle character level changes. - * Updates class features for all owned class items. + * Shows level-up dialog to display gained features and handle choices. * * @param {number} newLevel - The new character level * @param {number} oldLevel - The previous character level * @private */ async _onLevelChange(newLevel, oldLevel) { - // Get all class items - const classes = this.items.filter((i) => i.type === "class"); + debugLog(`_onLevelChange called: ${oldLevel} -> ${newLevel}`); - // Update features for each class - for (const classItem of classes) { - await classItem.updateClassFeatures(newLevel, oldLevel); + // Check if there are any classes that have features to process + const classes = this.items.filter((i) => i.type === "class"); + debugLog( + `Found ${classes.length} class(es):`, + classes.map((c) => c.name) + ); + + if (classes.length === 0) { + // No class, just notify + debugWarn("No classes found on actor - showing simple notification"); + ui.notifications.info(`${this.name} advanced to level ${newLevel}!`); + return; } + + // Log class feature info + for (const classItem of classes) { + const features = classItem.system.features || []; + const progression = classItem.system.progression || []; + debugLog(`Class "${classItem.name}" data:`, { + featuresCount: features.length, + features: features.map((f) => ({ + name: f.name, + level: f.level, + hasChanges: f.changes?.length > 0, + requiresChoice: f.requiresChoice, + })), + progression: progression.map((p) => ({ + level: p.level, + features: p.features, + })), + }); + } + + // Show level-up dialog to handle features and choices + // The dialog will call updateClassFeatures after user confirms + debugLog("Creating LevelUpDialog..."); + await LevelUpDialog.create(this, newLevel, oldLevel); + debugLog("LevelUpDialog created/displayed"); } /* -------------------------------------------- */ diff --git a/module/documents/item.mjs b/module/documents/item.mjs index 6ad923c..cfbcd64 100644 --- a/module/documents/item.mjs +++ b/module/documents/item.mjs @@ -1,3 +1,14 @@ +import LevelUpDialog from "../applications/level-up-dialog.mjs"; + +// Debug logging for level-up workflow - set to false to disable +const DEBUG_LEVELUP = false; +const debugLog = (...args) => { + if (DEBUG_LEVELUP) console.log("[VagabondItem]", ...args); +}; +const debugWarn = (...args) => { + if (DEBUG_LEVELUP) console.warn("[VagabondItem]", ...args); +}; + /** * VagabondItem Document Class * @@ -25,25 +36,95 @@ export default class VagabondItem extends Item { * @override */ async _onCreate(data, options, userId) { + debugLog(`_onCreate called for "${this.name}" (type: ${this.type})`, { + parentType: this.parent?.type, + actorId: this.actor?.id, + itemId: this.id, + userId, + currentUserId: game.user.id, + }); + await super._onCreate(data, options, userId); // Only process for the creating user - if (game.user.id !== userId) return; + if (game.user.id !== userId) { + debugLog("Skipping - not the creating user"); + return; + } // Apply class features when class is added to a character // Check that actor still exists (may be deleted in tests) if (this.type === "class" && this.parent?.type === "character" && this.actor?.id) { + debugLog("Class added to character - checking for choice features..."); + + // Check if there are choice features at the actor's current level + const currentLevel = this.actor.system.level || 1; + const features = this.system.features || []; + + debugLog( + "Features array:", + features.map((f) => ({ + name: f.name, + level: f.level, + requiresChoice: f.requiresChoice, + hasRequiresChoice: "requiresChoice" in f, + })) + ); + + const choiceFeatures = features.filter( + (f) => f.level <= currentLevel && f.requiresChoice === true + ); + + if (choiceFeatures.length > 0) { + // Show level-up dialog for choice features + debugLog(`Found ${choiceFeatures.length} choice features, showing dialog...`); + try { + // Use oldLevel=0 to indicate this is initial class assignment + await LevelUpDialog.create(this.actor, currentLevel, 0); + } catch (err) { + console.error("Failed to show level-up dialog:", err); + } + } else { + // No choices needed, apply features directly + debugLog("No choice features - applying initial features directly..."); + try { + const effects = await this.applyClassFeatures(); + debugLog(`Applied ${effects.length} initial Active Effects`); + } catch (err) { + // Actor may have been deleted during tests - silently ignore + if (!err.message?.includes("does not exist")) throw err; + debugWarn("Actor was deleted during feature application"); + } + } + } + + // Apply perk effects when perk is added to a character + if (this.type === "perk" && this.parent?.type === "character" && this.actor?.id) { + debugLog("Perk added to character - applying effects..."); try { - await this.applyClassFeatures(); + const effects = await this.applyPerkEffects(); + debugLog(`Applied ${effects.length} perk Active Effects`); } catch (err) { - // Actor may have been deleted during tests - silently ignore if (!err.message?.includes("does not exist")) throw err; + debugWarn("Actor was deleted during perk effect application"); + } + } + + // Apply ancestry traits when ancestry is added to a character + if (this.type === "ancestry" && this.parent?.type === "character" && this.actor?.id) { + debugLog("Ancestry added to character - applying traits..."); + try { + const effects = await this.applyAncestryTraits(); + debugLog(`Applied ${effects.length} ancestry trait Active Effects`); + } catch (err) { + if (!err.message?.includes("does not exist")) throw err; + debugWarn("Actor was deleted during ancestry trait application"); } } } /** - * Handle item deletion. For class items, remove associated Active Effects. + * Handle item deletion. For class/perk/ancestry items, remove associated Active Effects. * * @override */ @@ -53,6 +134,16 @@ export default class VagabondItem extends Item { await this._removeClassEffects(); } + // Remove perk effects before deletion + if (this.type === "perk" && this.parent?.type === "character") { + await this._removePerkEffects(); + } + + // Remove ancestry trait effects before deletion + if (this.type === "ancestry" && this.parent?.type === "character") { + await this._removeAncestryEffects(); + } + return super._preDelete(options, userId); } @@ -113,12 +204,13 @@ export default class VagabondItem extends Item { if (!system) return; // Determine attack skill based on properties + // Note: system.properties is a SchemaField (object with boolean values), not an array if (!system.attackSkill) { - if (system.properties?.includes("finesse")) { + if (system.properties?.finesse) { system.attackSkill = "finesse"; - } else if (system.properties?.includes("brawl")) { + } else if (system.properties?.brawl) { system.attackSkill = "brawl"; - } else if (system.gripType === "ranged" || system.properties?.includes("thrown")) { + } else if (system.gripType === "ranged" || system.properties?.thrown) { system.attackSkill = "ranged"; } else { system.attackSkill = "melee"; @@ -451,59 +543,66 @@ export default class VagabondItem extends Item { return { met: true, missing: [] }; } - const prereqs = this.system.prerequisites || []; + const prereqs = this.system.prerequisites; + if (!prereqs) { + return { met: true, missing: [] }; + } + const missing = []; - for (const prereq of prereqs) { - let met = false; - - switch (prereq.type) { - case "stat": { - // Check stat minimum - const statValue = actor.system.stats?.[prereq.stat]?.value || 0; - met = statValue >= (prereq.value || 0); - break; - } - - case "training": { - // Check if trained in skill - const skillData = actor.system.skills?.[prereq.skill]; - met = skillData?.trained === true; - break; - } - - case "spell": { - // Check if actor knows the spell - const knownSpells = actor.getSpells?.() || []; - met = knownSpells.some((s) => s.name === prereq.spellName); - break; - } - - case "perk": { - // Check if actor has the prerequisite perk - const perks = actor.getPerks?.() || []; - met = perks.some((p) => p.name === prereq.perkName); - break; - } - - case "level": - // Check minimum level - met = (actor.system.level || 1) >= (prereq.value || 1); - break; - - case "class": { - // Check if actor has the class - const classes = actor.getClasses?.() || []; - met = classes.some((c) => c.name === prereq.className); - break; + // Check stat requirements + if (prereqs.stats) { + for (const [stat, required] of Object.entries(prereqs.stats)) { + if (required !== null && required > 0) { + const actorStat = actor.system.stats?.[stat]?.value || 0; + if (actorStat < required) { + const statLabel = stat.charAt(0).toUpperCase() + stat.slice(1); + missing.push({ + type: "stat", + stat, + value: required, + label: `${statLabel} ${required}`, + }); + } } } + } - if (!met) { - missing.push(prereq); + // Check skill training requirements + if (prereqs.trainedSkills) { + for (const skillId of prereqs.trainedSkills) { + const skill = actor.system.skills?.[skillId]; + if (!skill?.trained) { + missing.push({ type: "training", skill: skillId, label: `Trained in ${skillId}` }); + } } } + // Check spell requirements + if (prereqs.spells?.length > 0) { + const knownSpells = actor.items.filter((i) => i.type === "spell"); + for (const spellName of prereqs.spells) { + if (!knownSpells.some((s) => s.name === spellName)) { + missing.push({ type: "spell", spellName, label: `Spell: ${spellName}` }); + } + } + } + + // Check perk requirements + if (prereqs.perks?.length > 0) { + const actorPerks = actor.items.filter((i) => i.type === "perk"); + for (const perkName of prereqs.perks) { + if (!actorPerks.some((p) => p.name === perkName)) { + missing.push({ type: "perk", perkName, label: `Perk: ${perkName}` }); + } + } + } + + // Custom requirements are always flagged as missing (need manual review) + if (prereqs.custom) { + missing.push({ type: "custom", label: prereqs.custom }); + } + return { met: missing.length === 0, missing, @@ -555,19 +654,43 @@ export default class VagabondItem extends Item { * Called when class is added to character or when level changes. * This method is idempotent - it won't create duplicate effects. * + * Note: Race condition protection is handled at the _onCreate level via + * #initialFeaturesApplied map, which guards against duplicate calls. + * * @param {number} [targetLevel] - Level to apply features for (defaults to actor's level) * @returns {Promise} Created effects */ async applyClassFeatures(targetLevel = null) { - if (this.type !== "class" || !this.actor) return []; + debugLog(`applyClassFeatures called for class "${this.name}"`, { + targetLevel, + actorLevel: this.actor?.system?.level, + }); + if (this.type !== "class" || !this.actor) { + debugWarn("applyClassFeatures: Not a class or no actor"); + return []; + } const level = targetLevel ?? this.actor.system.level ?? 1; const features = this.system.features || []; + debugLog(`Processing features for level ${level}`, { + totalFeatures: features.length, + allFeatures: features.map((f) => ({ + name: f.name, + level: f.level, + changesCount: f.changes?.length || 0, + })), + }); + // Get features at or below current level that have changes const applicableFeatures = features.filter((f) => f.level <= level && f.changes?.length > 0); + debugLog( + `Applicable features (level <= ${level} with changes):`, + applicableFeatures.map((f) => f.name) + ); if (applicableFeatures.length === 0) { + debugLog("No applicable features with changes - applying progression only"); // Still apply progression even if no features with changes await this._applyClassProgression(level); await this._applyTrainedSkills(); @@ -579,9 +702,16 @@ export default class VagabondItem extends Item { const existingFeatureNames = new Set( existingEffects.map((e) => e.flags?.vagabond?.featureName) ); + debugLog(`Existing effects from this class:`, [...existingFeatureNames]); + const newFeatures = applicableFeatures.filter((f) => !existingFeatureNames.has(f.name)); + debugLog( + `New features to apply:`, + newFeatures.map((f) => f.name) + ); if (newFeatures.length === 0) { + debugLog("All features already applied - updating progression only"); // All features already applied, just update progression await this._applyClassProgression(level); await this._applyTrainedSkills(); @@ -609,8 +739,20 @@ export default class VagabondItem extends Item { }, })); + debugLog( + "Creating Active Effects:", + effectsData.map((e) => ({ + name: e.name, + changes: e.changes, + })) + ); + // Create the effects const createdEffects = await this.actor.createEmbeddedDocuments("ActiveEffect", effectsData); + debugLog( + `Created ${createdEffects.length} Active Effects:`, + createdEffects.map((e) => e.name) + ); // Also update actor's mana and casting max from class progression await this._applyClassProgression(level); @@ -630,16 +772,35 @@ export default class VagabondItem extends Item { * @returns {Promise} Newly created effects */ async updateClassFeatures(newLevel, oldLevel) { - if (this.type !== "class" || !this.actor) return []; + debugLog(`updateClassFeatures called for class "${this.name}"`, { + oldLevel, + newLevel, + }); + + if (this.type !== "class" || !this.actor) { + debugWarn("updateClassFeatures: Not a class or no actor"); + return []; + } const features = this.system.features || []; + debugLog(`Total features in class: ${features.length}`); // Find features gained between old and new level const newFeatures = features.filter( (f) => f.level > oldLevel && f.level <= newLevel && f.changes?.length > 0 ); + debugLog( + `Features gained between level ${oldLevel} and ${newLevel}:`, + newFeatures.map((f) => ({ + name: f.name, + level: f.level, + changesCount: f.changes?.length || 0, + })) + ); + if (newFeatures.length === 0) { + debugLog("No new features with changes - updating progression only"); // Still update progression stats even if no new features await this._applyClassProgression(newLevel); return []; @@ -666,7 +827,19 @@ export default class VagabondItem extends Item { }, })); + debugLog( + "Creating Active Effects for level-up:", + effectsData.map((e) => ({ + name: e.name, + changes: e.changes, + })) + ); + const createdEffects = await this.actor.createEmbeddedDocuments("ActiveEffect", effectsData); + debugLog( + `Created ${createdEffects.length} Active Effects:`, + createdEffects.map((e) => e.name) + ); // Update mana and casting max await this._applyClassProgression(newLevel); @@ -690,6 +863,176 @@ export default class VagabondItem extends Item { } } + /* -------------------------------------------- */ + /* Perk Helpers */ + /* -------------------------------------------- */ + + /** + * Apply perk effects as Active Effects when perk is added to character. + * This method is idempotent - it won't create duplicate effects. + * + * @returns {Promise} Created effects + */ + async applyPerkEffects() { + debugLog(`applyPerkEffects called for perk "${this.name}"`, { + hasChanges: this.system.changes?.length > 0, + }); + + if (this.type !== "perk" || !this.actor) { + debugWarn("applyPerkEffects: Not a perk or no actor"); + return []; + } + + const changes = this.system.changes || []; + if (changes.length === 0) { + debugLog("Perk has no mechanical changes - skipping effect creation"); + return []; + } + + // Check if effect already exists (idempotent) + const existingEffect = this.actor.effects.find((e) => e.origin === this.uuid); + if (existingEffect) { + debugLog("Perk effect already exists - skipping"); + return []; + } + + // Build Active Effect data + const effectData = { + name: this.name, + icon: this.img || "icons/svg/upgrade.svg", + origin: this.uuid, + changes: changes.map((change) => ({ + key: change.key, + mode: change.mode ?? 2, // Default to ADD mode + value: String(change.value), + priority: change.priority ?? null, + })), + flags: { + vagabond: { + perkEffect: true, + perkName: this.name, + }, + }, + }; + + debugLog("Creating perk Active Effect:", { + name: effectData.name, + changes: effectData.changes, + }); + + const createdEffects = await this.actor.createEmbeddedDocuments("ActiveEffect", [effectData]); + debugLog(`Created ${createdEffects.length} perk Active Effects`); + + return createdEffects; + } + + /** + * Remove all Active Effects originating from this perk. + * + * @private + * @returns {Promise} + */ + async _removePerkEffects() { + if (!this.actor) return; + + const perkEffects = this.actor.effects.filter((e) => e.origin === this.uuid); + if (perkEffects.length > 0) { + debugLog(`Removing ${perkEffects.length} perk effects for "${this.name}"`); + const ids = perkEffects.map((e) => e.id); + await this.actor.deleteEmbeddedDocuments("ActiveEffect", ids); + } + } + + /* -------------------------------------------- */ + /* Ancestry Helpers */ + /* -------------------------------------------- */ + + /** + * Apply ancestry trait effects as Active Effects when ancestry is added to character. + * Each trait with a changes[] array creates a separate Active Effect. + * This method is idempotent - it won't create duplicate effects. + * + * @returns {Promise} Created effects + */ + async applyAncestryTraits() { + debugLog(`applyAncestryTraits called for ancestry "${this.name}"`, { + traitsCount: this.system.traits?.length || 0, + }); + + if (this.type !== "ancestry" || !this.actor) { + debugWarn("applyAncestryTraits: Not an ancestry or no actor"); + return []; + } + + const traits = this.system.traits || []; + const traitsWithChanges = traits.filter((t) => t.changes?.length > 0); + + if (traitsWithChanges.length === 0) { + debugLog("Ancestry has no traits with mechanical changes - skipping effect creation"); + return []; + } + + // Check for existing effects (idempotent) + const existingEffects = this.actor.effects.filter((e) => e.origin === this.uuid); + const existingTraitNames = new Set(existingEffects.map((e) => e.flags?.vagabond?.traitName)); + + const newTraits = traitsWithChanges.filter((t) => !existingTraitNames.has(t.name)); + if (newTraits.length === 0) { + debugLog("All ancestry traits already applied - skipping"); + return []; + } + + // Build Active Effect data for each trait + const effectsData = newTraits.map((trait) => ({ + name: `${this.name}: ${trait.name}`, + icon: this.img || "icons/svg/mystery-man.svg", + origin: this.uuid, + changes: trait.changes.map((change) => ({ + key: change.key, + mode: change.mode ?? 2, + value: String(change.value), + priority: change.priority ?? null, + })), + flags: { + vagabond: { + ancestryTrait: true, + ancestryName: this.name, + traitName: trait.name, + }, + }, + })); + + debugLog( + "Creating ancestry trait Active Effects:", + effectsData.map((e) => ({ + name: e.name, + changes: e.changes, + })) + ); + + const createdEffects = await this.actor.createEmbeddedDocuments("ActiveEffect", effectsData); + debugLog(`Created ${createdEffects.length} ancestry trait Active Effects`); + + return createdEffects; + } + + /** + * Remove all Active Effects originating from this ancestry. + * + * @private + * @returns {Promise} + */ + async _removeAncestryEffects() { + if (!this.actor) return; + + const ancestryEffects = this.actor.effects.filter((e) => e.origin === this.uuid); + if (ancestryEffects.length > 0) { + debugLog(`Removing ${ancestryEffects.length} ancestry effects for "${this.name}"`); + const ids = ancestryEffects.map((e) => e.id); + await this.actor.deleteEmbeddedDocuments("ActiveEffect", ids); + } + } + /** * Apply class progression stats (mana, casting max) to the actor. * diff --git a/module/sheets/base-actor-sheet.mjs b/module/sheets/base-actor-sheet.mjs index bd28ba9..63943fd 100644 --- a/module/sheets/base-actor-sheet.mjs +++ b/module/sheets/base-actor-sheet.mjs @@ -67,6 +67,10 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor modifyResource: VagabondActorSheet.#onModifyResource, toggleTrained: VagabondActorSheet.#onToggleTrained, }, + // Drag-drop configuration - use Foundry's built-in system + // Setting to empty array disables ActorSheetV2's default handling + // so we can use our own _onDrop override + dragDrop: [{ dropSelector: null }], }; /** @override */ @@ -416,7 +420,9 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor } /** - * Set up drag-and-drop handlers. + * Set up drag-and-drop handlers for dragging items FROM this sheet. + * Drop handling is configured via DEFAULT_OPTIONS.dragDrop and uses + * the _onDrop method override - we don't add manual listeners for drops. * @protected */ _setupDragDrop() { @@ -426,10 +432,9 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor el.setAttribute("draggable", "true"); el.addEventListener("dragstart", this._onDragStart.bind(this)); } - - // Enable dropping items onto the sheet - this.element.addEventListener("dragover", this._onDragOver.bind(this)); - this.element.addEventListener("drop", this._onDrop.bind(this)); + // Note: Drop handling is managed by Foundry's dragDrop configuration + // in DEFAULT_OPTIONS, which calls our _onDrop override. We don't add + // manual drop listeners here to avoid duplicate item creation. } /** @@ -611,9 +616,40 @@ export default class VagabondActorSheet extends HandlebarsApplicationMixin(Actor static async #onFormSubmit(event, form, formData) { const sheet = this; const updateData = foundry.utils.expandObject(formData.object); + + // Clean up numeric fields that may have empty string values + // This can happen when number inputs are cleared by the user + VagabondActorSheet.#cleanNumericFields(updateData); + await sheet.actor.update(updateData); } + /** + * Recursively clean numeric fields in update data. + * Empty strings are converted to 0 for numeric resource fields. + * @param {Object} obj - Object to clean + * @param {string} path - Current path for debugging + * @private + */ + static #cleanNumericFields(obj, path = "") { + if (!obj || typeof obj !== "object") return; + + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key; + + if (typeof value === "object" && value !== null) { + // Recurse into nested objects + VagabondActorSheet.#cleanNumericFields(value, currentPath); + } else if (value === "" || value === null) { + // Check if this should be a numeric field based on common patterns + const numericKeys = ["value", "max", "bonus", "min", "base", "level", "castingMax"]; + if (numericKeys.includes(key)) { + obj[key] = 0; + } + } + } + } + /** * Handle skill roll action. * @param {PointerEvent} event diff --git a/module/vagabond.mjs b/module/vagabond.mjs index 729fe47..e8431e3 100644 --- a/module/vagabond.mjs +++ b/module/vagabond.mjs @@ -367,6 +367,12 @@ Hooks.once("init", () => { const n = Number(num) || 0; return n >= 0 ? `+${n}` : `${n}`; }); + + // Join array elements with separator + Handlebars.registerHelper("join", (arr, separator) => { + if (!Array.isArray(arr)) return ""; + return arr.join(separator); + }); }); /* -------------------------------------------- */ diff --git a/package-lock.json b/package-lock.json index 0202d13..c44c270 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { + "@foundryvtt/foundryvtt-cli": "^3.0.2", "eslint": "^8.57.0", "husky": "^9.1.0", "lint-staged": "^15.2.0", @@ -79,6 +80,41 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@foundryvtt/foundryvtt-cli": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@foundryvtt/foundryvtt-cli/-/foundryvtt-cli-3.0.2.tgz", + "integrity": "sha512-coh4Cf4FD/GHxk2QMsd+3wLMivNeih4rfkbZy8CaYjdlpo6iciFQwxLqznZWtn+5p06zekvS2xLUF55NnbXQDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "classic-level": "^1.4.1", + "esm": "^3.2.25", + "js-yaml": "^4.1.0", + "mkdirp": "^3.0.1", + "nedb-promises": "^6.2.3", + "yargs": "^17.7.2" + }, + "bin": { + "fvtt": "fvtt.mjs" + }, + "engines": { + "node": ">17.0.0" + } + }, + "node_modules/@foundryvtt/foundryvtt-cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -465,6 +501,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@seald-io/binary-search-tree": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@seald-io/binary-search-tree/-/binary-search-tree-1.0.3.tgz", + "integrity": "sha512-qv3jnwoakeax2razYaMsGI/luWdliBLHTdC6jU55hQt1hcFqzauH/HsBollQ7IR4ySTtYhT+xyHoijpA16C+tA==", + "dev": true + }, + "node_modules/@seald-io/nedb": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@seald-io/nedb/-/nedb-4.1.2.tgz", + "integrity": "sha512-bDr6TqjBVS2rDyYM9CPxAnotj5FuNL9NF8o7h7YyFXM7yruqT4ddr+PkSb2mJvvw991bqdftazkEo38gykvaww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@seald-io/binary-search-tree": "^1.0.3", + "localforage": "^1.10.0", + "util": "^0.12.5" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -472,6 +526,25 @@ "dev": true, "license": "ISC" }, + "node_modules/abstract-level": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-1.0.4.tgz", + "integrity": "sha512-eUP/6pbXBkMbXFdx4IH2fVgvB7M0JvR7/lIL33zcs0IBcwjdzSSl31TOJsaCzmKSSDF9h8QYSOJux4Nd4YJqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "catering": "^2.1.0", + "is-buffer": "^2.0.5", + "level-supports": "^4.0.0", + "level-transcoder": "^1.0.1", + "module-error": "^1.0.1", + "queue-microtask": "^1.2.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -561,6 +634,22 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -568,6 +657,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -592,6 +702,81 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -602,6 +787,16 @@ "node": ">=6" } }, + "node_modules/catering": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/catering/-/catering-2.1.1.tgz", + "integrity": "sha512-K7Qy8O9p76sL3/3m7/zLKbRkyOlSZAgzEaLhyj2mXS8PsCud2Eo4hAb8aLtZqHh0QGqLcb9dlJSu6lHRVENm1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -635,6 +830,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classic-level": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.4.1.tgz", + "integrity": "sha512-qGx/KJl3bvtOHrGau2WklEZuXhS3zme+jf+fsu6Ej7W7IP/C49v7KNlWIsT1jZu0YnfzSIYDGcEWpCa1wKGWXQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-level": "^1.0.2", + "catering": "^2.1.0", + "module-error": "^1.0.1", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -668,6 +881,71 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -752,6 +1030,24 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -779,6 +1075,21 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -799,6 +1110,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -899,6 +1253,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -1090,6 +1454,22 @@ "dev": true, "license": "ISC" }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1097,6 +1477,36 @@ "dev": true, "license": "ISC" }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -1110,6 +1520,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", @@ -1174,6 +1623,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -1191,6 +1653,61 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -1217,6 +1734,27 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1227,6 +1765,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", @@ -1280,6 +1825,60 @@ "dev": true, "license": "ISC" }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1303,6 +1902,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1336,6 +1955,25 @@ "node": ">=8" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -1349,6 +1987,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1400,6 +2054,30 @@ "json-buffer": "3.0.1" } }, + "node_modules/level-supports": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-4.0.1.tgz", + "integrity": "sha512-PbXpve8rKeNcZ9C1mUicC9auIYFyGpkV9/i6g76tLgANwWhtG2v7I4xNBUlkn3lE2/dZF3Pi0ygYGtLc4RXXdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", + "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -1414,6 +2092,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1486,6 +2174,16 @@ "node": ">=18.0.0" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -1604,6 +2302,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -1664,6 +2372,32 @@ "node": "*" } }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/module-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", + "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1671,6 +2405,13 @@ "dev": true, "license": "MIT" }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1678,6 +2419,16 @@ "dev": true, "license": "MIT" }, + "node_modules/nedb-promises": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/nedb-promises/-/nedb-promises-6.2.3.tgz", + "integrity": "sha512-enq0IjNyBz9Qy9W/QPCcLGh/QORGBjXbIeZeWvIjO3OMLyAvlKT3hiJubP2BKEiFniUlR3L01o18ktqgn5jxqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@seald-io/nedb": "^4.0.2" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1686,6 +2437,18 @@ "license": "MIT", "optional": true }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "dev": true, + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -1860,6 +2623,16 @@ "node": ">=0.10" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -1931,6 +2704,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2033,6 +2816,24 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sass": { "version": "1.96.0", "resolved": "https://registry.npmjs.org/sass/-/sass-1.96.0.tgz", @@ -2054,6 +2855,24 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2295,6 +3114,20 @@ "punycode": "^2.1.0" } }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2311,6 +3144,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -2388,6 +3243,16 @@ "dev": true, "license": "ISC" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", @@ -2404,6 +3269,67 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 8a7f393..a3a131b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,13 @@ "version": "0.1.0", "description": "Foundry VTT system for Vagabond RPG", "scripts": { - "build": "sass styles/scss/vagabond.scss styles/vagabond.css --style=compressed", + "build": "npm run build:css && npm run build:packs", + "build:css": "sass styles/scss/vagabond.scss styles/vagabond.css --style=compressed", + "build:packs": "fvtt package workon vagabond --type System && npm run build:pack:ancestries && npm run build:pack:classes && npm run build:pack:spells && npm run build:pack:perks", + "build:pack:ancestries": "fvtt package pack -n ancestries -t Item --in packs/_source/ancestries --out packs", + "build:pack:classes": "fvtt package pack -n classes -t Item --in packs/_source/classes --out packs", + "build:pack:spells": "fvtt package pack -n spells -t Item --in packs/_source/spells --out packs", + "build:pack:perks": "fvtt package pack -n perks -t Item --in packs/_source/perks --out packs", "watch": "sass styles/scss/vagabond.scss styles/vagabond.css --watch --style=expanded --source-map", "lint": "eslint module/", "lint:fix": "eslint module/ --fix", @@ -23,6 +29,7 @@ }, "homepage": "https://github.com/calcorum/vagabond-rpg-foundryvtt#readme", "devDependencies": { + "@foundryvtt/foundryvtt-cli": "^3.0.2", "eslint": "^8.57.0", "husky": "^9.1.0", "lint-staged": "^15.2.0", diff --git a/packs/_source/ancestries/draken.json b/packs/_source/ancestries/draken.json new file mode 100755 index 0000000..14d5bc7 --- /dev/null +++ b/packs/_source/ancestries/draken.json @@ -0,0 +1,26 @@ +{ + "_id": "vagabondAnceDraken", + "_key": "!items!vagabondAnceDraken", + "name": "Draken", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Draken are dragon-blooded humanoids with scales, claws, and the ability to breathe elemental energy. Their draconic heritage grants them natural armor and resistance to one damage type.

", + "beingType": "cryptid", + "size": "medium", + "traits": [ + { + "name": "Breath Attack", + "description": "

As an Action, you can breathe elemental energy in a 15-foot cone, dealing 2d6! damage of your chosen type (fire, cold, shock, or acid, chosen at character creation). Targets may make a Reflex save to take half damage.

" + }, + { + "name": "Scale", + "description": "

Your natural scales grant you +1 Armor.

" + }, + { + "name": "Draconic Resilience", + "description": "

You take half damage from one damage type of your choice (matching your breath weapon).

" + } + ] + } +} diff --git a/packs/_source/ancestries/dwarf.json b/packs/_source/ancestries/dwarf.json new file mode 100755 index 0000000..551b7e8 --- /dev/null +++ b/packs/_source/ancestries/dwarf.json @@ -0,0 +1,41 @@ +{ + "_id": "vagabondAnceDwarf", + "_key": "!items!vagabondAnceDwarf", + "name": "Dwarf", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Stout and resilient, dwarves are known for their toughness and resistance to adversity. Their sturdy constitution makes them excellent warriors and craftsmen.

", + "beingType": "humanlike", + "size": "medium", + "traits": [ + { + "name": "Darksight", + "description": "

You can see in darkness as if it were dim light.

", + "changes": [ + { + "key": "system.senses.darkvision", + "mode": 5, + "value": "true" + } + ] + }, + { + "name": "Sturdy", + "description": "

You gain +1 to saves against Fear, Sickened, and Shove effects.

", + "changes": [] + }, + { + "name": "Tough", + "description": "

You gain +3 additional maximum HP.

", + "changes": [ + { + "key": "system.resources.hp.bonus", + "mode": 2, + "value": "3" + } + ] + } + ] + } +} diff --git a/packs/_source/ancestries/elf.json b/packs/_source/ancestries/elf.json new file mode 100755 index 0000000..16121c3 --- /dev/null +++ b/packs/_source/ancestries/elf.json @@ -0,0 +1,26 @@ +{ + "_id": "vagabondAnceElf00", + "_key": "!items!vagabondAnceElf00", + "name": "Elf", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Elves are graceful fae beings with an innate connection to magic. Their keen senses and natural attunement to the arcane make them exceptional spellcasters and perceptive scouts.

", + "beingType": "fae", + "size": "medium", + "traits": [ + { + "name": "Ascendancy", + "description": "

You are Trained in one Skill of your choice.

" + }, + { + "name": "Elven Eyes", + "description": "

You gain Favor on sight-based Detect checks.

" + }, + { + "name": "Naturally Attuned", + "description": "

You know one Spell of your choice (even if you are not a caster).

" + } + ] + } +} diff --git a/packs/_source/ancestries/goblin.json b/packs/_source/ancestries/goblin.json new file mode 100755 index 0000000..40d26e8 --- /dev/null +++ b/packs/_source/ancestries/goblin.json @@ -0,0 +1,26 @@ +{ + "_id": "vagabondAnceGoblin", + "_key": "!items!vagabondAnceGoblin", + "name": "Goblin", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Goblins are small, cunning creatures who thrive in darkness and refuse. Their ability to see in the dark and resistance to toxins makes them surprisingly resilient survivors.

", + "beingType": "cryptid", + "size": "small", + "traits": [ + { + "name": "Darksight", + "description": "

You can see in darkness as if it were dim light.

" + }, + { + "name": "Nimble", + "description": "

Your base Speed is increased by 5.

" + }, + { + "name": "Scavenger", + "description": "

You gain Favor on saves against the Sickened condition.

" + } + ] + } +} diff --git a/packs/_source/ancestries/halfling.json b/packs/_source/ancestries/halfling.json new file mode 100755 index 0000000..fb1ed36 --- /dev/null +++ b/packs/_source/ancestries/halfling.json @@ -0,0 +1,26 @@ +{ + "_id": "vagabondAnceHalfli", + "_key": "!items!vagabondAnceHalfli", + "name": "Halfling", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Small but surprisingly lucky, halflings are nimble folk who can slip through tight spaces and always seem to land on their feet. Their natural good fortune is legendary.

", + "beingType": "humanlike", + "size": "small", + "traits": [ + { + "name": "Nimble", + "description": "

Your base Speed is increased by 5.

" + }, + { + "name": "Squat", + "description": "

You can move through spaces occupied by larger creatures.

" + }, + { + "name": "Tricksy", + "description": "

When you take a Rest, you regain +1 additional Luck.

" + } + ] + } +} diff --git a/packs/_source/ancestries/human.json b/packs/_source/ancestries/human.json new file mode 100755 index 0000000..d82d9f0 --- /dev/null +++ b/packs/_source/ancestries/human.json @@ -0,0 +1,22 @@ +{ + "_id": "vagabondAnceHuman", + "_key": "!items!vagabondAnceHuman", + "name": "Human", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Humans are the most common and adaptable of all the peoples. Their knack for learning and strong potential make them capable of excelling in any field.

", + "beingType": "humanlike", + "size": "medium", + "traits": [ + { + "name": "Knack", + "description": "

You gain one Perk and Training in one Skill of your choice.

" + }, + { + "name": "Strong Potential", + "description": "

You gain +1 to one Stat of your choice (maximum 7).

" + } + ] + } +} diff --git a/packs/_source/ancestries/orc.json b/packs/_source/ancestries/orc.json new file mode 100755 index 0000000..5b0a878 --- /dev/null +++ b/packs/_source/ancestries/orc.json @@ -0,0 +1,26 @@ +{ + "_id": "vagabondAnceOrc000", + "_key": "!items!vagabondAnceOrc000", + "name": "Orc", + "type": "ancestry", + "img": "icons/svg/mystery-man.svg", + "system": { + "description": "

Orcs are powerful, hulking warriors with a natural talent for physical confrontation. Their impressive size allows them to carry more equipment and overpower foes in close combat.

", + "beingType": "cryptid", + "size": "medium", + "traits": [ + { + "name": "Darksight", + "description": "

You can see in darkness as if it were dim light.

" + }, + { + "name": "Beefy", + "description": "

You gain Favor on Grapple and Shove attempts.

" + }, + { + "name": "Hulking", + "description": "

You have +2 additional Item Slots.

" + } + ] + } +} diff --git a/packs/_source/classes/alchemist.json b/packs/_source/classes/alchemist.json new file mode 100755 index 0000000..2a02255 --- /dev/null +++ b/packs/_source/classes/alchemist.json @@ -0,0 +1,89 @@ +{ + "_id": "vagabondClsAlchem", + "_key": "!items!vagabondClsAlchem", + "name": "Alchemist", + "type": "class", + "img": "icons/svg/flask.svg", + "system": { + "description": "

Alchemists are masters of chemical warfare and crafting, using their knowledge to create powerful potions, bombs, and elixirs that devastate their foes or aid their allies.

", + "keyStat": "reason", + "actionStyle": "craft", + "zone": "backline", + "trainedSkills": ["craft"], + "startingPack": "

Alchemist or Assassin

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Alchemy", "Catalyze"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Eureka"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Potency"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Mix"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Big Bang"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Prima Materia"] } + ], + "features": [ + { + "name": "Alchemy", + "level": 1, + "description": "

You can attack with alchemical items using Craft. Formulae: Choose 4 alchemical items with a value no higher than (your Alchemist Level × 50s). You only need to provide 5s of materials to Craft these items and have Alchemy Tools equipped. You learn to Craft 1 other alchemical item this way every 2 Levels in this Class hereafter.

", + "passive": true, + "changes": [] + }, + { + "name": "Catalyze", + "level": 1, + "description": "

You gain the Deft Hands Perk, and you can Craft alchemical items with the Use Action.

", + "passive": true, + "changes": [] + }, + { + "name": "Eureka", + "level": 2, + "description": "

You gain a Studied die when you Crit on a Craft Check.

", + "passive": true, + "changes": [] + }, + { + "name": "Potency", + "level": 4, + "description": "

The damage and healing dice of your alchemical items can explode.

", + "passive": true, + "changes": [] + }, + { + "name": "Mix", + "level": 6, + "description": "

You can take the Use Action to combine two alchemical items together, causing their effects to both occur when you Use the combined item. This combined item lasts for the Round, then goes inert.

", + "passive": true, + "changes": [] + }, + { + "name": "Big Bang", + "level": 8, + "description": "

You gain a d6 bonus to the damage and healing of your alchemical items, and they can explode on a roll of their two highest values.

", + "passive": true, + "changes": [] + }, + { + "name": "Prima Materia", + "level": 10, + "description": "

Once per Day, you can use your Action or skip your Move to Craft an alchemical item with a value as high as 10g without materials.

", + "passive": true, + "changes": [] + } + ], + "customResource": { + "name": "Formulae Known", + "max": "4 + floor(@classes.alchemist.level / 2)" + } + } +} diff --git a/packs/_source/classes/barbarian.json b/packs/_source/classes/barbarian.json new file mode 100755 index 0000000..889dee3 --- /dev/null +++ b/packs/_source/classes/barbarian.json @@ -0,0 +1,80 @@ +{ + "_id": "vagabondClsBarbar", + "_key": "!items!vagabondClsBarbar", + "name": "Barbarian", + "type": "class", + "img": "icons/svg/combat.svg", + "system": { + "description": "

Barbarians are ferocious warriors who channel their rage into devastating attacks. Their fury makes them formidable frontline bruisers who can shrug off damage and strike fear into their enemies.

", + "keyStat": "might", + "actionStyle": "attack", + "zone": "frontline", + "trainedSkills": ["brawl", "detect", "influence", "leadership", "mysticism", "survival"], + "startingPack": "

Gladiator or Warrior

", + "isCaster": false, + "progression": [ + { "level": 1, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Rage", "Wrath"] }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Aggressor"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Fearmonger"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Mindless Rancor"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Bloodthirsty"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Rip and Tear"] } + ], + "features": [ + { + "name": "Rage", + "level": 1, + "description": "

While Berserk and wearing Light Armor or no Armor, you reduce damage you take by 1 per damage die, and your attack damage dice are one size larger and can explode. Further, you can go Berserk after you take damage or as part of making an attack. You remain Berserk this way for 1 minute, unless you end it (no Action) or go Unconscious.

", + "passive": true, + "changes": [] + }, + { + "name": "Wrath", + "level": 1, + "description": "

You gain the Interceptor Perk, and can make its attack against an Enemy that makes a Ranged Attack, Casts, or that damages you or an Ally.

", + "passive": true, + "changes": [] + }, + { + "name": "Aggressor", + "level": 2, + "description": "

You have a 10 foot bonus to Speed during the first Round of Combat, and having 3 or more Fatigue does not prevent you from taking the Rush Action.

", + "passive": true, + "changes": [] + }, + { + "name": "Fearmonger", + "level": 4, + "description": "

When you kill an Enemy, every Near Enemy with HD lower than your Level becomes Frightened until the end of your next Turn.

", + "passive": true, + "changes": [] + }, + { + "name": "Mindless Rancor", + "level": 6, + "description": "

You cannot be Charmed, Confused, or compelled to act against your will.

", + "passive": true, + "changes": [] + }, + { + "name": "Bloodthirsty", + "level": 8, + "description": "

Your attacks against Beings that are missing any HP are Favored, and you can sense them within Far as if by Blindsight.

", + "passive": true, + "changes": [] + }, + { + "name": "Rip and Tear", + "level": 10, + "description": "

While Berserk, you reduce damage you take by 2 per damage die, rather than 1, and you gain a +1 bonus to each die of damage you deal.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/bard.json b/packs/_source/classes/bard.json new file mode 100755 index 0000000..170554c --- /dev/null +++ b/packs/_source/classes/bard.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsBard00", + "_key": "!items!vagabondClsBard00", + "name": "Bard", + "type": "class", + "img": "icons/svg/sound.svg", + "system": { + "description": "

Bards are inspiring performers who use their musical talents to empower allies and bewitch enemies. Their well-versed knowledge allows them to master any perk without prerequisites.

", + "keyStat": "presence", + "actionStyle": "performance", + "zone": "midline", + "trainedSkills": ["performance"], + "startingPack": "

Musician

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Virtuoso", "Well-Versed"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Song of Rest"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Starstruck"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Bravado"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Awe-Inspiring"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Encore"] } + ], + "features": [ + { + "name": "Virtuoso", + "level": 1, + "description": "

During Combat, you can use your Action or skip your Move to perform with a Musical Instrument you have Equipped. When you do, your Allies that can hear you gain Favor on certain rolls of your choice until your next Turn: Inspiration: Healing rolls (as d6 bonus), Resolve: Saves, Valor: Attack or Cast Checks.

", + "passive": false, + "changes": [] + }, + { + "name": "Well-Versed", + "level": 1, + "description": "

You ignore Prerequisites for Perks, and take a Perk of your choice now.

", + "passive": true, + "changes": [] + }, + { + "name": "Song of Rest", + "level": 2, + "description": "

During a Breather while you are not Incapacitated, you and your Allies gain a Studied die and regain additional HP equal to (your Presence + your Bard Level).

", + "passive": true, + "changes": [] + }, + { + "name": "Starstruck", + "level": 4, + "description": "

When you perform Virtuoso, you can choose a Near Enemy who hears the performance and make a Performance Check. If you pass, you can choose one of the following Statuses that affects it for Cd4 Rounds: Berserk, Charmed, Confused, Frightened.

", + "passive": false, + "changes": [] + }, + { + "name": "Bravado", + "level": 6, + "description": "

Your Will Saves cannot be Hindered while you are not Incapacitated, and you can ignore effects that rely on you hearing them to be affected (such as a banshee's scream).

", + "passive": true, + "changes": [] + }, + { + "name": "Awe-Inspiring", + "level": 8, + "description": "

Your Virtuoso now grants two Favor.

", + "passive": true, + "changes": [] + }, + { + "name": "Encore", + "level": 10, + "description": "

Your Starstruck Feature can now affect all Near Enemies.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/dancer.json b/packs/_source/classes/dancer.json new file mode 100755 index 0000000..81a9a66 --- /dev/null +++ b/packs/_source/classes/dancer.json @@ -0,0 +1,92 @@ +{ + "_id": "vagabondClsDancer", + "_key": "!items!vagabondClsDancer", + "name": "Dancer", + "type": "class", + "img": "icons/svg/silhouette.svg", + "system": { + "description": "

Dancers are graceful performers who use their movements to inspire allies and evade attacks. Their fleet footwork makes them exceptional support characters who can grant allies extra actions.

", + "keyStat": "dexterity", + "actionStyle": "finesse", + "zone": "midline", + "trainedSkills": ["finesse", "performance", "brawl", "influence", "sneak"], + "startingPack": "

Courtesan or Musician

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Fleet of Foot", "Step Up"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Evasive"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { + "level": 4, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Don't Stop Me Now"] + }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Choreographer"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Flash of Beauty"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Double Time"] } + ], + "features": [ + { + "name": "Fleet of Foot", + "level": 1, + "description": "

You gain the Treads Lightly Perk, and the roll for you to Crit on Reflex Saves is reduced by an amount equal to (your Dancer Level divided by 4, round up).

", + "passive": true, + "changes": [] + }, + { + "name": "Step Up", + "level": 1, + "description": "

Once per Turn, you can use your Action to perform an enlivening dance. Doing so allows you to roll a second d20 on Reflex Saves until the start of your next Turn and use the higher result, and it gives one Ally of your choice that sees you a second Action this Turn.

", + "passive": false, + "changes": [] + }, + { + "name": "Evasive", + "level": 2, + "description": "

While you are not Incapacitated, you ignore Hinder on Reflex Saves and you ignore two of a Dodged attack's damage dice on a passed Save, rather than one.

", + "passive": true, + "changes": [] + }, + { + "name": "Don't Stop Me Now", + "level": 4, + "description": "

Your Speed is not affected by Difficult Terrain and you have Favor on Saves against being Paralyzed, Restrained, or moved.

", + "passive": true, + "changes": [] + }, + { + "name": "Choreographer", + "level": 6, + "description": "

When you use your Step Up Feature, the Ally gains Favor on the first Check they make with the Action you give them, and you both gain a 10 foot bonus to Speed for the Round.

", + "passive": true, + "changes": [] + }, + { + "name": "Flash of Beauty", + "level": 8, + "description": "

When you Crit on a Save, you can take two Actions, rather than one.

", + "passive": true, + "changes": [] + }, + { + "name": "Double Time", + "level": 10, + "description": "

You can Target two Allies with your Step Up Feature, rather than one.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/druid.json b/packs/_source/classes/druid.json new file mode 100755 index 0000000..1d63d30 --- /dev/null +++ b/packs/_source/classes/druid.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsDruid0", + "_key": "!items!vagabondClsDruid0", + "name": "Druid", + "type": "class", + "img": "icons/svg/oak.svg", + "system": { + "description": "

Druids are primal mystics who draw power from nature. Their signature ability to shapeshift into beasts makes them versatile combatants who can adapt to any situation.

", + "keyStat": "awareness", + "actionStyle": "mysticism", + "zone": "midline", + "trainedSkills": ["mysticism", "brawl", "craft", "detect", "medicine", "survival"], + "startingPack": "

Hermit

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 4, + "castingMax": 0, + "spellsKnown": 4, + "features": ["Primal Mystic", "Feral Shift"] + }, + { "level": 2, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Tempest Within"] }, + { "level": 3, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 4, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Innervate"] }, + { "level": 5, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 6, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Ancient Growth"] }, + { "level": 7, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 8, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Savagery"] }, + { "level": 9, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 10, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Force of Nature"] } + ], + "features": [ + { + "name": "Primal Mystic", + "level": 1, + "description": "

You can Cast Spells using Mysticism. Spells: You learn 4 Spells, one of which must always be Polymorph. You learn 1 other Spell every 2 Levels in this Class hereafter. Mana: Your Maximum Mana is equal to (4 × your Druid Level), and the highest amount of Mana you can spend to Cast a Spell is equal to (Awareness + half your Druid Level, round up). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Feral Shift", + "level": 1, + "description": "

You get the Shapechanger Perk and you can take an Action granted by the Beast you turn into as a part of the Cast Action.

", + "passive": true, + "changes": [] + }, + { + "name": "Tempest Within", + "level": 2, + "description": "

You reduce Cold, Fire, and Shock damage you take by (half your Druid Level) per damage die.

", + "passive": true, + "changes": [] + }, + { + "name": "Innervate", + "level": 4, + "description": "

You can use your Action to give a Close Being some of your Mana, or to end one Status affecting it from either Charmed, Confused, Frightened, or Sickened. This can be yourself.

", + "passive": false, + "changes": [] + }, + { + "name": "Ancient Growth", + "level": 6, + "description": "

While you Focus on a Casting of Polymorph that only Targets yourself, you can Focus one additional Spell. Further, your attacks with Beasts you Polymorph into count as (+1) Relics. This bonus increases every 6 Druid Levels hereafter, to a max of (+3).

", + "passive": true, + "changes": [] + }, + { + "name": "Savagery", + "level": 8, + "description": "

While you are polymorphed into a Beast, you have a +1 bonus to Armor.

", + "passive": true, + "changes": [] + }, + { + "name": "Force of Nature", + "level": 10, + "description": "

If you are reduced to 0 HP, roll a d10. If the result is equal to or lower than your Awareness, you are instead at 1 HP.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/fighter.json b/packs/_source/classes/fighter.json new file mode 100755 index 0000000..a49d38c --- /dev/null +++ b/packs/_source/classes/fighter.json @@ -0,0 +1,104 @@ +{ + "_id": "vagabondClsFighter", + "_key": "!items!vagabondClsFighter", + "name": "Fighter", + "type": "class", + "img": "icons/svg/sword.svg", + "system": { + "description": "

Fighters are versatile combatants who excel in any battlefield role. Whether wielding sword or bow, their martial prowess and tactical awareness make them formidable opponents.

", + "keyStat": "might", + "actionStyle": "attack", + "zone": "frontline", + "trainedSkills": ["brawl", "finesse", "detect", "leadership", "sneak"], + "startingPack": "

Gladiator, Knight, Warrior, or Watchman

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Fighting Style", "Valor I"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Momentum"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Valor II"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { + "level": 6, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Muster for Battle"] + }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Valor III"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Harrying"] } + ], + "features": [ + { + "name": "Fighting Style", + "level": 1, + "description": "

You gain the Situational Awareness Perk and another Perk with the Melee or Ranged Training Prerequisite. You can ignore Stat prerequisites for this Perk.

", + "passive": true, + "requiresChoice": true, + "choiceType": "perk", + "choiceFilter": { "prerequisite": ["Melee Training", "Ranged Training"] }, + "changes": [] + }, + { + "name": "Valor I", + "level": 1, + "description": "

The roll required for you to Crit on Attack Checks, and Saves to Dodge or Block Attacks is reduced by 1.

", + "passive": true, + "changes": [ + { "key": "system.attacks.melee.critThreshold", "mode": 2, "value": "-1", "priority": 10 }, + { "key": "system.attacks.ranged.critThreshold", "mode": 2, "value": "-1", "priority": 10 } + ] + }, + { + "name": "Valor II", + "level": 4, + "description": "

Your Valor improves. The crit threshold reduction increases to -2 total.

", + "passive": true, + "changes": [ + { "key": "system.attacks.melee.critThreshold", "mode": 2, "value": "-1", "priority": 10 }, + { "key": "system.attacks.ranged.critThreshold", "mode": 2, "value": "-1", "priority": 10 } + ] + }, + { + "name": "Valor III", + "level": 8, + "description": "

Your Valor reaches its peak. The crit threshold reduction increases to -3 total.

", + "passive": true, + "changes": [ + { "key": "system.attacks.melee.critThreshold", "mode": 2, "value": "-1", "priority": 10 }, + { "key": "system.attacks.ranged.critThreshold", "mode": 2, "value": "-1", "priority": 10 } + ] + }, + { + "name": "Momentum", + "level": 2, + "description": "

If you pass a Save against an attack, the next attack you make before the end of your next Turn is Favored.

", + "passive": true, + "changes": [] + }, + { + "name": "Muster for Battle", + "level": 6, + "description": "

You have two Actions on your first Turn of Combat.

", + "passive": true, + "changes": [] + }, + { + "name": "Harrying", + "level": 10, + "description": "

You can attack twice with the Attack Action, rather than just once.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/gunslinger.json b/packs/_source/classes/gunslinger.json new file mode 100755 index 0000000..8175694 --- /dev/null +++ b/packs/_source/classes/gunslinger.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsGunsli", + "_key": "!items!vagabondClsGunsli", + "name": "Gunslinger", + "type": "class", + "img": "icons/svg/target.svg", + "system": { + "description": "

Gunslingers are deadly marksmen who specialize in ranged combat. Their quick draw and deadly aim make them feared backline strikers who excel at taking down targets from a distance.

", + "keyStat": "awareness", + "actionStyle": "attack", + "zone": "backline", + "trainedSkills": ["detect", "brawl", "finesse", "influence", "sneak", "survival"], + "startingPack": "

Bounty Hunter or Tinker

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Quick Draw", "Deadeye"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Skeet Shooter"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Grit"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Devastator"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Bad Medicine"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["High Noon"] } + ], + "features": [ + { + "name": "Quick Draw", + "level": 1, + "description": "

You gain the Marksmanship Perk. Further, when combat occurs, you can make one attack before the first Turn.

", + "passive": true, + "changes": [] + }, + { + "name": "Deadeye", + "level": 1, + "description": "

After you pass a Ranged Check, you Crit on subsequent Ranged attacks on a d20 roll 1 lower, but no lower than 17. This resets to 0 at the end of your Turn if you did not pass a Ranged Check since your last Turn.

", + "passive": true, + "changes": [] + }, + { + "name": "Skeet Shooter", + "level": 2, + "description": "

Once per Round, you can make a Ranged attack on an Off-Turn to Target a projectile from an attack you can see. If you pass, reduce the damage of the triggering attack by the damage you would deal with your attack. The projectile falls out of the air if reduced to 0.

", + "passive": false, + "changes": [] + }, + { + "name": "Grit", + "level": 4, + "description": "

When you Crit on a Ranged attack, the damage dice can explode.

", + "passive": true, + "changes": [] + }, + { + "name": "Devastator", + "level": 6, + "description": "

When you reduce an Enemy to 0 HP, the roll on the d20 to Crit as per your Deadeye Feature is immediately set to 17.

", + "passive": true, + "changes": [] + }, + { + "name": "Bad Medicine", + "level": 8, + "description": "

You deal an extra die of damage when you Crit with a Ranged Check.

", + "passive": true, + "changes": [] + }, + { + "name": "High Noon", + "level": 10, + "description": "

Once per Turn, if you Crit on a Ranged Check, you can make one additional attack.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/hunter.json b/packs/_source/classes/hunter.json new file mode 100755 index 0000000..68a0d62 --- /dev/null +++ b/packs/_source/classes/hunter.json @@ -0,0 +1,92 @@ +{ + "_id": "vagabondClsHunter", + "_key": "!items!vagabondClsHunter", + "name": "Hunter", + "type": "class", + "img": "icons/svg/pawprint.svg", + "system": { + "description": "

Hunters are expert trackers and survivalists who mark their prey for destruction. Their ability to focus on a single target makes them deadly strikers who rarely miss their mark.

", + "keyStat": "awareness", + "actionStyle": "attack", + "zone": "midline", + "trainedSkills": ["survival"], + "startingPack": "

Scout or Warrior

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Hunter's Mark", "Survivalist"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Rover"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Overwatch"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Quarry"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { + "level": 8, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Lethal Precision"] + }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Apex Predator"] } + ], + "features": [ + { + "name": "Hunter's Mark", + "level": 1, + "description": "

You can mark a Being either when you attack it, or by skipping your Move if you can sense it. When you do: You must Focus on the mark. When you make an attack against it, roll two d20s and use the highest for the Check.

", + "passive": false, + "changes": [] + }, + { + "name": "Survivalist", + "level": 1, + "description": "

You gain the Padfoot Perk, you have Favor on Checks to track and navigate, and you can Forage while Traveling at a Normal Pace.

", + "passive": true, + "changes": [] + }, + { + "name": "Rover", + "level": 2, + "description": "

Difficult Terrain does not impede your walking Speed, and you have Climb and Swim.

", + "passive": true, + "changes": [] + }, + { + "name": "Overwatch", + "level": 4, + "description": "

Your additional d20 for attacks with your Hunter's Mark also applies to your Saves provoked by the marked Target.

", + "passive": true, + "changes": [] + }, + { + "name": "Quarry", + "level": 6, + "description": "

You can sense Beings that are missing any HP within Far or that are marked by your Hunter's Mark as if by Blindsight.

", + "passive": true, + "changes": [] + }, + { + "name": "Lethal Precision", + "level": 8, + "description": "

You now roll three d20s with your Hunter's Mark and Overwatch Features and use the highest result of the three for the result.

", + "passive": true, + "changes": [] + }, + { + "name": "Apex Predator", + "level": 10, + "description": "

Damage dealt to the Target of your Hunter's Mark ignores Immune and 1 of its Armor.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/luminary.json b/packs/_source/classes/luminary.json new file mode 100755 index 0000000..82132a8 --- /dev/null +++ b/packs/_source/classes/luminary.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsLumina", + "_key": "!items!vagabondClsLumina", + "name": "Luminary", + "type": "class", + "img": "icons/svg/sun.svg", + "system": { + "description": "

Luminaries are divine healers who channel radiant energy to restore their allies. Their powerful healing magic and ability to revive the fallen make them invaluable support characters.

", + "keyStat": "awareness", + "actionStyle": "mysticism", + "zone": "midline", + "trainedSkills": ["mysticism", "arcana", "detect", "influence", "leadership", "medicine"], + "startingPack": "

Devout

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 4, + "castingMax": 0, + "spellsKnown": 4, + "features": ["Theurgy", "Radiant Healer"] + }, + { "level": 2, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Overheal"] }, + { "level": 3, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 4, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Ever-Cure"] }, + { "level": 5, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 6, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Revivify"] }, + { "level": 7, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 8, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Saving Grace"] }, + { "level": 9, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 10, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Life-Giver"] } + ], + "features": [ + { + "name": "Theurgy", + "level": 1, + "description": "

You can Cast Spells using Mysticism. Spells: You learn 4 Spells, two of which must always be Life and Light. You learn 1 other Spell every 2 Luminary Levels hereafter. Mana: Your Maximum Mana is equal to (4 × your Luminary Level), and the highest amount of Mana you can spend to Cast a Spell is equal to (Awareness + half your Luminary Level, round up). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Radiant Healer", + "level": 1, + "description": "

You get the Assured Healer Perk, and the healing rolls of your Spells can also explode on their highest value.

", + "passive": true, + "changes": [] + }, + { + "name": "Overheal", + "level": 2, + "description": "

If you restore HP that exceeds the Being's Max HP, you can give the excess to yourself or a Being you can see.

", + "passive": true, + "changes": [] + }, + { + "name": "Ever-Cure", + "level": 4, + "description": "

When you restore HP, you can end either a Charmed, Confused, Dazed, Frightened, or Sickened Status affecting the Target.

", + "passive": true, + "changes": [] + }, + { + "name": "Revivify", + "level": 6, + "description": "

You can revive a Being with the Life Spell if it has been dead for as long as 1 hour. If you die, you are revived automatically. Afterward, you cannot be revived by this Feature for 1 day.

", + "passive": true, + "changes": [] + }, + { + "name": "Saving Grace", + "level": 8, + "description": "

Your healing rolls can also explode on a 2.

", + "passive": true, + "changes": [] + }, + { + "name": "Life-Giver", + "level": 10, + "description": "

Beings you revive are revived at 4 Fatigue if their Fatigue was previously higher, and do not gain Fatigue from your Life Spell.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/magus.json b/packs/_source/classes/magus.json new file mode 100755 index 0000000..751e29d --- /dev/null +++ b/packs/_source/classes/magus.json @@ -0,0 +1,92 @@ +{ + "_id": "vagabondClsMagus0", + "_key": "!items!vagabondClsMagus0", + "name": "Magus", + "type": "class", + "img": "icons/svg/lightning.svg", + "system": { + "description": "

Magi are spell-swords who blend martial prowess with arcane magic. Their ability to channel spells through their weapons makes them versatile combatants who excel in both melee and magical combat.

", + "keyStat": "reason", + "actionStyle": "arcana", + "zone": "frontline", + "trainedSkills": ["arcana", "brawl", "finesse"], + "startingPack": "

Knight or Scholar

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 4, + "castingMax": 0, + "spellsKnown": 4, + "features": ["Arcane Warrior", "Spell Strike"] + }, + { "level": 2, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Arcane Pool"] }, + { "level": 3, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 4, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Spell Combat"] }, + { "level": 5, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 6, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Counterstrike"] }, + { "level": 7, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { + "level": 8, + "mana": 4, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Greater Spell Strike"] + }, + { "level": 9, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 10, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["True Magus"] } + ], + "features": [ + { + "name": "Arcane Warrior", + "level": 1, + "description": "

You can Cast Spells using Arcana. Spells: You learn 4 Spells. You learn 1 other Spell every 2 Levels in this Class hereafter. Mana: Your Maximum Mana is equal to (4 × your Magus Level), and the highest amount of Mana you can spend to Cast a Spell is equal to (Reason + half your Magus Level, round up). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Spell Strike", + "level": 1, + "description": "

When you Cast a Spell that has a Range of Touch, you can deliver the Spell through a Melee Weapon attack. If the attack hits, the target is affected by both the attack and the Spell.

", + "passive": false, + "changes": [] + }, + { + "name": "Arcane Pool", + "level": 2, + "description": "

You can spend 1 Mana to enhance your weapon. For 1 minute, it counts as a (+1) Relic. This bonus increases by +1 at 6th and 10th Levels in this Class.

", + "passive": false, + "changes": [] + }, + { + "name": "Spell Combat", + "level": 4, + "description": "

When you take the Attack Action, you can also Cast a Spell with a Casting Time of 1 Action as part of that Action.

", + "passive": true, + "changes": [] + }, + { + "name": "Counterstrike", + "level": 6, + "description": "

When an Enemy within your Melee Reach Casts a Spell, you can make an attack against them as a Reaction.

", + "passive": false, + "changes": [] + }, + { + "name": "Greater Spell Strike", + "level": 8, + "description": "

When you use Spell Strike and hit, you can spend 2 Mana to maximize the Spell's damage dice instead of rolling them.

", + "passive": false, + "changes": [] + }, + { + "name": "True Magus", + "level": 10, + "description": "

When you use Spell Combat, you can Cast two Spells instead of one.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/merchant.json b/packs/_source/classes/merchant.json new file mode 100755 index 0000000..9d9135b --- /dev/null +++ b/packs/_source/classes/merchant.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsMercha", + "_key": "!items!vagabondClsMercha", + "name": "Merchant", + "type": "class", + "img": "icons/svg/coins.svg", + "system": { + "description": "

Merchants are savvy traders who use their wealth and social connections to support their allies. Their ability to acquire rare items and manipulate markets makes them invaluable support characters.

", + "keyStat": "reason", + "actionStyle": "influence", + "zone": "midline", + "trainedSkills": ["influence", "craft", "detect"], + "startingPack": "

Noble or Traveler

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Deep Pockets", "Connections"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Appraisal"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Haggle"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Investment"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Black Market"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Tycoon"] } + ], + "features": [ + { + "name": "Deep Pockets", + "level": 1, + "description": "

You start with an additional 5g, and you gain 1g at the start of each session.

", + "passive": true, + "changes": [] + }, + { + "name": "Connections", + "level": 1, + "description": "

You gain the Silver Tongue Perk. In any settlement, you can find a contact who can help you acquire goods, information, or services.

", + "passive": true, + "changes": [] + }, + { + "name": "Appraisal", + "level": 2, + "description": "

You can determine the exact value of any item, and you have Favor on Checks to identify magical items.

", + "passive": true, + "changes": [] + }, + { + "name": "Haggle", + "level": 4, + "description": "

You buy items for 75% of their listed price and sell items for 75% of their value (instead of 50%).

", + "passive": true, + "changes": [] + }, + { + "name": "Investment", + "level": 6, + "description": "

During downtime, you can invest gold. At the start of the next session, roll a d6: on 4-6, you gain 50% profit; on 2-3, you break even; on 1, you lose 50%.

", + "passive": false, + "changes": [] + }, + { + "name": "Black Market", + "level": 8, + "description": "

You can acquire any item regardless of its legality or rarity, given enough time and gold.

", + "passive": true, + "changes": [] + }, + { + "name": "Tycoon", + "level": 10, + "description": "

Your session gold increases to 3g, and your investments always succeed (minimum break even on a 1).

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/pugilist.json b/packs/_source/classes/pugilist.json new file mode 100755 index 0000000..c28be51 --- /dev/null +++ b/packs/_source/classes/pugilist.json @@ -0,0 +1,92 @@ +{ + "_id": "vagabondClsPugili", + "_key": "!items!vagabondClsPugili", + "name": "Pugilist", + "type": "class", + "img": "icons/svg/fist.svg", + "system": { + "description": "

Pugilists are masters of unarmed combat who turn their bodies into deadly weapons. Their devastating punches and grappling techniques make them formidable frontline bruisers.

", + "keyStat": "might", + "actionStyle": "brawl", + "zone": "frontline", + "trainedSkills": ["brawl", "finesse", "detect", "influence"], + "startingPack": "

Gladiator or Traveler

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Fisticuffs", "Haymaker"] + }, + { + "level": 2, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Deflect Missiles"] + }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Stunning Blow"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Iron Body"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Flurry of Blows"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perfect Form"] } + ], + "features": [ + { + "name": "Fisticuffs", + "level": 1, + "description": "

Your Unarmed attacks deal 1d6 damage (increasing to 1d8 at 5th level and 1d10 at 9th level), and you can make an Unarmed attack as a Move.

", + "passive": true, + "changes": [] + }, + { + "name": "Haymaker", + "level": 1, + "description": "

You gain the Wrestler Perk. When you hit with an Unarmed attack, you can push the target 5 feet.

", + "passive": true, + "changes": [] + }, + { + "name": "Deflect Missiles", + "level": 2, + "description": "

When hit by a Ranged attack, you can use your Reaction to reduce the damage by 1d10 + your Dexterity + your Pugilist Level.

", + "passive": false, + "changes": [] + }, + { + "name": "Stunning Blow", + "level": 4, + "description": "

When you hit with an Unarmed attack, you can force the target to make a Fortitude Save or be Dazed until the end of your next Turn.

", + "passive": false, + "changes": [] + }, + { + "name": "Iron Body", + "level": 6, + "description": "

While you are not wearing Armor, you have +1 Armor. You also have resistance to Bludgeoning damage.

", + "passive": true, + "changes": [] + }, + { + "name": "Flurry of Blows", + "level": 8, + "description": "

When you take the Attack Action with an Unarmed attack, you can make two additional Unarmed attacks.

", + "passive": true, + "changes": [] + }, + { + "name": "Perfect Form", + "level": 10, + "description": "

Your Unarmed attacks count as (+2) Relics, and you have Favor on all Saves while you are not wearing Armor.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/revelator.json b/packs/_source/classes/revelator.json new file mode 100755 index 0000000..7a2da81 --- /dev/null +++ b/packs/_source/classes/revelator.json @@ -0,0 +1,95 @@ +{ + "_id": "vagabondClsRevela", + "_key": "!items!vagabondClsRevela", + "name": "Revelator", + "type": "class", + "img": "icons/svg/holy-shield.svg", + "system": { + "description": "

Revelators are holy warriors who combine martial prowess with divine magic. Their ability to smite evil and protect allies makes them formidable frontline paladins.

", + "keyStat": "might", + "actionStyle": "leadership", + "zone": "frontline", + "trainedSkills": ["leadership", "brawl", "influence", "mysticism"], + "startingPack": "

Knight or Devout

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 2, + "castingMax": 0, + "spellsKnown": 2, + "features": ["Divine Sense", "Lay on Hands"] + }, + { "level": 2, "mana": 2, "castingMax": 0, "spellsKnown": 0, "features": ["Divine Smite"] }, + { "level": 3, "mana": 2, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { + "level": 4, + "mana": 2, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Aura of Protection"] + }, + { "level": 5, "mana": 2, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 6, "mana": 2, "castingMax": 0, "spellsKnown": 0, "features": ["Aura of Courage"] }, + { "level": 7, "mana": 2, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 8, "mana": 2, "castingMax": 0, "spellsKnown": 0, "features": ["Improved Smite"] }, + { "level": 9, "mana": 2, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 10, "mana": 2, "castingMax": 0, "spellsKnown": 0, "features": ["Holy Champion"] } + ], + "features": [ + { + "name": "Divine Sense", + "level": 1, + "description": "

You can Cast Spells using Leadership. Spells: You learn 2 Spells. You learn 1 other Spell every 2 Levels in this Class hereafter. Mana: Your Maximum Mana is equal to (2 × your Revelator Level). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Lay on Hands", + "level": 1, + "description": "

You have a pool of healing power equal to (5 × your Revelator Level). As an Action, you can touch a Being and restore HP from this pool. You can also spend 5 HP from this pool to cure one disease or neutralize one poison.

", + "passive": false, + "changes": [] + }, + { + "name": "Divine Smite", + "level": 2, + "description": "

When you hit with a Melee attack, you can spend Mana to deal extra Radiant damage: 2d8 for 1 Mana, +1d8 for each additional Mana spent (max 5d8).

", + "passive": false, + "changes": [] + }, + { + "name": "Aura of Protection", + "level": 4, + "description": "

You and Allies within 10 feet gain a bonus to Saves equal to your Presence (minimum +1).

", + "passive": true, + "changes": [] + }, + { + "name": "Aura of Courage", + "level": 6, + "description": "

You and Allies within 10 feet cannot be Frightened while you are conscious.

", + "passive": true, + "changes": [] + }, + { + "name": "Improved Smite", + "level": 8, + "description": "

All your Melee attacks deal an extra 1d8 Radiant damage.

", + "passive": true, + "changes": [] + }, + { + "name": "Holy Champion", + "level": 10, + "description": "

When you are reduced to 0 HP and not killed outright, you can choose to drop to 1 HP instead. Once you use this feature, you can't use it again until you Rest.

", + "passive": false, + "changes": [] + } + ], + "customResource": { + "name": "Lay on Hands Pool", + "max": "5 * @classes.revelator.level" + } + } +} diff --git a/packs/_source/classes/rogue.json b/packs/_source/classes/rogue.json new file mode 100755 index 0000000..c9e9cb3 --- /dev/null +++ b/packs/_source/classes/rogue.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsRogue0", + "_key": "!items!vagabondClsRogue0", + "name": "Rogue", + "type": "class", + "img": "icons/svg/dagger.svg", + "system": { + "description": "

Rogues are cunning opportunists who strike when their enemies least expect it. Their sneak attacks and luck manipulation make them deadly midline strikers who excel at exploiting weaknesses.

", + "keyStat": "dexterity", + "actionStyle": "attack", + "zone": "midline", + "trainedSkills": ["sneak", "finesse", "detect", "influence"], + "startingPack": "

Assassin or Thief

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Expertise", "Sneak Attack"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Cunning Action"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Uncanny Dodge"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Evasion"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Reliable Talent"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Stroke of Luck"] } + ], + "features": [ + { + "name": "Expertise", + "level": 1, + "description": "

You gain the Jack of All Trades Perk. Choose two Skills you are Trained in; you have Favor on Checks with those Skills.

", + "passive": true, + "changes": [] + }, + { + "name": "Sneak Attack", + "level": 1, + "description": "

Once per Turn, when you hit a Target with an attack and have Favor on the attack roll, or an Enemy of the Target is within 5 feet of it, you deal an extra Cd6 damage. This damage increases by 1d6 at 3rd, 5th, 7th, and 9th Levels in this Class.

", + "passive": true, + "changes": [] + }, + { + "name": "Cunning Action", + "level": 2, + "description": "

You can take the Dash, Disengage, or Hide Action as a Move, rather than an Action.

", + "passive": true, + "changes": [] + }, + { + "name": "Uncanny Dodge", + "level": 4, + "description": "

When an attack hits you, you can use your Reaction to halve the attack's damage against you.

", + "passive": false, + "changes": [] + }, + { + "name": "Evasion", + "level": 6, + "description": "

When you pass a Reflex Save, you take no damage instead of half damage.

", + "passive": true, + "changes": [] + }, + { + "name": "Reliable Talent", + "level": 8, + "description": "

When you make a Check with a Skill you are Trained in and roll lower than 10 on the d20, you can treat the roll as a 10.

", + "passive": true, + "changes": [] + }, + { + "name": "Stroke of Luck", + "level": 10, + "description": "

Once per Rest, if you miss with an attack, you can turn the miss into a hit.

", + "passive": false, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/sorcerer.json b/packs/_source/classes/sorcerer.json new file mode 100755 index 0000000..34340d0 --- /dev/null +++ b/packs/_source/classes/sorcerer.json @@ -0,0 +1,107 @@ +{ + "_id": "vagabondClsSorcer", + "_key": "!items!vagabondClsSorcer", + "name": "Sorcerer", + "type": "class", + "img": "icons/svg/fire.svg", + "system": { + "description": "

Sorcerers are innate spellcasters who channel raw magical power through force of personality. Their ability to manipulate their spells on the fly makes them devastating backline blasters.

", + "keyStat": "presence", + "actionStyle": "influence", + "zone": "backline", + "trainedSkills": ["influence", "arcana", "detect", "mysticism"], + "startingPack": "

Noble or Hermit

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 4, + "castingMax": 0, + "spellsKnown": 4, + "features": ["Innate Magic", "Metamagic"] + }, + { "level": 2, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Font of Magic"] }, + { "level": 3, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { + "level": 4, + "mana": 4, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Sorcerous Origin"] + }, + { "level": 5, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { + "level": 6, + "mana": 4, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Empowered Spells"] + }, + { "level": 7, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 8, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Origin Mastery"] }, + { "level": 9, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { + "level": 10, + "mana": 4, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Arcane Apotheosis"] + } + ], + "features": [ + { + "name": "Innate Magic", + "level": 1, + "description": "

You can Cast Spells using Influence. Spells: You learn 4 Spells. You learn 1 other Spell every 2 Levels in this Class hereafter. Mana: Your Maximum Mana is equal to (4 × your Sorcerer Level), and the highest amount of Mana you can spend to Cast a Spell is equal to (Presence + half your Sorcerer Level, round up). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Metamagic", + "level": 1, + "description": "

You can modify your Spells as you Cast them. Choose two Metamagic options: Careful (choose creatures to auto-succeed), Distant (double Range), Extended (double Duration), Quickened (Cast as Move), Subtle (no verbal/somatic), Twinned (Target second creature).

", + "passive": false, + "changes": [] + }, + { + "name": "Font of Magic", + "level": 2, + "description": "

You can convert Mana to Sorcery Points and vice versa. 1 Sorcery Point costs 1 Mana to create, and 1 Mana costs 2 Sorcery Points.

", + "passive": false, + "changes": [] + }, + { + "name": "Sorcerous Origin", + "level": 4, + "description": "

Choose an Origin that grants additional abilities: Draconic (resistance to one element, +1 Armor), Wild (random magical effects), or Shadow (see in darkness, teleport through shadows).

", + "passive": true, + "changes": [] + }, + { + "name": "Empowered Spells", + "level": 6, + "description": "

When you roll damage for a Spell, you can reroll a number of damage dice up to your Presence (minimum 1). You must use the new rolls.

", + "passive": false, + "changes": [] + }, + { + "name": "Origin Mastery", + "level": 8, + "description": "

Your Sorcerous Origin grants an improved ability based on your choice.

", + "passive": true, + "changes": [] + }, + { + "name": "Arcane Apotheosis", + "level": 10, + "description": "

You regain 4 Mana when you finish a Breather, and your Metamagic options cost 1 less Sorcery Point (minimum 1).

", + "passive": true, + "changes": [] + } + ], + "customResource": { + "name": "Sorcery Points", + "max": "@classes.sorcerer.level" + } + } +} diff --git a/packs/_source/classes/vanguard.json b/packs/_source/classes/vanguard.json new file mode 100755 index 0000000..096baac --- /dev/null +++ b/packs/_source/classes/vanguard.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsVangua", + "_key": "!items!vagabondClsVangua", + "name": "Vanguard", + "type": "class", + "img": "icons/svg/shield.svg", + "system": { + "description": "

Vanguards are stalwart defenders who protect their allies from harm. Their ability to absorb damage and control the battlefield makes them invaluable frontline tanks.

", + "keyStat": "might", + "actionStyle": "brawl", + "zone": "frontline", + "trainedSkills": ["brawl", "detect", "leadership", "influence"], + "startingPack": "

Knight or Warrior

", + "isCaster": false, + "progression": [ + { + "level": 1, + "mana": 0, + "castingMax": 0, + "spellsKnown": 0, + "features": ["Shield Wall", "Protector"] + }, + { "level": 2, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Taunt"] }, + { "level": 3, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 4, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Resolute"] }, + { "level": 5, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 6, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Hold the Line"] }, + { "level": 7, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 8, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Bulwark"] }, + { "level": 9, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Perk"] }, + { "level": 10, "mana": 0, "castingMax": 0, "spellsKnown": 0, "features": ["Immovable"] } + ], + "features": [ + { + "name": "Shield Wall", + "level": 1, + "description": "

While you have a Shield equipped, you have +1 Armor, and Allies within 5 feet of you have +1 to their Block Saves.

", + "passive": true, + "changes": [] + }, + { + "name": "Protector", + "level": 1, + "description": "

You gain the Sentinel Perk. When an Ally within 5 feet is targeted by an attack, you can use your Reaction to become the target instead.

", + "passive": false, + "changes": [] + }, + { + "name": "Taunt", + "level": 2, + "description": "

As an Action, you can taunt a Near Enemy. Until the end of your next Turn, it has Hinder on attacks that don't include you as a Target.

", + "passive": false, + "changes": [] + }, + { + "name": "Resolute", + "level": 4, + "description": "

You have Favor on Saves against being Frightened, Charmed, or moved against your will.

", + "passive": true, + "changes": [] + }, + { + "name": "Hold the Line", + "level": 6, + "description": "

Enemies provoke an Opportunity Attack from you when they move while within your Reach, even if they Disengage.

", + "passive": true, + "changes": [] + }, + { + "name": "Bulwark", + "level": 8, + "description": "

While you have a Shield equipped, you reduce all damage you take by 2 per damage die.

", + "passive": true, + "changes": [] + }, + { + "name": "Immovable", + "level": 10, + "description": "

You cannot be moved against your will, and you cannot be knocked Prone. Additionally, your Shield Wall bonus increases to +2 Armor and +2 to Block Saves.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/witch.json b/packs/_source/classes/witch.json new file mode 100755 index 0000000..d2f8a85 --- /dev/null +++ b/packs/_source/classes/witch.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsWitch0", + "_key": "!items!vagabondClsWitch0", + "name": "Witch", + "type": "class", + "img": "icons/svg/eye.svg", + "system": { + "description": "

Witches are mysterious practitioners of dark magic who hex their enemies and commune with spirits. Their curses and debuffs make them powerful backline controllers.

", + "keyStat": "awareness", + "actionStyle": "mysticism", + "zone": "backline", + "trainedSkills": ["mysticism", "arcana", "craft", "detect", "survival"], + "startingPack": "

Hermit or Occultist

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 4, + "castingMax": 0, + "spellsKnown": 4, + "features": ["Witchcraft", "Hex"] + }, + { "level": 2, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Familiar"] }, + { "level": 3, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 4, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Evil Eye"] }, + { "level": 5, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 6, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Coven Magic"] }, + { "level": 7, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 8, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Greater Hex"] }, + { "level": 9, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 10, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Grand Hex"] } + ], + "features": [ + { + "name": "Witchcraft", + "level": 1, + "description": "

You can Cast Spells using Mysticism. Spells: You learn 4 Spells. You learn 1 other Spell every 2 Levels in this Class hereafter. Mana: Your Maximum Mana is equal to (4 × your Witch Level), and the highest amount of Mana you can spend to Cast a Spell is equal to (Awareness + half your Witch Level, round up). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Hex", + "level": 1, + "description": "

You can place a Hex on a Near Being as an Action. Choose one: Misfortune (Hinder on one roll per Turn), Slumber (Will Save or fall asleep), Cackle (extend Hex duration). The Hex lasts for 1 minute or until you place another Hex.

", + "passive": false, + "changes": [] + }, + { + "name": "Familiar", + "level": 2, + "description": "

You gain a magical familiar that can scout, deliver Touch spells, and aid you. If your familiar dies, you can resummon it during a Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Evil Eye", + "level": 4, + "description": "

You can curse a Near Enemy with your gaze. It has Hinder on Saves against your Spells and Hexes until the end of your next Turn.

", + "passive": false, + "changes": [] + }, + { + "name": "Coven Magic", + "level": 6, + "description": "

When you are within 30 feet of another caster, you can combine your magic. When you both Cast the same Spell targeting the same area or creature, combine the effects.

", + "passive": true, + "changes": [] + }, + { + "name": "Greater Hex", + "level": 8, + "description": "

You learn additional Hex options: Agony (target takes Cd6 damage each Turn), Disguise (target appears different), Flight (target gains Fly).

", + "passive": true, + "changes": [] + }, + { + "name": "Grand Hex", + "level": 10, + "description": "

You learn the most powerful Hexes: Death Curse (target must Save or die), Eternal Slumber (target falls into magical sleep until curse is broken), Forced Reincarnation (target's soul is placed in a new body).

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/classes/wizard.json b/packs/_source/classes/wizard.json new file mode 100755 index 0000000..c58a64f --- /dev/null +++ b/packs/_source/classes/wizard.json @@ -0,0 +1,86 @@ +{ + "_id": "vagabondClsWizard0", + "_key": "!items!vagabondClsWizard0", + "name": "Wizard", + "type": "class", + "img": "icons/svg/book.svg", + "system": { + "description": "

Wizards are scholars of the arcane who cast spells through careful study and precise technique. Their deep understanding of magical theory makes them versatile backline blasters.

", + "keyStat": "reason", + "actionStyle": "arcana", + "zone": "backline", + "trainedSkills": ["arcana", "craft", "detect", "influence", "medicine"], + "startingPack": "

Scholar

", + "isCaster": true, + "progression": [ + { + "level": 1, + "mana": 4, + "castingMax": 0, + "spellsKnown": 4, + "features": ["Arcane Scholar", "Spellbook"] + }, + { "level": 2, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Focused Casting"] }, + { "level": 3, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 4, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Magical Thesis"] }, + { "level": 5, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 6, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Multicast"] }, + { "level": 7, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 8, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Greater Thesis"] }, + { "level": 9, "mana": 4, "castingMax": 0, "spellsKnown": 1, "features": ["Perk"] }, + { "level": 10, "mana": 4, "castingMax": 0, "spellsKnown": 0, "features": ["Arcane Mastery"] } + ], + "features": [ + { + "name": "Arcane Scholar", + "level": 1, + "description": "

You can Cast Spells using Arcana. Spells: You learn 4 Spells. You learn 1 other Spell every 2 Levels in this Class hereafter. Mana: Your Maximum Mana is equal to (4 × your Wizard Level), and the highest amount of Mana you can spend to Cast a Spell is equal to (Reason + half your Wizard Level, round up). You regain spent Mana when you Rest.

", + "passive": true, + "changes": [] + }, + { + "name": "Spellbook", + "level": 1, + "description": "

You get the Ritualist Perk. You have a Spellbook that contains the details and methods of Casting your Spells. If you lose your Spellbook, you can recopy it during a Rest using materials worth 5s × your Wizard Level. You can copy Spells from others' Spellbooks or Scrolls; doing so costs 5s and requires 1 hour per Spell.

", + "passive": true, + "changes": [] + }, + { + "name": "Focused Casting", + "level": 2, + "description": "

You can Focus on two Spells simultaneously, rather than one.

", + "passive": true, + "changes": [] + }, + { + "name": "Magical Thesis", + "level": 4, + "description": "

Choose one: Abjurer (reduce damage from Spells by 1 per die), Conjurer (summons have +1 Armor), Evoker (Spell damage dice can explode), or Illusionist (Illusions are Favored to disbelieve).

", + "passive": true, + "changes": [] + }, + { + "name": "Multicast", + "level": 6, + "description": "

When you Cast a Spell with a Range of Close or farther, you can spend 1 additional Mana to Target an additional Being within Range.

", + "passive": true, + "changes": [] + }, + { + "name": "Greater Thesis", + "level": 8, + "description": "

Your Magical Thesis improves. Abjurer (reduce by 2), Conjurer (summons have +2 Armor), Evoker (explode on two highest values), Illusionist (Illusions deal psychic damage on failed disbelief).

", + "passive": true, + "changes": [] + }, + { + "name": "Arcane Mastery", + "level": 10, + "description": "

Once per Day, you can Cast a Spell without spending Mana.

", + "passive": true, + "changes": [] + } + ], + "customResource": {} + } +} diff --git a/packs/_source/perks/sharpshooter.json b/packs/_source/perks/sharpshooter.json new file mode 100644 index 0000000..930a38d --- /dev/null +++ b/packs/_source/perks/sharpshooter.json @@ -0,0 +1,44 @@ +{ + "_id": "vagabondPerkSharpshooter", + "name": "Sharpshooter", + "type": "perk", + "img": "icons/svg/target.svg", + "system": { + "description": "

You have trained extensively with ranged weapons. Your ranged attacks deal +1 damage.

", + "prerequisites": { + "stats": { + "might": null, + "dexterity": 4, + "awareness": null, + "reason": null, + "presence": null, + "luck": null + }, + "trainedSkills": ["ranged"], + "spells": [], + "perks": [], + "custom": "Ranged Training" + }, + "changes": [ + { + "key": "system.attacks.ranged.damageBonus", + "mode": 2, + "value": "1" + } + ], + "passive": true, + "uses": { + "value": 0, + "max": 0, + "per": "" + }, + "luckCost": 0, + "grantsLuck": 0, + "isRitual": false, + "ritualDuration": 0, + "ritualComponents": "", + "tags": ["combat", "ranged", "training"] + }, + "effects": [], + "_key": "!items!vagabondPerkSharpshooter" +} diff --git a/packs/_source/perks/situational-awareness.json b/packs/_source/perks/situational-awareness.json new file mode 100644 index 0000000..9c918c1 --- /dev/null +++ b/packs/_source/perks/situational-awareness.json @@ -0,0 +1,38 @@ +{ + "_id": "vagabondPerkSituationalAwareness", + "name": "Situational Awareness", + "type": "perk", + "img": "icons/svg/eye.svg", + "system": { + "description": "

You are always alert to danger. You cannot be Surprised, and you have Favor on Initiative rolls.

", + "prerequisites": { + "stats": { + "might": null, + "dexterity": null, + "awareness": 4, + "reason": null, + "presence": null, + "luck": null + }, + "trainedSkills": [], + "spells": [], + "perks": [], + "custom": "" + }, + "changes": [], + "passive": true, + "uses": { + "value": 0, + "max": 0, + "per": "" + }, + "luckCost": 0, + "grantsLuck": 0, + "isRitual": false, + "ritualDuration": 0, + "ritualComponents": "", + "tags": ["combat", "awareness"] + }, + "effects": [], + "_key": "!items!vagabondPerkSituationalAwareness" +} diff --git a/packs/_source/perks/tough.json b/packs/_source/perks/tough.json new file mode 100644 index 0000000..0e19acc --- /dev/null +++ b/packs/_source/perks/tough.json @@ -0,0 +1,44 @@ +{ + "_id": "vagabondPerkTough", + "name": "Tough", + "type": "perk", + "img": "icons/svg/shield.svg", + "system": { + "description": "

Your body is hardened through training or natural resilience. You gain +5 maximum HP.

", + "prerequisites": { + "stats": { + "might": 4, + "dexterity": null, + "awareness": null, + "reason": null, + "presence": null, + "luck": null + }, + "trainedSkills": [], + "spells": [], + "perks": [], + "custom": "" + }, + "changes": [ + { + "key": "system.resources.hp.bonus", + "mode": 2, + "value": "5" + } + ], + "passive": true, + "uses": { + "value": 0, + "max": 0, + "per": "" + }, + "luckCost": 0, + "grantsLuck": 0, + "isRitual": false, + "ritualDuration": 0, + "ritualComponents": "", + "tags": ["combat", "defensive"] + }, + "effects": [], + "_key": "!items!vagabondPerkTough" +} diff --git a/packs/_source/perks/weapon-mastery.json b/packs/_source/perks/weapon-mastery.json new file mode 100644 index 0000000..18bd92f --- /dev/null +++ b/packs/_source/perks/weapon-mastery.json @@ -0,0 +1,44 @@ +{ + "_id": "vagabondPerkWeaponMastery", + "name": "Weapon Mastery", + "type": "perk", + "img": "icons/svg/sword.svg", + "system": { + "description": "

You have trained extensively with melee weapons. Your melee attacks deal +1 damage.

", + "prerequisites": { + "stats": { + "might": 4, + "dexterity": null, + "awareness": null, + "reason": null, + "presence": null, + "luck": null + }, + "trainedSkills": ["melee"], + "spells": [], + "perks": [], + "custom": "Melee Training" + }, + "changes": [ + { + "key": "system.attacks.melee.damageBonus", + "mode": 2, + "value": "1" + } + ], + "passive": true, + "uses": { + "value": 0, + "max": 0, + "per": "" + }, + "luckCost": 0, + "grantsLuck": 0, + "isRitual": false, + "ritualDuration": 0, + "ritualComponents": "", + "tags": ["combat", "melee", "training"] + }, + "effects": [], + "_key": "!items!vagabondPerkWeaponMastery" +} diff --git a/packs/ancestries/000012.ldb b/packs/ancestries/000012.ldb new file mode 100755 index 0000000..e8a28d9 Binary files /dev/null and b/packs/ancestries/000012.ldb differ diff --git a/packs/ancestries/000027.ldb b/packs/ancestries/000027.ldb new file mode 100644 index 0000000..937cc98 Binary files /dev/null and b/packs/ancestries/000027.ldb differ diff --git a/packs/ancestries/000029.ldb b/packs/ancestries/000029.ldb new file mode 100644 index 0000000..dba1bf3 Binary files /dev/null and b/packs/ancestries/000029.ldb differ diff --git a/packs/ancestries/000032.ldb b/packs/ancestries/000032.ldb new file mode 100644 index 0000000..0f9849c Binary files /dev/null and b/packs/ancestries/000032.ldb differ diff --git a/packs/ancestries/CURRENT b/packs/ancestries/CURRENT new file mode 100644 index 0000000..d95f027 --- /dev/null +++ b/packs/ancestries/CURRENT @@ -0,0 +1 @@ +MANIFEST-000031 diff --git a/packs/ancestries/LOCK b/packs/ancestries/LOCK new file mode 100755 index 0000000..e69de29 diff --git a/packs/ancestries/LOG b/packs/ancestries/LOG new file mode 100644 index 0000000..211130e --- /dev/null +++ b/packs/ancestries/LOG @@ -0,0 +1,5 @@ +2025/12/16-18:10:04.194010 7efc368006c0 Recovering log #30 +2025/12/16-18:10:04.194143 7efc368006c0 Level-0 table #32: started +2025/12/16-18:10:04.195439 7efc368006c0 Level-0 table #32: 3258 bytes OK +2025/12/16-18:10:04.198818 7efc368006c0 Delete type=0 #30 +2025/12/16-18:10:04.198914 7efc368006c0 Delete type=3 #28 diff --git a/packs/ancestries/LOG.old b/packs/ancestries/LOG.old new file mode 100644 index 0000000..1149ab3 --- /dev/null +++ b/packs/ancestries/LOG.old @@ -0,0 +1,5 @@ +2025/12/16-18:07:40.012790 7f160a8006c0 Recovering log #26 +2025/12/16-18:07:40.012916 7f160a8006c0 Level-0 table #29: started +2025/12/16-18:07:40.014122 7f160a8006c0 Level-0 table #29: 3258 bytes OK +2025/12/16-18:07:40.016513 7f160a8006c0 Delete type=0 #26 +2025/12/16-18:07:40.016583 7f160a8006c0 Delete type=3 #24 diff --git a/packs/ancestries/MANIFEST-000031 b/packs/ancestries/MANIFEST-000031 new file mode 100644 index 0000000..b51cd7c Binary files /dev/null and b/packs/ancestries/MANIFEST-000031 differ diff --git a/packs/classes/000015.ldb b/packs/classes/000015.ldb new file mode 100755 index 0000000..bd98752 Binary files /dev/null and b/packs/classes/000015.ldb differ diff --git a/packs/classes/000032.ldb b/packs/classes/000032.ldb new file mode 100644 index 0000000..a2b47a2 Binary files /dev/null and b/packs/classes/000032.ldb differ diff --git a/packs/classes/000034.ldb b/packs/classes/000034.ldb new file mode 100644 index 0000000..4fb2f8b Binary files /dev/null and b/packs/classes/000034.ldb differ diff --git a/packs/classes/000037.ldb b/packs/classes/000037.ldb new file mode 100644 index 0000000..2393e63 Binary files /dev/null and b/packs/classes/000037.ldb differ diff --git a/packs/classes/CURRENT b/packs/classes/CURRENT new file mode 100644 index 0000000..ecb0b4b --- /dev/null +++ b/packs/classes/CURRENT @@ -0,0 +1 @@ +MANIFEST-000036 diff --git a/packs/classes/LOCK b/packs/classes/LOCK new file mode 100755 index 0000000..e69de29 diff --git a/packs/classes/LOG b/packs/classes/LOG new file mode 100644 index 0000000..0f47ff4 --- /dev/null +++ b/packs/classes/LOG @@ -0,0 +1,5 @@ +2025/12/16-18:10:04.202642 7efc35e006c0 Recovering log #35 +2025/12/16-18:10:04.202961 7efc35e006c0 Level-0 table #37: started +2025/12/16-18:10:04.205022 7efc35e006c0 Level-0 table #37: 26320 bytes OK +2025/12/16-18:10:04.208106 7efc35e006c0 Delete type=0 #35 +2025/12/16-18:10:04.208186 7efc35e006c0 Delete type=3 #33 diff --git a/packs/classes/LOG.old b/packs/classes/LOG.old new file mode 100644 index 0000000..e387970 --- /dev/null +++ b/packs/classes/LOG.old @@ -0,0 +1,5 @@ +2025/12/16-18:07:40.018965 7f1609e006c0 Recovering log #31 +2025/12/16-18:07:40.019228 7f1609e006c0 Level-0 table #34: started +2025/12/16-18:07:40.021159 7f1609e006c0 Level-0 table #34: 26319 bytes OK +2025/12/16-18:07:40.023880 7f1609e006c0 Delete type=0 #31 +2025/12/16-18:07:40.023975 7f1609e006c0 Delete type=3 #29 diff --git a/packs/classes/MANIFEST-000036 b/packs/classes/MANIFEST-000036 new file mode 100644 index 0000000..6652241 Binary files /dev/null and b/packs/classes/MANIFEST-000036 differ diff --git a/packs/perks/CURRENT b/packs/perks/CURRENT new file mode 100644 index 0000000..32108be --- /dev/null +++ b/packs/perks/CURRENT @@ -0,0 +1 @@ +MANIFEST-000016 diff --git a/packs/perks/LOCK b/packs/perks/LOCK new file mode 100755 index 0000000..e69de29 diff --git a/packs/perks/LOG b/packs/perks/LOG new file mode 100644 index 0000000..fad62b4 --- /dev/null +++ b/packs/perks/LOG @@ -0,0 +1,3 @@ +2025/12/16-18:10:04.216777 7efc37c006c0 Recovering log #15 +2025/12/16-18:10:04.220349 7efc37c006c0 Delete type=0 #15 +2025/12/16-18:10:04.220487 7efc37c006c0 Delete type=3 #14 diff --git a/packs/perks/LOG.old b/packs/perks/LOG.old new file mode 100644 index 0000000..8e88dc2 --- /dev/null +++ b/packs/perks/LOG.old @@ -0,0 +1,3 @@ +2025/12/16-18:07:40.030894 7f16094006c0 Recovering log #13 +2025/12/16-18:07:40.033308 7f16094006c0 Delete type=0 #13 +2025/12/16-18:07:40.033390 7f16094006c0 Delete type=3 #12 diff --git a/packs/perks/MANIFEST-000016 b/packs/perks/MANIFEST-000016 new file mode 100644 index 0000000..4552a69 Binary files /dev/null and b/packs/perks/MANIFEST-000016 differ diff --git a/packs/spells/CURRENT b/packs/spells/CURRENT new file mode 100644 index 0000000..32108be --- /dev/null +++ b/packs/spells/CURRENT @@ -0,0 +1 @@ +MANIFEST-000016 diff --git a/packs/spells/LOCK b/packs/spells/LOCK new file mode 100755 index 0000000..e69de29 diff --git a/packs/spells/LOG b/packs/spells/LOG new file mode 100644 index 0000000..37de3c3 --- /dev/null +++ b/packs/spells/LOG @@ -0,0 +1,3 @@ +2025/12/16-18:10:04.211149 7efc372006c0 Recovering log #15 +2025/12/16-18:10:04.214021 7efc372006c0 Delete type=0 #15 +2025/12/16-18:10:04.214179 7efc372006c0 Delete type=3 #14 diff --git a/packs/spells/LOG.old b/packs/spells/LOG.old new file mode 100644 index 0000000..32d08a8 --- /dev/null +++ b/packs/spells/LOG.old @@ -0,0 +1,3 @@ +2025/12/16-18:07:40.026292 7f160b2006c0 Recovering log #13 +2025/12/16-18:07:40.028734 7f160b2006c0 Delete type=0 #13 +2025/12/16-18:07:40.028819 7f160b2006c0 Delete type=3 #12 diff --git a/packs/spells/MANIFEST-000016 b/packs/spells/MANIFEST-000016 new file mode 100644 index 0000000..4552a69 Binary files /dev/null and b/packs/spells/MANIFEST-000016 differ diff --git a/styles/scss/dialogs/_level-up-dialog.scss b/styles/scss/dialogs/_level-up-dialog.scss new file mode 100644 index 0000000..7cf5553 --- /dev/null +++ b/styles/scss/dialogs/_level-up-dialog.scss @@ -0,0 +1,241 @@ +// Vagabond RPG - Level Up Dialog Styles +// ====================================== +// Styles for the level-up dialog that shows gained features and choices. + +.vagabond.level-up-dialog { + &.themed { + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + } + + .level-up-content { + @include flex-column; + gap: $spacing-4; + padding: $spacing-4; + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + } + + // Header section + .level-up-header { + text-align: center; + padding-bottom: $spacing-3; + border-bottom: 2px solid var(--color-accent-primary); + + h2 { + margin: 0 0 $spacing-2 0; + font-family: $font-family-header; + font-size: $font-size-2xl; + font-weight: $font-weight-bold; + color: var(--color-accent-primary); + } + + .level-announcement { + margin: 0; + font-size: $font-size-lg; + color: var(--color-text-primary); + + strong { + color: var(--color-accent-primary); + } + } + } + + // Features gained section + .features-gained { + @include flex-column; + gap: $spacing-3; + + h3 { + margin: 0; + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + .feature-list { + list-style: none; + margin: 0; + padding: 0; + @include flex-column; + gap: $spacing-2; + } + + .feature-item { + @include panel; + padding: $spacing-3; + background-color: var(--color-bg-secondary); + + &.has-effects { + border-left: 3px solid var(--color-success); + } + + .feature-header { + @include flex-center; + justify-content: flex-start; + gap: $spacing-2; + margin-bottom: $spacing-2; + + .feature-name { + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + .feature-class { + font-size: $font-size-sm; + color: var(--color-text-muted); + } + + .feature-auto { + color: var(--color-success); + font-size: $font-size-sm; + + i { + font-size: $font-size-base; + } + } + } + + .feature-description { + font-size: $font-size-sm; + color: var(--color-text-secondary); + line-height: 1.5; + + p { + margin: 0; + } + + strong { + color: var(--color-text-primary); + } + } + } + } + + // Choice features section + .choice-features { + @include flex-column; + gap: $spacing-3; + + h3 { + margin: 0; + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + .choice-feature { + @include panel; + padding: $spacing-3; + background-color: var(--color-bg-secondary); + border-left: 3px solid var(--color-warning); + + .choice-label { + @include flex-center; + justify-content: flex-start; + gap: $spacing-2; + margin-bottom: $spacing-2; + + .feature-name { + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + .feature-class { + font-size: $font-size-sm; + color: var(--color-text-muted); + } + } + + .feature-description { + font-size: $font-size-sm; + color: var(--color-text-secondary); + margin-bottom: $spacing-3; + + p { + margin: 0; + } + } + + .perk-choice-select { + @include input-base; + width: 100%; + cursor: pointer; + } + } + } + + // Perk selection section + .perk-selection { + @include flex-column; + gap: $spacing-3; + + h3 { + margin: 0; + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + } + + .perk-instruction { + margin: 0; + font-size: $font-size-sm; + color: var(--color-text-secondary); + } + + .perk-slot { + @include panel; + padding: $spacing-3; + background-color: var(--color-bg-secondary); + + .perk-slot-label { + display: block; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + margin-bottom: $spacing-2; + } + + .perk-select { + @include input-base; + width: 100%; + cursor: pointer; + + option:disabled { + color: var(--color-text-muted); + } + } + } + } + + // No features message + .no-features { + @include panel; + padding: $spacing-4; + text-align: center; + background-color: var(--color-bg-secondary); + + p { + margin: 0; + color: var(--color-text-muted); + font-style: italic; + } + } + + // Confirm button + .dialog-buttons { + margin-top: $spacing-2; + + .confirm-btn { + @include button-primary; + width: 100%; + padding: $spacing-3; + font-size: $font-size-lg; + gap: $spacing-2; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + } + } +} diff --git a/styles/scss/vagabond.scss b/styles/scss/vagabond.scss index 836b4fc..a586287 100644 --- a/styles/scss/vagabond.scss +++ b/styles/scss/vagabond.scss @@ -22,6 +22,7 @@ // Dialogs @import "dialogs/roll-dialog"; +@import "dialogs/level-up-dialog"; // Chat @import "chat/chat-cards"; diff --git a/system.json b/system.json index 6ca897f..e101bfc 100644 --- a/system.json +++ b/system.json @@ -74,62 +74,13 @@ "PLAYER": "OBSERVER", "ASSISTANT": "OWNER" } - }, - { - "name": "weapons", - "label": "Weapons", - "path": "packs/weapons", - "type": "Item", - "ownership": { - "PLAYER": "OBSERVER", - "ASSISTANT": "OWNER" - } - }, - { - "name": "armor", - "label": "Armor", - "path": "packs/armor", - "type": "Item", - "ownership": { - "PLAYER": "OBSERVER", - "ASSISTANT": "OWNER" - } - }, - { - "name": "equipment", - "label": "Equipment", - "path": "packs/equipment", - "type": "Item", - "ownership": { - "PLAYER": "OBSERVER", - "ASSISTANT": "OWNER" - } - }, - { - "name": "bestiary", - "label": "Bestiary", - "path": "packs/bestiary", - "type": "Actor", - "ownership": { - "PLAYER": "NONE", - "ASSISTANT": "OWNER" - } } ], "packFolders": [ { "name": "Vagabond RPG", "sorting": "a", - "packs": [ - "ancestries", - "classes", - "spells", - "perks", - "weapons", - "armor", - "equipment", - "bestiary" - ] + "packs": ["ancestries", "classes"] } ], "documentTypes": { diff --git a/templates/dialog/level-up.hbs b/templates/dialog/level-up.hbs new file mode 100644 index 0000000..a2d20c7 --- /dev/null +++ b/templates/dialog/level-up.hbs @@ -0,0 +1,105 @@ +{{!-- Level Up Dialog Template --}} +{{!-- Shows features gained and handles perk/choice selections --}} + +
+
+

Level Up!

+

+ {{actor.name}} has reached Level {{newLevel}} +

+
+ + {{!-- Features Gained --}} + {{#if features.length}} +
+

Features Gained

+
    + {{#each features}} +
  • +
    + {{name}} + ({{className}}) + {{#if hasChanges}} + + + + {{/if}} +
    +
    {{{description}}}
    +
  • + {{/each}} +
+
+ {{/if}} + + {{!-- Choice Features (e.g., Fighting Style) --}} + {{#if hasChoices}} +
+

Feature Choices

+ {{#each choiceFeatures}} +
+ +
{{{description}}}
+ + {{!-- For perk choices with filtered list --}} + {{#if (eq choiceType "perk")}} + + {{/if}} +
+ {{/each}} +
+ {{/if}} + + {{!-- Perk Selection --}} + {{#if hasPerks}} +
+

Perk Selection

+

Choose a perk from the list below:

+ + {{#each perkSlots}} +
+ + +
+ {{/each}} +
+ {{/if}} + + {{!-- No features message --}} + {{#unless features.length}} + {{#unless hasPerks}} + {{#unless hasChoices}} +
+

No new features at this level.

+
+ {{/unless}} + {{/unless}} + {{/unless}} + + {{!-- Confirm Button --}} +
+ +
+