From 2a7e1c419ec610e5d3583d98ee71f3a318477198 Mon Sep 17 00:00:00 2001 From: Jacob Lucas Date: Tue, 1 Jun 2021 01:55:14 +0100 Subject: [PATCH] Updated to DND5e 1.3.2 Things unfinished: - Migration - The update adds new sections to the class sheet to allow some light customisation, this hasn't been included, but could be extended for the sake of dynamic classes with automatic class features and more - The French - The packs have not yet been updated, meaning due to the addition of a progression field to the class item, classes now don't set force or tech points - I updated the function calls in starships, but I didn't update it very thoroughly, it'll need checking - I only did a little testing - There has since been updates to DND5e that hasn't made it to release that patch bugs, those should be implemented Things changed from base 5e: - Short rests and long rests were merged into one function, this needed some rewrites to account for force and tech points, and for printing the correct message Extra Comments: - Unfinished code exists for automatic spell scrolls, this could be extended for single use force or tech powers - Weapon proficiencies probably need revising - Elven accuracy, halfling lucky, and reliable talent are present in the roll logic, this probably needs revising for sw5e - SW5e has a variant rule that permits force powers of any alignment to use either charisma or wisdom, that could be implemented - SW5e's version of gritty realism, [Longer Rests](https://sw5e.com/rules/variantRules/Longer%20Rests) differs from base dnd, this could be implemented - Extra ideas I've had while looking through the code can be found in Todos next to the ideas relevant context --- lang/en.json | 45 +- less/original/actors.less | 13 +- less/original/apps.less | 114 +- less/original/character.less | 3 +- less/original/npc.less | 23 + less/update/components/actor-global.less | 27 +- less/update/components/actor-themes.less | 3 + less/update/components/forms-global.less | 2 +- module/actor/entity.js | 1023 ++++++++++------- module/actor/sheets/newSheet/base.js | 183 +-- module/actor/sheets/newSheet/character.js | 35 +- module/actor/sheets/newSheet/npc.js | 20 +- module/actor/sheets/newSheet/starship.js | 13 +- module/actor/sheets/newSheet/vehicle.js | 53 +- module/actor/sheets/oldSheets/base.js | 166 ++- module/actor/sheets/oldSheets/character.js | 13 +- module/actor/sheets/oldSheets/npc.js | 20 +- module/actor/sheets/oldSheets/vehicle.js | 55 +- module/apps/ability-use-dialog.js | 10 +- module/apps/actor-flags.js | 28 +- module/apps/actor-type.js | 110 ++ module/apps/hit-dice-config.js | 91 ++ module/apps/long-rest.js | 6 +- module/apps/movement-config.js | 13 +- module/apps/select-items-prompt.js | 68 ++ module/apps/senses-config.js | 16 +- module/apps/short-rest.js | 2 +- module/apps/trait-selector.js | 70 +- module/canvas.js | 18 +- module/chat.js | 4 +- module/combat.js | 7 +- module/config.js | 172 ++- module/dice.js | 457 +++----- module/dice/d20-roll.js | 217 ++++ module/dice/damage-roll.js | 181 +++ module/dice/roll-dialog.js | 15 + module/effects.js | 4 +- module/item/entity.js | 374 ++++-- module/item/sheet.js | 96 +- module/migration.js | 1 + module/pixi/ability-template.js | 25 +- module/token.js | 99 ++ sw5e-dark.css | 3 + sw5e-global.css | 33 +- sw5e-light.css | 3 + sw5e.css | 133 ++- sw5e.js | 38 +- system.json | 4 +- template.json | 14 +- .../actors/newActor/character-sheet.html | 5 +- templates/actors/newActor/item.hbs | 2 +- templates/actors/newActor/limited-sheet.html | 4 +- templates/actors/newActor/npc-sheet.html | 34 +- .../newActor/parts/swalt-biography.html | 12 +- .../actors/newActor/parts/swalt-core.html | 26 +- .../actors/newActor/parts/swalt-notes.html | 10 +- .../newActor/parts/swalt-resources.html | 10 +- templates/actors/newActor/vehicle-sheet.html | 2 +- .../actors/oldActor/character-sheet.html | 37 +- templates/actors/oldActor/limited-sheet.html | 2 +- templates/actors/oldActor/npc-sheet.html | 27 +- .../actors/oldActor/parts/actor-features.html | 8 +- .../oldActor/parts/actor-powerbook.html | 2 +- templates/actors/oldActor/vehicle-sheet.html | 2 +- templates/apps/actor-flags.html | 9 + templates/apps/actor-type.html | 38 + templates/apps/hit-dice-config.html | 23 + templates/apps/select-items-promt.html | 18 + templates/chat/item-card.html | 7 +- templates/chat/roll-dialog.html | 14 +- templates/chat/tool-card.html | 18 - templates/chat/tool-roll-dialog.html | 33 - 72 files changed, 3107 insertions(+), 1359 deletions(-) create mode 100644 module/apps/actor-type.js create mode 100644 module/apps/hit-dice-config.js create mode 100644 module/apps/select-items-prompt.js create mode 100644 module/dice/d20-roll.js create mode 100644 module/dice/damage-roll.js create mode 100644 module/dice/roll-dialog.js create mode 100644 module/token.js create mode 100644 templates/apps/actor-type.html create mode 100644 templates/apps/hit-dice-config.html create mode 100644 templates/apps/select-items-promt.html delete mode 100644 templates/chat/tool-card.html delete mode 100644 templates/chat/tool-roll-dialog.html diff --git a/lang/en.json b/lang/en.json index de15c066..03a969fc 100644 --- a/lang/en.json +++ b/lang/en.json @@ -43,8 +43,10 @@ "SETTINGS.5eInitTBN": "Initiative Dexterity Tiebreaker", "SETTINGS.5eNoExpL": "Remove experience bars from character sheets.", "SETTINGS.5eNoExpN": "Disable Experience Tracking", + "SETTINGS.5eReset": "Reset", "SETTINGS.5eRestEpic": "Epic Heroism (LR: 1 hour, SR: 1 min)", "SETTINGS.5eRestGritty": "Gritty Realism (LR: 7 days, SR: 8 hours)", + "SETTINGS.5eUndoChanges": "Undo Changes", "SETTINGS.5eRestL": "Configure which rest variant should be used for games within this system.", "SETTINGS.5eRestN": "Rest Variant", "SETTINGS.5eRestPHB": "Player's Handbook (LR: 8 hours, SR: 1 hour)", @@ -104,7 +106,10 @@ "SW5E.ActionUtil": "Utility", "SW5E.ActionWarningNoItem": "The requested item {item} no longer exists on Actor {name}", "SW5E.ActionWarningNoToken": "You must have one or more controlled Tokens in order to use this option.", + "SW5E.ActorWarningInvalidItem": "{itemType} items cannot be added to a {actorType}.", "SW5E.Add": "Add", + "SW5E.AddEmbeddedItemPromptHint": "Do you want to add these items to your character sheet?", + "SW5E.SelectItemsPromptTitle": "Select Items", "SW5E.AdditionalNotes": "Additional Notes", "SW5E.Advantage": "Advantage", "SW5E.Alignment": "Alignment", @@ -118,6 +123,7 @@ "SW5E.AlignmentND": "Neutral Dark", "SW5E.AlignmentNL": "Neutral Light", "SW5E.Appearance": "Appearance", + "SW5E.Apply": "Apply", "SW5E.ArchetypeName": "Archetype Name", "SW5E.Archetypes": "Archetypes", "SW5E.ArmorClass": "Armor Class", @@ -199,7 +205,11 @@ "SW5E.ChatContextHealing": "Apply Healing", "SW5E.ChatFlavor": "Chat Message Flavor", "SW5E.ClassLevels": "Class Levels", + "SW5E.ClassMakeOriginal": "Original Class", + "SW5E.ClassMakeOriginalHint": "First class taken by character used to determine certain class traits when multiclassing.", "SW5E.ClassName": "Class Name", + "SW5E.ClassOriginal": "Original Class", + "SW5E.ClassSaves": "Saving Throws", "SW5E.ClassSkillsChosen": "Chosen Class Skills", "SW5E.ClassSkillsNumber": "Number of Starting Skills", "SW5E.Collapse": "Collapse/Expand", @@ -257,6 +267,33 @@ "SW5E.ConUnconscious": "Unconscious", "SW5E.Core": "Core", "SW5E.CostGP": "Cost (CR)", + "SW5E.CreatureAberration": "Aberration", + "SW5E.CreatureAberrationPl": "Aberrations", + "SW5E.CreatureBeast": "Beast", + "SW5E.CreatureBeastPl": "Beasts", + "SW5E.CreatureConstruct": "Construct", + "SW5E.CreatureConstructPl": "Constructs", + "SW5E.CreatureDroid": "Droid", + "SW5E.CreatureDroidPl": "Droids", + "SW5E.CreatureElemental": "Elemental", + "SW5E.CreatureElementalPl": "Elementals", + "SW5E.CreatureHuman": "Human", + "SW5E.CreatureHumanPl": "Humans", + "SW5E.CreatureHumanoid": "Humanoid", + "SW5E.CreatureHumanoidPl": "Humanoids", + "SW5E.CreaturePlant": "Plant", + "SW5E.CreaturePlantPl": "Plants", + "SW5E.CreatureUndead": "Undead", + "SW5E.CreatureUndeadPl": "Undead", + "SW5E.CreatureType": "Creature Type", + "SW5E.CreatureTypeTitle": "Configure Creature Type", + "SW5E.CreatureSwarm": "Swarm", + "SW5E.CreatureSwarmSize": "Swarm Size", + "SW5E.CreatureSwarmPhrase": "Swarm of {size} {type}", + "SW5E.CreatureTypeConfig": "Configure Creature Type", + "SW5E.CreatureTypeSelectorCustom": "Custom Type", + "SW5E.CreatureTypeSelectorSubtype": "Subtype", + "SW5E.Crewed": "Crewed", "SW5E.Cover": "Cover", "SW5E.CoverHalf": "Half", "SW5E.CoverThreeQuarters": "Three Quarters", @@ -264,6 +301,7 @@ "SW5E.CrewCap": "Crew Capacity", "SW5E.Critical": "Critical", "SW5E.CriticalHit": "Critical Hit", + "SW5E.PowerfulCritical": "Powerful Critical", "SW5E.Currency": "Currency", "SW5E.CurrencyConvert": "Convert All Currency", "SW5E.CurrencyConvertHint": "Convert all carried currency to the highest possible denomination to reduce the amount of coinage carried by the character. Be wary, this action cannot be undone.", @@ -493,6 +531,10 @@ "SW5E.HealthConditions": "Health Conditions", "SW5E.HealthFormula": "Health Formula", "SW5E.HitDice": "Hit Dice", + "SW5E.HitDiceConfig": "Adjust Hit Dice", + "SW5E.HitDiceConfigHint": "Adjust remaining hit dice levels for each class.", + "SW5E.HitDiceMax": "Maximum Hit Dice", + "SW5E.HitDiceRemaining": "Remaining Hit Dice", "SW5E.HitDiceRoll": "Roll Hit Dice", "SW5E.HitDiceUsed": "Hit Dice Used", "SW5E.HitDiceWarn": "{name} has no available {formula} Hit Dice remaining!", @@ -866,7 +908,7 @@ "SW5E.Role": "Role", "SW5E.RolePl": "Roles", "SW5E.Roll": "Roll", - "SW5E.RollExample": "e.g. +1d4", + "SW5E.RollExample": "e.g. 1d4", "SW5E.RollMode": "Roll Mode", "SW5E.RollSituationalBonus": "Situational Bonus?", "SW5E.Save": "Save", @@ -935,6 +977,7 @@ "SW5E.SkillPrc": "Perception", "SW5E.SkillPrf": "Performance", "SW5E.SkillPromptTitle": "{skill} Skill Check", + "SW5E.Skip": "Skip", "SW5E.Skills": "Skills", "SW5E.SkillSlt": "Sleight of Hand", "SW5E.SkillSte": "Stealth", diff --git a/less/original/actors.less b/less/original/actors.less index 84553cae..eef86c31 100644 --- a/less/original/actors.less +++ b/less/original/actors.less @@ -71,7 +71,7 @@ } // Movement Configuration - .movement { + .movement, .hit-dice { h4.attribute-name { position: relative; } @@ -655,6 +655,15 @@ // Empty powerbook controls .powerbook-empty .item-controls { flex: 1; } + /* ----------------------------------------- */ + /* Features Tab */ + /* ----------------------------------------- */ + + // Original class icon + .features i.original-class { + color: #4b4a44 + } + /* ----------------------------------------- */ /* TinyMCE */ /* ----------------------------------------- */ @@ -678,4 +687,4 @@ overflow-y: auto; scrollbar-width: thin; } -} \ No newline at end of file +} diff --git a/less/original/apps.less b/less/original/apps.less index 67701da2..67ca5cac 100644 --- a/less/original/apps.less +++ b/less/original/apps.less @@ -10,9 +10,7 @@ .sw5e { .window-content { - background: @sheetBackground; font-size: 13px; - color: @colorDark; } /* ----------------------------------------- */ @@ -44,6 +42,8 @@ select:disabled, textarea:disabled { color: @colorOlive; + border: 1px solid transparent !important; + outline: none !important; &:hover, &:focus { box-shadow: none !important; @@ -58,28 +58,6 @@ border: @borderGroove; } - // Checkbox Labels - // TODO: THIS CAN BE MOSTLY REMOVED NOW THAT IT IS IN CORE, see core forms.less - label.checkbox { - flex: auto; - padding: 0; - margin: 0; - height: 22px; - line-height: 22px; - font-size: 11px; - > input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 0 2px 0 0; - position: relative; - top: 4px; - } - &.right > input[type="checkbox"] { - margin: 0 0 0 2px; - } - } - - /* Form Groups */ .form-group { label { @@ -98,11 +76,12 @@ // Stacked Groups .form-group.stacked { - label { + > label { flex: 0 0 100%; margin: 0; } - label.checkbox { + label.checkbox, + label.radio { flex: auto; text-align: left; } @@ -131,6 +110,34 @@ } +/* ----------------------------------------- */ +/* Hit Dice Config Sheet Specifically */ +/* ----------------------------------------- */ + +.sw5e.hd-config { + .form-group { + button.increment, button.decrement { + flex: 0 0 1rem; + line-height: 1rem; + } + + button.decrement { + margin-right: 0; + } + + span.sep { + margin: 0; + } + + input { + flex: 0 0 2rem; + text-align: center; + margin-left: 2px; + margin-right: 2px; + } + } +} + /* ----------------------------------------- */ /* Entity Sheets Specifically */ /* ----------------------------------------- */ @@ -475,7 +482,7 @@ /* Trait Selector /* ----------------------------------------- */ -#trait-selector { +.trait-selector { .trait-list { list-style: none; margin: 0; @@ -488,6 +495,59 @@ } } +/* ----------------------------------------- */ +/* Actor Type Config Sheet Specifically */ +/* ----------------------------------------- */ + +.actor-type { + .trait-list { + display: flex; + flex-wrap: wrap; + li { + flex-basis: 50%; + flex-grow: 1; + } + li.form-group { + flex-basis: 100%; + } + } + label.radio { + display: flex; + flex: auto; + font-size: 12px; + line-height: 20px; + font-weight: normal; + > input[type="radio"] { + margin: 0 5px 0 0; + } + } + li.custom-type input[type="radio"] { + display: none; + } +} + +/* ----------------------------------------- */ +/* Add Feature Prompt Specifically */ +/* ----------------------------------------- */ + +.sw5e.select-items-prompt { + .dialog-content { + margin-bottom: 1em; + } + + .items-list { + margin-top: 0.5em; + } + + .item-name > label, .item-image, input { + cursor: pointer; + } + + .item-name > label { + align-items: center; + } +} + /* ----------------------------------------- */ /* HUD /* ----------------------------------------- */ diff --git a/less/original/character.less b/less/original/character.less index 358b1d4a..e08a754f 100644 --- a/less/original/character.less +++ b/less/original/character.less @@ -89,7 +89,7 @@ // Custom Resources .resource .attribute-value { - input { + > input { flex: 0 0 25%; } label.recharge { @@ -99,6 +99,7 @@ font-size: 11px; text-align: center; color: @colorOlive; + align-items: center; input[type="checkbox"] { height: 14px; width: 14px; diff --git a/less/original/npc.less b/less/original/npc.less index 506bf8de..21cb7be1 100644 --- a/less/original/npc.less +++ b/less/original/npc.less @@ -30,5 +30,28 @@ .summary { font-size: 18px; + + li.creature-type { + display: flex; + justify-content: space-between; + width: 1em; + padding: 0 3px; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; + } + &:hover .config-button { + display: block; + } + } } } \ No newline at end of file diff --git a/less/update/components/actor-global.less b/less/update/components/actor-global.less index 907906f4..b37cd430 100644 --- a/less/update/components/actor-global.less +++ b/less/update/components/actor-global.less @@ -140,6 +140,7 @@ height: auto; .russoOne(17px); line-height: 24px; + width: 100%; } .proficiency { @@ -1053,10 +1054,34 @@ h1.character-name { align-self: auto; } - .npc-size { + .npc-size, .creature-type { .russoOne(18px); line-height: 28px; } + + div.creature-type { + display: flex; + justify-content: space-between; + padding: 1px 4px; + border: 1px solid transparent; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; + } + &:hover .config-button { + display: block; + } + } + .attributes { grid-template-columns: repeat(3, 1fr); footer { diff --git a/less/update/components/actor-themes.less b/less/update/components/actor-themes.less index 3f1aa997..40ee3c84 100644 --- a/less/update/components/actor-themes.less +++ b/less/update/components/actor-themes.less @@ -408,6 +408,9 @@ &.npc { .swalt-sheet { header { + div.creature-type:hover { + border-color: @inputBorderFocus; + } .experience { color: @actorProficiencyTextColor; } diff --git a/less/update/components/forms-global.less b/less/update/components/forms-global.less index f5c1aee6..c6355041 100644 --- a/less/update/components/forms-global.less +++ b/less/update/components/forms-global.less @@ -1,4 +1,4 @@ -input[type="text"], input[type="number"], input[type="password"], input[type="date"], input[type="time"], select, textarea { +input[type="text"], input[type="number"], input[type="password"], input[type="date"], input[type="time"], select, textarea, .roundTransition { border-radius: 4px; transition: all 0.3s; &:hover { diff --git a/module/actor/entity.js b/module/actor/entity.js index 981d1aec..014c30c3 100644 --- a/module/actor/entity.js +++ b/module/actor/entity.js @@ -1,21 +1,62 @@ import { d20Roll, damageRoll } from "../dice.js"; +import SelectItemsPrompt from "../apps/select-items-prompt.js"; import ShortRestDialog from "../apps/short-rest.js"; import LongRestDialog from "../apps/long-rest.js"; import {SW5E} from '../config.js'; +import Item5e from "../item/entity.js"; /** * Extend the base Actor class to implement additional system-specific logic for SW5e. + * @extends {Actor} */ export default class Actor5e extends Actor { + /** + * The data source for Actor5e.classes allowing it to be lazily computed. + * @type {Object} + * @private + */ + _classes = undefined; + + /* -------------------------------------------- */ + /* Properties */ + /* -------------------------------------------- */ + + /** + * A mapping of classes belonging to this Actor. + * @type {Object} + */ + get classes() { + if ( this._classes !== undefined ) return this._classes; + if ( this.data.type !== "character" ) return this._classes = {}; + return this._classes = this.items.filter((item) => item.type === "class").reduce((obj, cls) => { + obj[cls.name.slugify({strict: true})] = cls; + return obj; + }, {}); + } + + /* -------------------------------------------- */ + /** * Is this Actor currently polymorphed into some other creature? - * @return {boolean} + * @type {boolean} */ get isPolymorphed() { return this.getFlag("sw5e", "isPolymorphed") || false; } + /* -------------------------------------------- */ + /* Methods */ + /* -------------------------------------------- */ + + /** @override */ + prepareData() { + super.prepareData(); + + // iterate over owned items and recompute attributes that depend on prepared actor data + this.items.forEach(item => item.prepareFinalAttributes()); + } + /* -------------------------------------------- */ /** @override */ @@ -81,6 +122,9 @@ export default class Actor5e extends Actor { // Prepare skills this._prepareSkills(actorData, bonuses, checkBonus, originalSkills); + // Reset class store to ensure it is updated with any changes + this._classes = undefined; + // Determine Initiative Modifier const init = data.attributes.init; const athlete = flags.remarkableAthlete; @@ -93,18 +137,14 @@ export default class Actor5e extends Actor { init.bonus = init.value + (flags.initiativeAlert ? 5 : 0); init.total = init.mod + init.prof + init.bonus; - // Prepare power-casting data - data.attributes.powerForceLightDC = 8 + data.abilities.wis.mod + data.attributes.prof ?? 10; - data.attributes.powerForceDarkDC = 8 + data.abilities.cha.mod + data.attributes.prof ?? 10; - data.attributes.powerForceUnivDC = Math.max(data.attributes.powerForceLightDC,data.attributes.powerForceDarkDC) ?? 10; - data.attributes.powerTechDC = 8 + data.abilities.int.mod + data.attributes.prof ?? 10; - this._computePowercastingProgression(this.data); + // Cache labels + this.labels = {}; + if ( this.type === "npc" ) { + this.labels["creatureType"] = this.constructor.formatCreatureType(data.details.type); + } - // Compute owned item attributes which depend on prepared Actor data - this.items.forEach(item => { - item.getSaveDC(); - item.getAttackToHit(); - }); + // Prepare power-casting data + this._computePowercastingProgression(this.data); } /* -------------------------------------------- */ @@ -133,21 +173,63 @@ export default class Actor5e extends Actor { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ getRollData() { const data = super.getRollData(); - data.classes = this.data.items.reduce((obj, i) => { - if ( i.type === "class" ) { - obj[i.name.slugify({strict: true})] = i.data; - } + data.prof = this.data.data.attributes.prof || 0; + data.classes = Object.entries(this.classes).reduce((obj, e) => { + const [slug, cls] = e; + obj[slug] = cls.data.data; return obj; }, {}); - data.prof = this.data.data.attributes.prof || 0; return data; } /* -------------------------------------------- */ + /** + * Given a list of items to add to the Actor, optionally prompt the + * user for which they would like to add. + * @param {Array.} items - The items being added to the Actor. + * @param {boolean} [prompt=true] - Whether or not to prompt the user. + * @returns {Promise} + */ + async addEmbeddedItems(items, prompt=true) { + let itemsToAdd = items; + if ( !items.length ) return []; + + // Obtain the array of item creation data + let toCreate = []; + if (prompt) { + const itemIdsToAdd = await SelectItemsPrompt.create(items, { + hint: game.i18n.localize('SW5E.AddEmbeddedItemPromptHint') + }); + for (let item of items) { + if (itemIdsToAdd.includes(item.id)) toCreate.push(item.toObject()); + } + } else { + toCreate = items.map(item => item.toObject()); + } + + // Create the requested items + if (itemsToAdd.length === 0) return []; + return Item5e.createDocuments(toCreate, {parent: this}); + } + + /* -------------------------------------------- */ + + /** + * Get a list of features to add to the Actor when a class item is updated. + * Optionally prompt the user for which they would like to add. + */ + async getClassFeatures({className, archetypeName, level}={}) { + const existing = new Set(this.items.map(i => i.name)); + const features = await Actor5e.loadClassFeatures({className, archetypeName, level}); + return features.filter(f => !existing.has(f.name)) || []; + } + + /* -------------------------------------------- */ + /** * Return the features which a character is awarded for each class level * @param {string} className The class name being added @@ -156,7 +238,7 @@ export default class Actor5e extends Actor { * @param {number} priorLevel The previous level of the added class * @return {Promise} Array of Item5e entities */ - static async getClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) { + static async loadClassFeatures({className="", archetypeName="", level=1, priorLevel=0}={}) { className = className.toLowerCase(); archetypeName = archetypeName.slugify(); @@ -195,52 +277,6 @@ export default class Actor5e extends Actor { return features; } - /* -------------------------------------------- */ - - /** @override */ - async updateEmbeddedEntity(embeddedName, data, options={}) { - const createItems = embeddedName === "OwnedItem" ? await this._createClassFeatures(data) : []; - let updated = await super.updateEmbeddedEntity(embeddedName, data, options); - if ( createItems.length ) await this.createEmbeddedEntity("OwnedItem", createItems); - return updated; - } - - /* -------------------------------------------- */ - - /** - * Create additional class features in the Actor when a class item is updated. - * @private - */ - async _createClassFeatures(updated) { - let toCreate = []; - for (let u of updated instanceof Array ? updated : [updated]) { - const item = this.items.get(u._id); - if (!item || (item.data.type !== "class")) continue; - const updateData = expandObject(u); - const config = { - className: updateData.name || item.data.name, - archetypeName: getProperty(updateData, "data.archetype") || item.data.data.archetype, - level: getProperty(updateData, "data.levels"), - priorLevel: item ? item.data.data.levels : 0 - } - - // Get and create features for an increased class level - let changed = false; - if ( config.level && (config.level > config.priorLevel)) changed = true; - if ( config.archetypeName !== item.data.data.archetype ) changed = true; - - // Get features to create - if ( changed ) { - const existing = new Set(this.items.map(i => i.name)); - const features = await Actor5e.getClassFeatures(config); - for ( let f of features ) { - if ( !existing.has(f.name) ) toCreate.push(f); - } - } - } - return toCreate - } - /* -------------------------------------------- */ /* Data Preparation Helpers */ /* -------------------------------------------- */ @@ -252,11 +288,11 @@ export default class Actor5e extends Actor { const data = actorData.data; // Determine character level and available hit dice based on owned Class items - const [level, hd] = actorData.items.reduce((arr, item) => { + const [level, hd] = this.items.reduce((arr, item) => { if ( item.type === "class" ) { - const classLevels = parseInt(item.data.levels) || 1; + const classLevels = parseInt(item.data.data.levels) || 1; arr[0] += classLevels; - arr[1] += classLevels - (parseInt(item.data.hitDiceUsed) || 0); + arr[1] += classLevels - (parseInt(item.data.data.hitDiceUsed) || 0); } return arr; }, [0, 0]); @@ -317,7 +353,7 @@ export default class Actor5e extends Actor { const data = actorData.data; // Proficiency - data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4); + data.attributes.prof = Math.floor((Math.max(data.details.tier, 1) + 7) / 4); // Link hull to hp and shields to temp hp data.attributes.hull.value = data.attributes.hp.value; @@ -388,9 +424,17 @@ export default class Actor5e extends Actor { */ _computePowercastingProgression (actorData) { if (actorData.type === 'vehicle' || actorData.type === 'starship') return; - const powers = actorData.data.powers; + const ad = actorData.data; + const powers = ad.powers; const isNPC = actorData.type === 'npc'; + // Powercasting DC + // TODO: Consider an option for using the variant rule of all powers use the same value + ad.attributes.powerForceLightDC = 8 + ad.abilities.wis.mod + ad.attributes.prof ?? 10; + ad.attributes.powerForceDarkDC = 8 + ad.abilities.cha.mod + ad.attributes.prof ?? 10; + ad.attributes.powerForceUnivDC = Math.max(ad.attributes.powerForceLightDC,ad.attributes.powerForceDarkDC) ?? 10 + ad.attributes.powerTechDC = 8 + ad.abilities.int.mod + ad.attributes.prof ?? 10; + // Translate the list of classes into force and tech power-casting progression const forceProgression = { classes: 0, @@ -419,11 +463,11 @@ export default class Actor5e extends Actor { const classes = this.data.items.filter(i => i.type === "class"); let priority = 0; for ( let cls of classes ) { - const d = cls.data; - if ( d.powercasting === "none" ) continue; + const d = cls.data.data; + if ( d.powercasting.progression === "none" ) continue; const levels = d.levels; - const prog = d.powercasting; - + const prog = d.powercasting.progression; + // TODO: Consider a more dynamic system switch (prog) { case 'consular': priority = 3; @@ -504,6 +548,7 @@ export default class Actor5e extends Actor { } // EXCEPTION: multi-classed progression uses multi rounded down rather than levels + // TODO: This could be cleaned up a little, one change at a time though if (!isNPC && forceProgression.classes > 1) { forceProgression.levels = Math.floor(forceProgression.multi); forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel['multi'][forceProgression.levels - 1]; @@ -514,16 +559,16 @@ export default class Actor5e extends Actor { } // EXCEPTION: NPC with an explicit power-caster level - if (isNPC && actorData.data.details.powerForceLevel) { - forceProgression.levels = actorData.data.details.powerForceLevel; - actorData.data.attributes.force.level = forceProgression.levels; - forceProgression.maxClass = actorData.data.attributes.powercasting; + if (isNPC && ad.details.powerForceLevel) { + forceProgression.levels = ad.details.powerForceLevel; + ad.attributes.force.level = forceProgression.levels; + forceProgression.maxClass = ad.attributes.powercasting; forceProgression.maxClassPowerLevel = SW5E.powerMaxLevel[forceProgression.maxClass][Math.clamped((forceProgression.levels - 1), 0, 20)]; } - if (isNPC && actorData.data.details.powerTechLevel) { - techProgression.levels = actorData.data.details.powerTechLevel; - actorData.data.attributes.tech.level = techProgression.levels; - techProgression.maxClass = actorData.data.attributes.powercasting; + if (isNPC && ad.details.powerTechLevel) { + techProgression.levels = ad.details.powerTechLevel; + ad.attributes.tech.level = techProgression.levels; + techProgression.maxClass = ad.attributes.powercasting; techProgression.maxClassPowerLevel = SW5E.powerMaxLevel[techProgression.maxClass][Math.clamped((techProgression.levels - 1), 0, 20)]; } @@ -563,15 +608,16 @@ export default class Actor5e extends Actor { } // Set Force and tech power for PC Actors + // TODO: Can join these !NPCs to save a whole comparison if (!isNPC && forceProgression.levels){ - actorData.data.attributes.force.known.max = forceProgression.powersKnown; - actorData.data.attributes.force.points.max = forceProgression.points + Math.max(actorData.data.abilities.wis.mod,actorData.data.abilities.cha.mod); - actorData.data.attributes.force.level = forceProgression.levels; + ad.attributes.force.known.max = forceProgression.powersKnown; + ad.attributes.force.points.max = forceProgression.points + Math.max(ad.abilities.wis.mod,ad.abilities.cha.mod); + ad.attributes.force.level = forceProgression.levels; } if (!isNPC && techProgression.levels){ - actorData.data.attributes.tech.known.max = techProgression.powersKnown; - actorData.data.attributes.tech.points.max = techProgression.points + actorData.data.abilities.int.mod; - actorData.data.attributes.tech.level = techProgression.levels; + ad.attributes.tech.known.max = techProgression.powersKnown; + ad.attributes.tech.points.max = techProgression.points + ad.abilities.int.mod; + ad.attributes.tech.level = techProgression.levels; } // Tally Powers Known and check for migration first to avoid errors @@ -596,8 +642,8 @@ export default class Actor5e extends Actor { } continue; } - actorData.data.attributes.force.known.value = knownForcePowers; - actorData.data.attributes.tech.known.value = knownTechPowers; + ad.attributes.force.known.value = knownForcePowers; + ad.attributes.tech.known.value = knownTechPowers; } } @@ -613,13 +659,13 @@ export default class Actor5e extends Actor { * @private */ _computeEncumbrance(actorData) { - + // TODO: Maybe add an option for variant encumbrance // Get the total weight from items const physicalItems = ["weapon", "equipment", "consumable", "tool", "backpack", "loot"]; let weight = actorData.items.reduce((weight, i) => { if ( !physicalItems.includes(i.type) ) return weight; - const q = i.data.quantity || 0; - const w = i.data.weight || 0; + const q = i.data.data.quantity || 0; + const w = i.data.data.weight || 0; return weight + (q * w); }, 0); @@ -649,128 +695,58 @@ export default class Actor5e extends Actor { } /* -------------------------------------------- */ - /* Socket Listeners and Handlers + /* Event Handlers /* -------------------------------------------- */ - /** @override */ - static async create(data, options={}) { - data.token = data.token || {}; - if ( data.type === "character" ) { - mergeObject(data.token, { - vision: true, - dimSight: 30, - brightSight: 0, - actorLink: true, - disposition: 1 - }, {overwrite: false}); + /** @inheritDoc */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + + // Token size category + const s = CONFIG.SW5E.tokenSizes[this.data.data.traits.size || "med"]; + this.data.token.update({width: s, height: s}); + + // Player character configuration + if ( this.type === "character" ) { + this.data.token.update({vision: true, actorLink: true, disposition: 1}); } - return super.create(data, options); } /* -------------------------------------------- */ - /** @override */ - async update(data, options={}) { + /** @inheritdoc */ + async _preUpdate(changed, options, user) { + await super._preUpdate(changed, options, user); // Apply changes in Actor size to Token width/height - const newSize = getProperty(data, "data.traits.size"); - if ( newSize && (newSize !== getProperty(this.data, "data.traits.size")) ) { + const newSize = foundry.utils.getProperty(changed, "data.traits.size"); + if ( newSize && (newSize !== foundry.utils.getProperty(this.data, "data.traits.size")) ) { let size = CONFIG.SW5E.tokenSizes[newSize]; - if ( this.isToken ) this.token.update({height: size, width: size}); - else if ( !data["token.width"] && !hasProperty(data, "token.width") ) { - data["token.height"] = size; - data["token.width"] = size; + if ( !foundry.utils.hasProperty(changed, "token.width") ) { + changed.token = changed.token || {}; + changed.token.height = size; + changed.token.width = size; } } // Reset death save counters - if ( (this.data.data.attributes.hp.value <= 0) && (getProperty(data, "data.attributes.hp.value") > 0) ) { - setProperty(data, "data.attributes.death.success", 0); - setProperty(data, "data.attributes.death.failure", 0); + const isDead = this.data.data.attributes.hp.value <= 0; + if ( isDead && (foundry.utils.getProperty(changed, "data.attributes.hp.value") > 0) ) { + foundry.utils.setProperty(changed, "data.attributes.death.success", 0); + foundry.utils.setProperty(changed, "data.attributes.death.failure", 0); } - - // Perform the update - return super.update(data, options); - } - - /* -------------------------------------------- */ - - /** @override */ - async createEmbeddedEntity(embeddedName, itemData, options={}) { - - // Pre-creation steps for owned items - if ( embeddedName === "OwnedItem" ) this._preCreateOwnedItem(itemData, options); - - // Standard embedded entity creation - return super.createEmbeddedEntity(embeddedName, itemData, options); } /* -------------------------------------------- */ /** - * A temporary shim function which will eventually (in core fvtt version 0.8.0+) be migrated to the new abstraction layer - * @param itemData - * @param options - * @private + * Assign a class item as the original class for the Actor based on which class has the most levels + * @protected */ - _preCreateOwnedItem(itemData, options) { - if ( this.data.type === "vehicle" ) return; - const isNPC = this.data.type === 'npc'; - let initial = {}; - switch ( itemData.type ) { - - case "weapon": - if ( getProperty(itemData, "data.equipped") === undefined ) { - initial["data.equipped"] = isNPC; // NPCs automatically equip weapons - } - if ( getProperty(itemData, "data.proficient") === undefined ) { - if ( isNPC ) { - initial["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const weaponProf = { - "natural": true, - "simpleVW": "sim", - "simpleB": "sim", - "simpleLW": "sim", - "martialVW": "mar", - "martialB": "mar", - "martialLW": "mar" - }[itemData.data?.weaponType]; // Player characters check proficiency - const actorWeaponProfs = this.data.data.traits?.weaponProf?.value || []; - const hasWeaponProf = (weaponProf === true) || actorWeaponProfs.includes(weaponProf); - initial["data.proficient"] = hasWeaponProf; - } - } - break; - - case "equipment": - if ( getProperty(itemData, "data.equipped") === undefined ) { - initial["data.equipped"] = isNPC; // NPCs automatically equip equipment - } - if ( getProperty(itemData, "data.proficient") === undefined ) { - if ( isNPC ) { - initial["data.proficient"] = true; // NPCs automatically have equipment proficiency - } else { - const armorProf = { - "natural": true, - "clothing": true, - "light": "lgt", - "medium": "med", - "heavy": "hvy", - "shield": "shl" - }[itemData.data?.armor?.type]; // Player characters check proficiency - const actorArmorProfs = this.data.data.traits?.armorProf?.value || []; - const hasEquipmentProf = (armorProf === true) || actorArmorProfs.includes(armorProf); - initial["data.proficient"] = hasEquipmentProf; - } - } - break; - - case "power": - initial["data.prepared"] = true; // automatically prepare powers for everyone - break; - } - mergeObject(itemData, initial); + _assignPrimaryClass() { + const classes = this.itemTypes.class.sort((a, b) => b.data.data.levels - a.data.data.levels); + const newPC = classes[0]?.id || ""; + return this.update({"data.details.originalClass": newPC}); } /* -------------------------------------------- */ @@ -862,15 +838,17 @@ export default class Actor5e extends Actor { const reliableTalent = (skl.value >= 1 && this.getFlag("sw5e", "reliableTalent")); // Roll and return - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.format("SW5E.SkillPromptTitle", {skill: CONFIG.SW5E.skills[skillId] || CONFIG.SW5E.starshipSkills[skillId]}), halflingLucky: this.getFlag("sw5e", "halflingLucky"), reliableTalent: reliableTalent, - messageData: {"flags.sw5e.roll": {type: "skill", skillId }} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "skill", skillId } + } }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); return d20Roll(rollData); } @@ -941,14 +919,16 @@ export default class Actor5e extends Actor { } // Roll and return - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.format("SW5E.AbilityPromptTitle", {ability: label}), halflingLucky: feats.halflingLucky, - messageData: {"flags.sw5e.roll": {type: "ability", abilityId }} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "ability", abilityId } + } }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); return d20Roll(rollData); } @@ -988,14 +968,16 @@ export default class Actor5e extends Actor { } // Roll and return - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.format("SW5E.SavePromptTitle", {ability: label}), halflingLucky: this.getFlag("sw5e", "halflingLucky"), - messageData: {"flags.sw5e.roll": {type: "save", abilityId }} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "save", abilityId } + } }); - rollData.speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); return d20Roll(rollData); } @@ -1018,26 +1000,26 @@ export default class Actor5e extends Actor { // Evaluate a global saving throw bonus const parts = []; const data = {}; - const speaker = options.speaker || ChatMessage.getSpeaker({actor: this}); // Include a global actor ability save bonus - const bonuses = getProperty(this.data.data, "bonuses.abilities") || {}; + const bonuses = foundry.utils.getProperty(this.data.data, "bonuses.abilities") || {}; if ( bonuses.save ) { parts.push("@saveBonus"); data.saveBonus = bonuses.save; } // Evaluate the roll - const rollData = mergeObject(options, { + const rollData = foundry.utils.mergeObject(options, { parts: parts, data: data, title: game.i18n.localize("SW5E.DeathSavingThrow"), - speaker: speaker, halflingLucky: this.getFlag("sw5e", "halflingLucky"), targetValue: 10, - messageData: {"flags.sw5e.roll": {type: "death"}} + messageData: { + speaker: options.speaker || ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "death"} + } }); - rollData.speaker = speaker; const roll = await d20Roll(rollData); if ( !roll ) return null; @@ -1045,6 +1027,8 @@ export default class Actor5e extends Actor { const success = roll.total >= 10; const d20 = roll.dice[0].total; + let chatString; + // Save success if ( success ) { let successes = (death.success || 0) + 1; @@ -1056,7 +1040,7 @@ export default class Actor5e extends Actor { "data.attributes.death.failure": 0, "data.attributes.hp.value": 1 }); - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveCriticalSuccess", {name: this.name}), speaker}); + chatString = "SW5E.DeathSaveCriticalSuccess"; } // 3 Successes = survive and reset checks @@ -1065,7 +1049,7 @@ export default class Actor5e extends Actor { "data.attributes.death.success": 0, "data.attributes.death.failure": 0 }); - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveSuccess", {name: this.name}), speaker}); + chatString = "SW5E.DeathSaveSuccess"; } // Increment successes @@ -1077,10 +1061,17 @@ export default class Actor5e extends Actor { let failures = (death.failure || 0) + (d20 === 1 ? 2 : 1); await this.update({"data.attributes.death.failure": Math.clamped(failures, 0, 3)}); if ( failures >= 3 ) { // 3 Failures = death - await ChatMessage.create({content: game.i18n.format("SW5E.DeathSaveFailure", {name: this.name}), speaker}); + chatString = "SW5E.DeathSaveFailure"; } } + // Display success/failure chat message + if ( chatString ) { + let chatData = { content: game.i18n.format(chatString, {name: this.name}), speaker }; + ChatMessage.applyRollMode(chatData, roll.options.rollMode); + await ChatMessage.create(chatData); + } + // Return the rolled result return roll; } @@ -1121,7 +1112,7 @@ export default class Actor5e extends Actor { // Prepare roll data const parts = [`1${denomination}`, "@abilities.con.mod"]; const title = game.i18n.localize("SW5E.HitDiceRoll"); - const rollData = duplicate(this.data.data); + const rollData = foundry.utils.deepClone(this.data.data); // Call the roll helper utility const roll = await damageRoll({ @@ -1129,11 +1120,13 @@ export default class Actor5e extends Actor { parts: parts, data: rollData, title: title, - speaker: ChatMessage.getSpeaker({actor: this}), - allowcritical: false, + allowCritical: false, fastForward: !dialog, dialogOptions: {width: 350}, - messageData: {"flags.sw5e.roll": {type: "hitDie"}} + messageData: { + speaker: ChatMessage.getSpeaker({actor: this}), + "flags.sw5e.roll": {type: "hitDie"} + } }); if ( !roll ) return null; @@ -1148,20 +1141,34 @@ export default class Actor5e extends Actor { /* -------------------------------------------- */ /** - * Cause this Actor to take a Short Rest and regain all Tech Points - * During a Short Rest resources and limited item uses may be recovered - * @param {boolean} dialog Present a dialog window which allows for rolling hit dice as part of the Short Rest - * @param {boolean} chat Summarize the results of the rest workflow as a chat message - * @param {boolean} autoHD Automatically spend Hit Dice if you are missing 3 or more hit points - * @param {boolean} autoHDThreshold A number of missing hit points which would trigger an automatic HD roll - * @return {Promise} A Promise which resolves once the short rest workflow has completed + * Results from a rest operation. + * + * @typedef {object} RestResult + * @property {number} dhp Hit points recovered during the rest. + * @property {number} dhd Hit dice recovered or spent during the rest. + * @property {object} updateData Updates applied to the actor. + * @property {Array.} updateItems Updates applied to actor's items. + * @property {boolean} newDay Whether a new day occurred during the rest. + */ + + /* -------------------------------------------- */ + + /** + * Take a short rest, possibly spending hit dice and recovering resources, item uses, and tech slots & points. + * + * @param {object} [options] + * @param {boolean} [options.dialog=true] Present a dialog window which allows for rolling hit dice as part + * of the Short Rest and selecting whether a new day has occurred. + * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. + * @param {boolean} [options.autoHD=false] Automatically spend Hit Dice if you are missing 3 or more hit points. + * @param {boolean} [options.autoHDThreshold=3] A number of missing hit points which would trigger an automatic HD roll. + * @return {Promise.} A Promise which resolves once the short rest workflow has completed. */ async shortRest({dialog=true, chat=true, autoHD=false, autoHDThreshold=3}={}) { // Take note of the initial hit points and number of hit dice the Actor has - const hp = this.data.data.attributes.hp; const hd0 = this.data.data.attributes.hd; - const hp0 = hp.value; + const hp0 = this.data.data.attributes.hp.value; let newDay = false; // Display a Dialog for rolling hit dice @@ -1175,96 +1182,24 @@ export default class Actor5e extends Actor { // Automatically spend hit dice else if ( autoHD ) { - while ( (hp.value + autoHDThreshold) <= hp.max ) { - const r = await this.rollHitDie(undefined, {dialog: false}); - if ( r === null ) break; - } + await this.autoSpendHitDice({ threshold: autoHDThreshold }); } - // Note the change in HP and HD and TP which occurred - const dhd = this.data.data.attributes.hd - hd0; - const dhp = this.data.data.attributes.hp.value - hp0; - const dtp = this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value; - - // Automatically Retore Tech Points - this.update({"data.attributes.tech.points.value": this.data.data.attributes.tech.points.max}); - - // Recover character resources - const updateData = {}; - for ( let [k, r] of Object.entries(this.data.data.resources) ) { - if ( r.max && r.sr ) { - updateData[`data.resources.${k}.value`] = r.max; - } - } - - // Recover item uses - const recovery = newDay ? ["sr", "day"] : ["sr"]; - const items = this.items.filter(item => item.data.data.uses && recovery.includes(item.data.data.uses.per)); - const updateItems = items.map(item => { - return { - _id: item._id, - "data.uses.value": item.data.data.uses.max - }; - }); - await this.updateEmbeddedEntity("OwnedItem", updateItems); - - // Display a Chat Message summarizing the rest effects - if ( chat ) { - - // Summarize the rest duration - let restFlavor; - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = game.i18n.localize("SW5E.ShortRestNormal"); break; - case 'gritty': restFlavor = game.i18n.localize(newDay ? "SW5E.ShortRestOvernight" : "SW5E.ShortRestGritty"); break; - case 'epic': restFlavor = game.i18n.localize("SW5E.ShortRestEpic"); break; - } - - // Summarize the health effects - let srMessage = "SW5E.ShortRestResultShort"; - if ((dhd !== 0) && (dhp !== 0)){ - if (dtp !== 0){ - srMessage = "SW5E.ShortRestResultWithTech"; - }else{ - srMessage = "SW5E.ShortRestResult"; - } - }else{ - if (dtp !== 0){ - srMessage = "SW5E.ShortRestResultOnlyTech"; - } - } - - // Create a chat message - ChatMessage.create({ - user: game.user._id, - speaker: {actor: this, alias: this.name}, - flavor: restFlavor, - content: game.i18n.format(srMessage, {name: this.name, dice: -dhd, health: dhp, tech: dtp}) - }); - } - - // Return data summarizing the rest effects - return { - dhd: dhd, - dhp: dhp, - dtp: dtp, - updateData: updateData, - updateItems: updateItems, - newDay: newDay - } + return this._rest(chat, newDay, false, this.data.data.attributes.hd - hd0, this.data.data.attributes.hp.value - hp0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value); } /* -------------------------------------------- */ /** - * Take a long rest, recovering HP, HD, resources, Force and Power points and power slots - * @param {boolean} dialog Present a confirmation dialog window whether or not to take a long rest - * @param {boolean} chat Summarize the results of the rest workflow as a chat message - * @param {boolean} newDay Whether the long rest carries over to a new day - * @return {Promise} A Promise which resolves once the long rest workflow has completed + * Take a long rest, recovering hit points, hit dice, resources, item uses, and tech & force power points & slots. + * + * @param {object} [options] + * @param {boolean} [options.dialog=true] Present a confirmation dialog window whether or not to take a long rest. + * @param {boolean} [options.chat=true] Summarize the results of the rest workflow as a chat message. + * @param {boolean} [options.newDay=true] Whether the long rest carries over to a new day. + * @return {Promise.} A Promise which resolves once the long rest workflow has completed. */ async longRest({dialog=true, chat=true, newDay=true}={}) { - const data = this.data.data; - // Maybe present a confirmation dialog if ( dialog ) { try { @@ -1274,109 +1209,304 @@ export default class Actor5e extends Actor { } } - // Recover hit, tech, and force points to full, and eliminate any existing temporary HP, TP, and FP - const dhp = data.attributes.hp.max - data.attributes.hp.value; - const dtp = data.attributes.tech.points.max - data.attributes.tech.points.value; - const dfp = data.attributes.force.points.max - data.attributes.force.points.value; - const updateData = { - "data.attributes.hp.value": data.attributes.hp.max, - "data.attributes.hp.temp": 0, - "data.attributes.hp.tempmax": 0, - "data.attributes.tech.points.value": data.attributes.tech.points.max, - "data.attributes.tech.points.temp": 0, - "data.attributes.tech.points.tempmax": 0, - "data.attributes.force.points.value": data.attributes.force.points.max, - "data.attributes.force.points.temp": 0, - "data.attributes.force.points.tempmax": 0 - }; - - // Recover character resources - for ( let [k, r] of Object.entries(data.resources) ) { - if ( r.max && (r.sr || r.lr) ) { - updateData[`data.resources.${k}.value`] = r.max; - } - } - - // Recover power slots - for ( let [k, v] of Object.entries(data.powers) ) { - updateData[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0); - } - for ( let [k, v] of Object.entries(data.powers) ) { - updateData[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0); - } - // Determine the number of hit dice which may be recovered - let recoverHD = Math.max(Math.floor(data.details.level / 2), 1); - let dhd = 0; - - // Sort classes which can recover HD, assuming players prefer recovering larger HD first. - const updateItems = this.items.filter(item => item.data.type === "class").sort((a, b) => { - let da = parseInt(a.data.data.hitDice.slice(1)) || 0; - let db = parseInt(b.data.data.hitDice.slice(1)) || 0; - return db - da; - }).reduce((updates, item) => { - const d = item.data.data; - if ( (recoverHD > 0) && (d.hitDiceUsed > 0) ) { - let delta = Math.min(d.hitDiceUsed || 0, recoverHD); - recoverHD -= delta; - dhd += delta; - updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); - } - return updates; - }, []); - - // Iterate over owned items, restoring uses per day and recovering Hit Dice - const recovery = newDay ? ["sr", "lr", "day"] : ["sr", "lr"]; - for ( let item of this.items ) { - const d = item.data.data; - if ( d.uses && recovery.includes(d.uses.per) ) { - updateItems.push({_id: item.id, "data.uses.value": d.uses.max}); - } - else if ( d.recharge && d.recharge.value ) { - updateItems.push({_id: item.id, "data.recharge.charged": true}); - } - } - - // Perform the updates - await this.update(updateData); - if ( updateItems.length ) await this.updateEmbeddedEntity("OwnedItem", updateItems); - - // Display a Chat Message summarizing the rest effects - let restFlavor; - switch (game.settings.get("sw5e", "restVariant")) { - case 'normal': restFlavor = game.i18n.localize(newDay ? "SW5E.LongRestOvernight" : "SW5E.LongRestNormal"); break; - case 'gritty': restFlavor = game.i18n.localize("SW5E.LongRestGritty"); break; - case 'epic': restFlavor = game.i18n.localize("SW5E.LongRestEpic"); break; - } - - // Determine the chat message to display - if ( chat ) { - let lrMessage = "SW5E.LongRestResult"; - if (dhp !== 0) lrMessage += "HP"; - if (dfp !== 0) lrMessage += "FP"; - if (dtp !== 0) lrMessage += "TP"; - if (dhd !== 0) lrMessage += "HD"; - ChatMessage.create({ - user: game.user._id, - speaker: {actor: this, alias: this.name}, - flavor: restFlavor, - content: game.i18n.format(lrMessage, {name: this.name, health: dhp, tech: dtp, force: dfp, dice: dhd}) - }); - } - - // Return data summarizing the rest effects - return { - dhd: dhd, - dhp: dhp, - dtp: dtp, - dfp: dfp, - updateData: updateData, - updateItems: updateItems, - newDay: newDay - } + return this._rest(chat, newDay, true, 0, 0, this.data.data.attributes.tech.points.max - this.data.data.attributes.tech.points.value, this.data.data.attributes.force.points.max - this.data.data.attributes.force.points.value); } /* -------------------------------------------- */ + /** + * Perform all of the changes needed for a short or long rest. + * + * @param {boolean} chat Summarize the results of the rest workflow as a chat message. + * @param {boolean} newDay Has a new day occurred during this rest? + * @param {boolean} longRest Is this a long rest? + * @param {number} [dhd=0] Number of hit dice spent during so far during the rest. + * @param {number} [dhp=0] Number of hit points recovered so far during the rest. + * @param {number} [dtp=0] Number of tech points recovered so far during the rest. + * @param {number} [dfp=0] Number of force points recovered so far during the rest. + * @return {Promise.} Consolidated results of the rest workflow. + * @private + */ + async _rest(chat, newDay, longRest, dhd=0, dhp=0, dtp=0, dfp=0) { + // TODO: Turn gritty realism into the SW5e longer rests variant rule https://sw5e.com/rules/variantRules/Longer%20Rests + let hitPointsRecovered = 0; + let hitPointUpdates = {}; + let hitDiceRecovered = 0; + let hitDiceUpdates = []; + + // Recover hit points & hit dice on long rest + if ( longRest ) { + ({ updates: hitPointUpdates, hitPointsRecovered } = this._getRestHitPointRecovery()); + ({ updates: hitDiceUpdates, hitDiceRecovered } = this._getRestHitDiceRecovery()); + } + + // Figure out the rest of the changes + const result = { + dhd: dhd + hitDiceRecovered, + dhp: dhp + hitPointsRecovered, + dtp: dtp, + dfp: dfp, + updateData: { + ...hitPointUpdates, + ...this._getRestResourceRecovery({ recoverShortRestResources: !longRest, recoverLongRestResources: longRest }), + ...this._getRestPowerRecovery({ recoverForcePowers: longRest }) + }, + updateItems: [ + ...hitDiceUpdates, + ...this._getRestItemUsesRecovery({ recoverLongRestUses: longRest, recoverDailyUses: newDay }) + ], + newDay: newDay + } + + // Perform updates + await this.update(result.updateData); + await this.updateEmbeddedDocuments("Item", result.updateItems); + + // Display a Chat Message summarizing the rest effects + if ( chat ) await this._displayRestResultMessage(result, longRest); + + // Return data summarizing the rest effects + return result; + } + + /* -------------------------------------------- */ + + /** + * Display a chat message with the result of a rest. + * + * @param {RestResult} result Result of the rest operation. + * @param {boolean} [longRest=false] Is this a long rest? + * @return {Promise.} Chat message that was created. + * @protected + */ + async _displayRestResultMessage(result, longRest=false) { + const { dhd, dhp, dtp, dfp, newDay } = result; + const diceRestored = dhd !== 0; + const healthRestored = dhp !== 0; + const length = longRest ? "Long" : "Short"; + + let restFlavor, message; + + // Summarize the rest duration + switch (game.settings.get("sw5e", "restVariant")) { + case 'normal': restFlavor = (longRest && newDay) ? "SW5E.LongRestOvernight" : `SW5E.${length}RestNormal`; break; + case 'gritty': restFlavor = (!longRest && newDay) ? "SW5E.ShortRestOvernight" : `SW5E.${length}RestGritty`; break; + case 'epic': restFlavor = `SW5E.${length}RestEpic`; break; + } + + // Determine the chat message to display + if (longRest) { + message = "SW5E.LongRestResult"; + if (dhp !== 0) message += "HP"; + if (dfp !== 0) message += "FP"; + if (dtp !== 0) message += "TP"; + if (dhd !== 0) message += "HD"; + } else { + message = "SW5E.ShortRestResultShort"; + if ((dhd !== 0) && (dhp !== 0)){ + if (dtp !== 0){ + message = "SW5E.ShortRestResultWithTech"; + }else{ + message = "SW5E.ShortRestResult"; + } + }else{ + if (dtp !== 0){ + message = "SW5E.ShortRestResultOnlyTech"; + } + } + } + + // Create a chat message + let chatData = { + user: game.user.id, + speaker: {actor: this, alias: this.name}, + flavor: game.i18n.localize(restFlavor), + content: game.i18n.format(message, { + name: this.name, + dice: longRest ? dhd : -dhd, + health: dhp, + tech: dtp, + force: dfp + }) + }; + ChatMessage.applyRollMode(chatData, game.settings.get("core", "rollMode")); + return ChatMessage.create(chatData); + } + + /* -------------------------------------------- */ + + /** + * Automatically spend hit dice to recover hit points up to a certain threshold. + * + * @param {object} [options] + * @param {number} [options.threshold=3] A number of missing hit points which would trigger an automatic HD roll. + * @return {Promise.} Number of hit dice spent. + */ + async autoSpendHitDice({ threshold=3 }={}) { + const max = this.data.data.attributes.hp.max + this.data.data.attributes.hp.tempmax; + + let diceRolled = 0; + while ( (this.data.data.attributes.hp.value + threshold) <= max ) { + const r = await this.rollHitDie(undefined, {dialog: false}); + if ( r === null ) break; + diceRolled += 1; + } + + return diceRolled; + } + + /* -------------------------------------------- */ + + /** + * Recovers actor hit points and eliminates any temp HP. + * + * @param {object} [options] + * @param {boolean} [options.recoverTemp=true] Reset temp HP to zero. + * @param {boolean} [options.recoverTempMax=true] Reset temp max HP to zero. + * @return {object} Updates to the actor and change in hit points. + * @protected + */ + _getRestHitPointRecovery({ recoverTemp=true, recoverTempMax=true }={}) { + const data = this.data.data; + let updates = {}; + let max = data.attributes.hp.max; + + if ( recoverTempMax ) { + updates["data.attributes.hp.tempmax"] = 0; + } else { + max += data.attributes.hp.tempmax; + } + updates["data.attributes.hp.value"] = max; + if ( recoverTemp ) { + updates["data.attributes.hp.temp"] = 0; + } + + return { updates, hitPointsRecovered: max - data.attributes.hp.value }; + } + + /* -------------------------------------------- */ + + /** + * Recovers actor resources. + * @param {object} [options] + * @param {boolean} [options.recoverShortRestResources=true] Recover resources that recharge on a short rest. + * @param {boolean} [options.recoverLongRestResources=true] Recover resources that recharge on a long rest. + * @return {object} Updates to the actor. + * @protected + */ + _getRestResourceRecovery({recoverShortRestResources=true, recoverLongRestResources=true}={}) { + let updates = {}; + for ( let [k, r] of Object.entries(this.data.data.resources) ) { + if ( Number.isNumeric(r.max) && ((recoverShortRestResources && r.sr) || (recoverLongRestResources && r.lr)) ) { + updates[`data.resources.${k}.value`] = Number(r.max); + } + } + return updates; + } + + /* -------------------------------------------- */ + + /** + * Recovers power slots. + * + * @param longRest = true It's a long rest + * @return {object} Updates to the actor. + * @protected + */ + _getRestPowerRecovery({ recoverTechPowers=true, recoverForcePowers=true }={}) { + let updates = {}; + + if (recoverTechPowers) { + updates["data.attributes.tech.points.value"] = this.data.data.attributes.tech.points.max; + updates["data.attributes.tech.points.temp"] = 0; + updates["data.attributes.tech.points.tempmax"] = 0; + + for (let [k, v] of Object.entries(this.data.data.powers)) { + updates[`data.powers.${k}.tvalue`] = Number.isNumeric(v.toverride) ? v.toverride : (v.tmax ?? 0); + } + } + + if (recoverForcePowers) { + updates["data.attributes.force.points.value"] = this.data.data.attributes.force.points.max; + updates["data.attributes.force.points.temp"] = 0; + updates["data.attributes.force.points.tempmax"] = 0; + + for ( let [k, v] of Object.entries(this.data.data.powers) ) { + updates[`data.powers.${k}.fvalue`] = Number.isNumeric(v.foverride) ? v.foverride : (v.fmax ?? 0); + } + } + + return updates; + } + + /* -------------------------------------------- */ + + /** + * Recovers class hit dice during a long rest. + * + * @param {object} [options] + * @param {number} [options.maxHitDice] Maximum number of hit dice to recover. + * @return {object} Array of item updates and number of hit dice recovered. + * @protected + */ + _getRestHitDiceRecovery({ maxHitDice=undefined }={}) { + // Determine the number of hit dice which may be recovered + if ( maxHitDice === undefined ) { + maxHitDice = Math.max(Math.floor(this.data.data.details.level / 2), 1); + } + + // Sort classes which can recover HD, assuming players prefer recovering larger HD first. + const sortedClasses = this.items.filter(item => item.data.type === "class").sort((a, b) => { + return (parseInt(a.data.data.hitDice.slice(1)) || 0) - (parseInt(a.data.data.hitDice.slice(1)) || 0); + }); + + let updates = []; + let hitDiceRecovered = 0; + for ( let item of sortedClasses ) { + const d = item.data.data; + if ( (hitDiceRecovered < maxHitDice) && (d.hitDiceUsed > 0) ) { + let delta = Math.min(d.hitDiceUsed || 0, maxHitDice - hitDiceRecovered); + hitDiceRecovered += delta; + updates.push({_id: item.id, "data.hitDiceUsed": d.hitDiceUsed - delta}); + } + } + + return { updates, hitDiceRecovered }; + } + + /* -------------------------------------------- */ + + /** + * Recovers item uses during short or long rests. + * + * @param {object} [options] + * @param {boolean} [options.recoverShortRestUses=true] Recover uses for items that recharge after a short rest. + * @param {boolean} [options.recoverLongRestUses=true] Recover uses for items that recharge after a long rest. + * @param {boolean} [options.recoverDailyUses=true] Recover uses for items that recharge on a new day. + * @return {Array.} Array of item updates. + * @protected + */ + _getRestItemUsesRecovery({ recoverShortRestUses=true, recoverLongRestUses=true, recoverDailyUses=true }={}) { + let recovery = []; + if ( recoverShortRestUses ) recovery.push("sr"); + if ( recoverLongRestUses ) recovery.push("lr"); + if ( recoverDailyUses ) recovery.push("day"); + + let updates = []; + for ( let item of this.items ) { + const d = item.data.data; + if ( d.uses && recovery.includes(d.uses.per) ) { + updates.push({_id: item.id, "data.uses.value": d.uses.max}); + } + if ( recoverLongRestUses && d.recharge && d.recharge.value ) { + updates.push({_id: item.id, "data.recharge.charged": true}); + } + } + + return updates; + } + + /* -------------------------------------------- */ /** * Transform this Actor into another one. @@ -1407,10 +1537,10 @@ export default class Actor5e extends Actor { } // Get the original Actor data and the new source data - const o = duplicate(this.toJSON()); + const o = this.toJSON(); o.flags.sw5e = o.flags.sw5e || {}; o.flags.sw5e.transformOptions = {mergeSkills, mergeSaves}; - const source = duplicate(target.toJSON()); + const source = target.toJSON(); // Prepare new data to merge from the source const d = { @@ -1419,40 +1549,38 @@ export default class Actor5e extends Actor { data: source.data, // Get the data model of your new form items: source.items, // Get the items of your new form effects: o.effects.concat(source.effects), // Combine active effects from both forms - token: source.token, // New token configuration img: source.img, // New appearance permission: o.permission, // Use the original actor permissions folder: o.folder, // Be displayed in the same sidebar folder flags: o.flags // Use the original actor flags }; - // Additional adjustments + // Specifically delete some data attributes delete d.data.resources; // Don't change your resource pools delete d.data.currency; // Don't lose currency delete d.data.bonuses; // Don't lose global bonuses - delete d.token.actorId; // Don't reference the old actor ID - d.token.actorLink = o.token.actorLink; // Keep your actor link - d.token.name = d.name; // Token name same as actor name + + // Specific additional adjustments d.data.details.alignment = o.data.details.alignment; // Don't change alignment d.data.attributes.exhaustion = o.data.attributes.exhaustion; // Keep your prior exhaustion level d.data.attributes.inspiration = o.data.attributes.inspiration; // Keep inspiration d.data.powers = o.data.powers; // Keep power slots - // Handle wildcard + // Token appearance updates + d.token = {name: d.name}; + for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) { + d.token[k] = source.token[k]; + } + if ( !keepVision ) { + for ( let k of ['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle'] ) { + d.token[k] = source.token[k]; + } + } if ( source.token.randomImg ) { const images = await target.getTokenImages(); d.token.img = images[Math.floor(Math.random() * images.length)]; } - // Keep Token configurations - const tokenConfig = ["displayName", "vision", "actorLink", "disposition", "displayBars", "bar1", "bar2"]; - if ( keepVision ) { - tokenConfig.push(...['dimSight', 'brightSight', 'dimLight', 'brightLight', 'vision', 'sightAngle']); - } - for ( let c of tokenConfig ) { - d.token[c] = o.token[c]; - } - // Transfer ability scores const abilities = d.data.abilities; for ( let k of Object.keys(abilities) ) { @@ -1519,13 +1647,13 @@ export default class Actor5e extends Actor { if ( !transformTokens ) return; const tokens = this.getActiveTokens(true); const updates = tokens.map(t => { - const newTokenData = duplicate(d.token); + const newTokenData = foundry.utils.deepClone(d.token); if ( !t.data.actorLink ) newTokenData.actorData = newActor.data; newTokenData._id = t.data._id; newTokenData.actorId = newActor.id; return newTokenData; }); - return canvas.scene?.updateEmbeddedEntity("Token", updates); + return canvas.scene?.updateEmbeddedDocuments("Token", updates); } /* -------------------------------------------- */ @@ -1537,16 +1665,19 @@ export default class Actor5e extends Actor { */ async revertOriginalForm() { if ( !this.isPolymorphed ) return; - if ( !this.owner ) { + if ( !this.isOwner ) { return ui.notifications.warn(game.i18n.localize("SW5E.PolymorphRevertWarn")); } // If we are reverting an unlinked token, simply replace it with the base actor prototype if ( this.isToken ) { const baseActor = game.actors.get(this.token.data.actorId); - const prototypeTokenData = duplicate(baseActor.token); - prototypeTokenData.actorData = null; - return this.token.update(prototypeTokenData); + const prototypeTokenData = await baseActor.getTokenData(); + const tokenUpdate = {actorData: {}}; + for ( let k of ["width", "height", "scale", "img", "mirrorX", "mirrorY", "tint", "alpha", "lockRotation"] ) { + tokenUpdate[k] = prototypeTokenData[k]; + } + return this.token.update(tokenUpdate, {recursive: false}); } // Obtain a reference to the original actor @@ -1557,18 +1688,19 @@ export default class Actor5e extends Actor { if ( canvas.ready ) { const tokens = this.getActiveTokens(true); const tokenUpdates = tokens.map(t => { - const tokenData = duplicate(original.data.token); + const tokenData = original.data.token.toJSON(); tokenData._id = t.id; tokenData.actorId = original.id; return tokenData; }); - canvas.scene.updateEmbeddedEntity("Token", tokenUpdates); + canvas.scene.updateEmbeddedDocuments("Token", tokenUpdates); } - // Delete the polymorphed Actor and maybe re-render the original sheet + // Delete the polymorphed version of the actor, if possible const isRendered = this.sheet.rendered; if ( game.user.isGM ) await this.delete(); - original.sheet.render(isRendered); + else if ( isRendered ) this.sheet.close(); + if ( isRendered ) original.sheet.render(isRendered); return original; } @@ -1596,6 +1728,33 @@ export default class Actor5e extends Actor { }); } + /* -------------------------------------------- */ + + /** + * Format a type object into a string. + * @param {object} typeData The type data to convert to a string. + * @returns {string} + */ + static formatCreatureType(typeData) { + if ( typeof typeData === "string" ) return typeData; // backwards compatibility + let localizedType; + if ( typeData.value === "custom" ) { + localizedType = typeData.custom; + } else { + let code = CONFIG.SW5E.creatureTypes[typeData.value]; + localizedType = game.i18n.localize(!!typeData.swarm ? `${code}Pl` : code); + } + let type = localizedType; + if ( !!typeData.swarm ) { + type = game.i18n.format('SW5E.CreatureSwarmPhrase', { + size: game.i18n.localize(CONFIG.SW5E.actorSizes[typeData.swarm]), + type: localizedType + }); + } + if (typeData.subtype) type = `${type} (${typeData.subtype})`; + return type; + } + /* -------------------------------------------- */ /* DEPRECATED METHODS */ /* -------------------------------------------- */ diff --git a/module/actor/sheets/newSheet/base.js b/module/actor/sheets/newSheet/base.js index 2575dad9..348dda4d 100644 --- a/module/actor/sheets/newSheet/base.js +++ b/module/actor/sheets/newSheet/base.js @@ -1,8 +1,10 @@ import Item5e from "../../../item/entity.js"; import TraitSelector from "../../../apps/trait-selector.js"; import ActorSheetFlags from "../../../apps/actor-flags.js"; +import ActorHitDiceConfig from "../../../apps/hit-dice-config.js"; import ActorMovementConfig from "../../../apps/movement-config.js"; import ActorSensesConfig from "../../../apps/senses-config.js"; +import ActorTypeConfig from "../../../apps/actor-type.js"; import {SW5E} from '../../../config.js'; import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js"; @@ -46,6 +48,14 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ + /** + * A set of item types that should be prevented from being dropped on this type of actor sheet. + * @type {Set} + */ + static unsupportedItemTypes = new Set(); + + /* -------------------------------------------- */ + /** @override */ get template() { if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/newActor/expanded-limited-sheet.html"; @@ -55,44 +65,51 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ /** @override */ - getData() { + getData(options) { // Basic data - let isOwner = this.entity.owner; + let isOwner = this.actor.isOwner; const data = { owner: isOwner, - limited: this.entity.limited, + limited: this.actor.limited, options: this.options, editable: this.isEditable, cssClass: isOwner ? "editable" : "locked", - isCharacter: this.entity.data.type === "character", - isNPC: this.entity.data.type === "npc", - isStarship: this.entity.data.type === "starship", - isVehicle: this.entity.data.type === 'vehicle', + isCharacter: this.actor.data.type === "character", + isNPC: this.actor.data.type === "npc", + isStarship: this.actor.data.type === "starship", + isVehicle: this.actor.data.type === 'vehicle', config: CONFIG.SW5E, + rollData: this.actor.getRollData.bind(this.actor) }; - // The Actor and its Items - data.actor = duplicate(this.actor.data); - data.items = this.actor.items.map(i => { - i.data.labels = i.labels; - return i.data; - }); + // The Actor's data + const actorData = this.actor.data.toObject(false); + data.actor = actorData; + data.data = actorData.data; + + // Owned Items + data.items = actorData.items; + for ( let i of data.items ) { + const item = this.actor.items.get(i._id); + i.labels = item.labels; + } data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - data.data = data.actor.data; + + // Labels and filters data.labels = this.actor.labels || {}; data.filters = this._filters; // Ability Scores - for ( let [a, abl] of Object.entries(data.actor.data.abilities)) { + for ( let [a, abl] of Object.entries(actorData.data.abilities)) { abl.icon = this._getProficiencyIcon(abl.proficient); abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; abl.label = CONFIG.SW5E.abilities[a]; } // Skills - if (data.actor.data.skills) { - for ( let [s, skl] of Object.entries(data.actor.data.skills)) { + if (actorData.data.skills) { + for ( let [s, skl] of Object.entries(actorData.data.skills)) { skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; skl.icon = this._getProficiencyIcon(skl.value); skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; @@ -105,19 +122,19 @@ export default class ActorSheet5e extends ActorSheet { } // Movement speeds - data.movement = this._getMovementSpeed(data.actor); + data.movement = this._getMovementSpeed(actorData); // Senses - data.senses = this._getSenses(data.actor); + data.senses = this._getSenses(actorData); // Update traits - this._prepareTraits(data.actor.data.traits); + this._prepareTraits(actorData.data.traits); // Prepare owned items this._prepareItems(data); // Prepare active effects - data.effects = prepareActiveEffectCategories(this.entity.effects); + data.effects = prepareActiveEffectCategories(this.actor.effects); // Return data to the sheet return data @@ -223,12 +240,13 @@ export default class ActorSheet5e extends ActorSheet { /** * Insert a power into the powerbook object when rendering the character sheet - * @param {Object} data The Actor data being prepared - * @param {Array} powers The power data being prepared + * @param {Object} data The Actor data being prepared + * @param {Array} powers The power data being prepared + * @param {string} school The school of the powerbook being prepared * @private */ _preparePowerbook(data, powers, school) { - const owner = this.actor.owner; + const owner = this.actor.isOwner; const levels = data.data.powers; const powerbook = {}; @@ -413,18 +431,18 @@ export default class ActorSheet5e extends ActorSheet { html.find('.item-create').click(this._onItemCreate.bind(this)); html.find('.item-edit').click(this._onItemEdit.bind(this)); html.find('.item-delete').click(this._onItemDelete.bind(this)); - html.find('.item-collapse').click(this._onItemCollapse.bind(this)); + html.find('.item-collapse').click(this._onItemCollapse.bind(this)); html.find('.item-uses input').click(ev => ev.target.select()).change(this._onUsesChange.bind(this)); html.find('.slot-max-override').click(this._onPowerSlotOverride.bind(this)); - html.find('.increment-class-level').click(this._onIncrementClassLevel.bind(this)); - html.find('.decrement-class-level').click(this._onDecrementClassLevel.bind(this)); + html.find('.increment-class-level').click(this._onIncrementClassLevel.bind(this)); + html.find('.decrement-class-level').click(this._onDecrementClassLevel.bind(this)); // Active Effect management - html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.entity)); + html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.actor)); } // Owner Only Listeners - if ( this.actor.owner ) { + if ( this.actor.isOwner ) { // Ability Checks html.find('.ability-name').click(this._onRollAbilityTest.bind(this)); @@ -491,17 +509,25 @@ export default class ActorSheet5e extends ActorSheet { _onConfigMenu(event) { event.preventDefault(); const button = event.currentTarget; + let app; switch ( button.dataset.action ) { + case "hit-dice": + app = new ActorHitDiceConfig(this.object); + break; case "movement": - new ActorMovementConfig(this.object).render(true); + app = new ActorMovementConfig(this.object); break; case "flags": - new ActorSheetFlags(this.object).render(true); + app = new ActorSheetFlags(this.object); break; case "senses": - new ActorSensesConfig(this.object).render(true); + app = new ActorSensesConfig(this.object); + break; + case "type": + new ActorTypeConfig(this.object).render(true); break; } + app?.render(true); } /* -------------------------------------------- */ @@ -535,7 +561,7 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ async _onDropActor(event, data) { - const canPolymorph = game.user.isGM || (this.actor.owner && game.settings.get('sw5e', 'allowPolymorphing')); + const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing')); if ( !canPolymorph ) return false; // Get the target actor @@ -610,15 +636,40 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ async _onDropItemCreate(itemData) { + // Check to make sure items of this type are allowed on this actor + if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) { + return ui.notifications.warn(game.i18n.format("SW5E.ActorWarningInvalidItem", { + itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]), + actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type]) + })); + } + // Create a Consumable power scroll on the Inventory tab if ( (itemData.type === "power") && (this._tabs[0].active === "inventory") ) { const scroll = await Item5e.createScrollFromPower(itemData); itemData = scroll.data; } - // Ignore certain statuses if ( itemData.data ) { - ["attunement", "equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + // Ignore certain statuses + ["equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + + // Downgrade ATTUNED to REQUIRED + itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED); + } + + // Stack identical consumables + if ( itemData.type === "consumable" && itemData.flags.core?.sourceId ) { + const similarItem = this.actor.items.find(i => { + const sourceId = i.getFlag("core", "sourceId"); + return sourceId && (sourceId === itemData.flags.core?.sourceId) && + (i.type === "consumable"); + }); + if ( similarItem ) { + return similarItem.update({ + 'data.quantity': similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1) + }); + } } // Create the owned item as normal @@ -659,7 +710,7 @@ export default class ActorSheet5e extends ActorSheet { async _onUsesChange(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); event.target.value = uses; return item.update({ 'data.uses.value': uses }); @@ -674,7 +725,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRoll(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.roll(); } @@ -688,7 +739,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRecharge(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.rollRecharge(); }; @@ -701,8 +752,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemSummary(event) { event.preventDefault(); let li = $(event.currentTarget).parents(".item"), - item = this.actor.getOwnedItem(li.data("item-id")), - chatData = item.getChatData({secrets: this.actor.owner}); + item = this.actor.items.get(li.data("item-id")), + chatData = item.getChatData({secrets: this.actor.isOwner}); // Toggle summary if ( li.hasClass("expanded") ) { @@ -733,10 +784,10 @@ export default class ActorSheet5e extends ActorSheet { const itemData = { name: game.i18n.format("SW5E.ItemNew", {type: type.capitalize()}), type: type, - data: duplicate(header.dataset) + data: foundry.utils.deepClone(header.dataset) }; delete itemData.data["type"]; - return this.actor.createEmbeddedEntity("OwnedItem", itemData); + return this.actor.createEmbeddedDocuments("Item", [itemData]); } /* -------------------------------------------- */ @@ -749,8 +800,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemEdit(event) { event.preventDefault(); const li = event.currentTarget.closest(".item"); - const item = this.actor.getOwnedItem(li.dataset.itemId); - item.sheet.render(true); + const item = this.actor.items.get(li.dataset.itemId); + return item.sheet.render(true); } /* -------------------------------------------- */ @@ -763,7 +814,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemDelete(event) { event.preventDefault(); const li = event.currentTarget.closest(".item"); - this.actor.deleteOwnedItem(li.dataset.itemId); + const item = this.actor.items.get(li.dataset.itemId); + if ( item ) return item.delete(); } /** @@ -803,12 +855,12 @@ _onItemCollapse(event) { const itemId = li.dataset.itemId; const actor = game.actors.get(actorId); - const item = actor.getOwnedItem(itemId); + const item = actor.items.get(itemId); let levels = item.data.data.levels; - const update = {_id: item._id, data: {levels: (levels + 1) }}; + const update = {_id: item.data._id, data: {levels: (levels + 1) }}; - actor.updateOwnedItem(update) + actor.updateEmbeddedDocuments("Item", [update]); } /** @@ -827,12 +879,12 @@ _onItemCollapse(event) { const itemId = li.dataset.itemId; const actor = game.actors.get(actorId); - const item = actor.getOwnedItem(itemId); + const item = actor.items.get(itemId); let levels = item.data.data.levels; - const update = {_id: item._id, data: {levels: (levels - 1) }}; + const update = {_id: item.data._id, data: {levels: (levels - 1) }}; - actor.updateOwnedItem(update) + actor.updateEmbeddedDocuments("Item", [update]); } /* -------------------------------------------- */ @@ -845,7 +897,7 @@ _onItemCollapse(event) { _onRollAbilityTest(event) { event.preventDefault(); let ability = event.currentTarget.parentElement.dataset.ability; - this.actor.rollAbility(ability, {event: event}); + return this.actor.rollAbility(ability, {event: event}); } /* -------------------------------------------- */ @@ -858,7 +910,7 @@ _onItemCollapse(event) { _onRollSkillCheck(event) { event.preventDefault(); const skill = event.currentTarget.parentElement.dataset.skill; - this.actor.rollSkill(skill, {event: event}); + return this.actor.rollSkill(skill, {event: event}); } /* -------------------------------------------- */ @@ -871,7 +923,7 @@ _onItemCollapse(event) { _onToggleAbilityProficiency(event) { event.preventDefault(); const field = event.currentTarget.previousElementSibling; - this.actor.update({[field.name]: 1 - parseInt(field.value)}); + return this.actor.update({[field.name]: 1 - parseInt(field.value)}); } /* -------------------------------------------- */ @@ -888,7 +940,7 @@ _onItemCollapse(event) { const filter = li.dataset.filter; if ( set.has(filter) ) set.delete(filter); else set.add(filter); - this.render(); + return this.render(); } /* -------------------------------------------- */ @@ -904,7 +956,7 @@ _onItemCollapse(event) { const label = a.parentElement.querySelector("label"); const choices = CONFIG.SW5E[a.dataset.options]; const options = { name: a.dataset.target, title: label.innerText, choices }; - new TraitSelector(this.actor, options).render(true) + return new TraitSelector(this.actor, options).render(true) } /* -------------------------------------------- */ @@ -912,15 +964,14 @@ _onItemCollapse(event) { /** @override */ _getHeaderButtons() { let buttons = super._getHeaderButtons(); - - // Add button to revert polymorph - if ( !this.actor.isPolymorphed || this.actor.isToken ) return buttons; - buttons.unshift({ - label: 'SW5E.PolymorphRestoreTransformation', - class: "restore-transformation", - icon: "fas fa-backward", - onclick: ev => this.actor.revertOriginalForm() - }); + if (this.actor.isPolymorphed) { + buttons.unshift({ + label: 'SW5E.PolymorphRestoreTransformation', + class: "restore-transformation", + icon: "fas fa-backward", + onclick: () => this.actor.revertOriginalForm() + }); + } return buttons; } -} \ No newline at end of file +} diff --git a/module/actor/sheets/newSheet/character.js b/module/actor/sheets/newSheet/character.js index 4b1a987f..d2382258 100644 --- a/module/actor/sheets/newSheet/character.js +++ b/module/actor/sheets/newSheet/character.js @@ -87,7 +87,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { let [items, forcepowers, techpowers, feats, classes, deployments, deploymentfeatures, ventures, species, archetypes, classfeatures, backgrounds, fightingstyles, fightingmasteries, lightsaberforms] = data.items.reduce((arr, item) => { // Item details - item.img = item.img || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.attunement = { [CONFIG.SW5E.attunementTypes.REQUIRED]: { @@ -111,6 +111,9 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { // Item toggle state this._prepareItemToggleState(item); + // Primary Class + if ( item.type === "class" ) item.isOriginalClass = ( item.data._id === this.actor.data.data.details.originalClass ); + // Classify items into types if ( item.type === "power" && ["lgt", "drk", "uni"].includes(item.data.school) ) arr[1].push(item); else if ( item.type === "power" && ["tec"].includes(item.data.school) ) arr[2].push(item); @@ -168,7 +171,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { if ( f.data.activation.type ) features.active.items.push(f); else features.passive.items.push(f); } - classes.sort((a, b) => b.levels - a.levels); + classes.sort((a, b) => b.data.levels - a.data.levels); features.classes.items = classes; features.classfeatures.items = classfeatures; features.archetype.items = archetypes; @@ -260,10 +263,10 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { if (["gmroll", "blindroll"].includes(rollMode)) rollWhisper = ChatMessage.getWhisperIDs("GM"); if (rollMode === "blindroll") rollBlind = true; ChatMessage.create({ - user: game.user._id, + user: game.user.data._id, content: content, speaker: { - actor: this.actor._id, + actor: this.actor.data._id, token: this.actor.token, alias: this.actor.name }, @@ -276,7 +279,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { html.find('.item-delete').click(event => { let li = $(event.currentTarget).parents('.item'); let itemId = li.attr("data-item-id"); - let item = this.actor.getOwnedItem(itemId); + let item = this.actor.items.get(itemId); new Dialog({ title: `Deleting ${item.data.name}`, content: `

Are you sure you want to delete ${item.data.name}?

`, @@ -327,7 +330,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { _onToggleItem(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped"; return item.update({[attr]: !getProperty(item.data, attr)}); } @@ -390,7 +393,7 @@ export default class ActorSheet5eCharacterNew extends ActorSheet5e { // } // Default drop handling if levels were not added - super._onDropItemCreate(itemData); + return super._onDropItemCreate(itemData); } } async function addFavorites(app, html, data) { @@ -465,11 +468,11 @@ async function addFavorites(app, html, data) { if (app.options.editable) { let favBtn = $(``); favBtn.click(ev => { - app.actor.getOwnedItem(item._id).update({ + app.actor.items.get(item.data._id).update({ "flags.favtab.isFavourite": !item.flags.favtab.isFavourite }); }); - html.find(`.item[data-item-id="${item._id}"]`).find('.item-controls').prepend(favBtn); + html.find(`.item[data-item-id="${item.data._id}"]`).find('.item-controls').prepend(favBtn); } if (isFav) { @@ -543,12 +546,12 @@ async function addFavorites(app, html, data) { //favtabHtml.find('.item-toggle').click(event => app._onToggleItem(event)); favtabHtml.find('.item-edit').click(ev => { let itemId = $(ev.target).parents('.item')[0].dataset.itemId; - app.actor.getOwnedItem(itemId).sheet.render(true); + app.actor.items.get(itemId).sheet.render(true); }); favtabHtml.find('.item-fav').click(ev => { let itemId = $(ev.target).parents('.item')[0].dataset.itemId; - let val = !app.actor.getOwnedItem(itemId).data.flags.favtab.isFavourite - app.actor.getOwnedItem(itemId).update({ + let val = !app.actor.items.get(itemId).data.flags.favtab.isFavourite + app.actor.items.get(itemId).update({ "flags.favtab.isFavourite": val }); }); @@ -564,10 +567,10 @@ async function addFavorites(app, html, data) { let list = null; if (dropData.data.type === 'feat') list = favFeats; else list = favItems; - let dragSource = list.find(i => i._id === dropData.data._id); - let siblings = list.filter(i => i._id !== dropData.data._id); + let dragSource = list.find(i => i.data._id === dropData.data._id); + let siblings = list.filter(i => i.data._id !== dropData.data._id); let targetId = ev.target.closest('.item').dataset.itemId; - let dragTarget = siblings.find(s => s._id === targetId); + let dragTarget = siblings.find(s => s.data._id === targetId); if (dragTarget === undefined) return; const sortUpdates = SortingHelpers.performIntegerSort(dragSource, { @@ -577,7 +580,7 @@ async function addFavorites(app, html, data) { }); const updateData = sortUpdates.map(u => { const update = u.update; - update._id = u.target._id; + update._id = u.target.data._id; return update; }); app.actor.updateEmbeddedEntity("OwnedItem", updateData); diff --git a/module/actor/sheets/newSheet/npc.js b/module/actor/sheets/newSheet/npc.js index 51c43006..dea59e8f 100644 --- a/module/actor/sheets/newSheet/npc.js +++ b/module/actor/sheets/newSheet/npc.js @@ -1,3 +1,4 @@ +import Actor5e from "../../entity.js"; import ActorSheet5e from "./base.js"; /** @@ -27,6 +28,11 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + /** * Organize Owned Items for rendering the NPC sheet * @private @@ -43,7 +49,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { // Start by classifying items into groups for rendering let [forcepowers, techpowers, other] = data.items.reduce((arr, item) => { - item.img = item.img || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.hasUses = item.data.uses && (item.data.uses.max > 0); item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); @@ -80,17 +86,19 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { data.techPowerbook = techPowerbook; } - /* -------------------------------------------- */ /** @override */ - getData() { - const data = super.getData(); + getData(options) { + const data = super.getData(options); // Challenge Rating const cr = parseFloat(data.data.details.cr || 0); const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; + + // Creature Type + data.labels["type"] = this.actor.labels.creatureType; return data; } @@ -99,7 +107,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { + async _updateObject(event, formData) { // Format NPC Challenge Rating const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; @@ -109,7 +117,7 @@ export default class ActorSheet5eNPCNew extends ActorSheet5e { if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); // Parent ActorSheet update steps - super._updateObject(event, formData); + return super._updateObject(event, formData); } /* -------------------------------------------- */ diff --git a/module/actor/sheets/newSheet/starship.js b/module/actor/sheets/newSheet/starship.js index 3e08cdcf..e27354d6 100644 --- a/module/actor/sheets/newSheet/starship.js +++ b/module/actor/sheets/newSheet/starship.js @@ -44,7 +44,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e { // Start by classifying items into groups for rendering let [forcepowers, techpowers, other] = data.items.reduce((arr, item) => { - item.img = item.img || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.hasUses = item.data.uses && (item.data.uses.max > 0); item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); @@ -91,8 +91,8 @@ export default class ActorSheet5eStarship extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - getData() { - const data = super.getData(); + getData(options) { + const data = super.getData(options); // Add Size info data.isTiny = data.actor.data.traits.size === "tiny"; @@ -114,7 +114,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { + async _updateObject(event, formData) { // Format NPC Challenge Rating const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; @@ -124,7 +124,7 @@ export default class ActorSheet5eStarship extends ActorSheet5e { if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); // Parent ActorSheet update steps - super._updateObject(event, formData); + return super._updateObject(event, formData); } /* -------------------------------------------- */ @@ -152,5 +152,4 @@ export default class ActorSheet5eStarship extends ActorSheet5e { AudioHelper.play({src: CONFIG.sounds.dice}); this.actor.update({"data.attributes.hp.value": hp, "data.attributes.hp.max": hp}); } - -} \ No newline at end of file +} diff --git a/module/actor/sheets/newSheet/vehicle.js b/module/actor/sheets/newSheet/vehicle.js index cba36ebd..804ce55f 100644 --- a/module/actor/sheets/newSheet/vehicle.js +++ b/module/actor/sheets/newSheet/vehicle.js @@ -20,6 +20,12 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + + /** * Creates a new cargo entry for a vehicle Actor. */ @@ -206,24 +212,39 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { } }; + // Classify items owned by the vehicle and compute total cargo weight let totalWeight = 0; for (const item of data.items) { this._prepareCrewedItem(item); - if (item.type === 'weapon') features.weapons.items.push(item); - else if (item.type === 'equipment') features.equipment.items.push(item); - else if (item.type === 'loot') { + + // Handle cargo explicitly + const isCargo = item.flags.sw5e?.vehicleCargo === true; + if ( isCargo ) { totalWeight += (item.data.weight || 0) * item.data.quantity; cargo.cargo.items.push(item); + continue } - else if (item.type === 'feat') { - if (!item.data.activation.type || item.data.activation.type === 'none') { - features.passive.items.push(item); - } - else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); - else features.actions.items.push(item); + + // Handle non-cargo item types + switch ( item.type ) { + case "weapon": + features.weapons.items.push(item); + break; + case "equipment": + features.equipment.items.push(item); + break; + case "feat": + if ( !item.data.activation.type || (item.data.activation.type === "none") ) features.passive.items.push(item); + else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); + else features.actions.items.push(item); + break; + default: + totalWeight += (item.data.weight || 0) * item.data.quantity; + cargo.cargo.items.push(item); } } + // Update the rendering context data data.features = Object.values(features); data.cargo = Object.values(cargo); data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data); @@ -272,7 +293,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const property = row.classList.contains('crew') ? 'crew' : 'passengers'; // Get the cargo entry - const cargo = duplicate(this.actor.data.data.cargo[property]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); const entry = cargo[idx]; if (!entry) return null; @@ -322,7 +343,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const target = event.currentTarget; const type = target.dataset.type; if (type === 'crew' || type === 'passengers') { - const cargo = duplicate(this.actor.data.data.cargo[type]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); cargo.push(this.constructor.newCargo); return this.actor.update({[`data.cargo.${type}`]: cargo}); } @@ -343,13 +364,21 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { if (row.classList.contains('cargo-row')) { const idx = Number(row.dataset.itemId); const type = row.classList.contains('crew') ? 'crew' : 'passengers'; - const cargo = duplicate(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); return this.actor.update({[`data.cargo.${type}`]: cargo}); } return super._onItemDelete(event); } + /** @override */ + async _onDropItemCreate(itemData) { + const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"]; + const isCargo = cargoTypes.includes(itemData.type) && (this._tabs[0].active === "cargo"); + foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo); + return super._onDropItemCreate(itemData); + } + /* -------------------------------------------- */ /** diff --git a/module/actor/sheets/oldSheets/base.js b/module/actor/sheets/oldSheets/base.js index aaabc84e..ab5aef2c 100644 --- a/module/actor/sheets/oldSheets/base.js +++ b/module/actor/sheets/oldSheets/base.js @@ -1,8 +1,10 @@ import Item5e from "../../../item/entity.js"; import TraitSelector from "../../../apps/trait-selector.js"; import ActorSheetFlags from "../../../apps/actor-flags.js"; +import ActorHitDiceConfig from "../../../apps/hit-dice-config.js"; import ActorMovementConfig from "../../../apps/movement-config.js"; import ActorSensesConfig from "../../../apps/senses-config.js"; +import ActorTypeConfig from "../../../apps/actor-type.js"; import {SW5E} from '../../../config.js'; import {onManageActiveEffect, prepareActiveEffectCategories} from "../../../effects.js"; @@ -44,6 +46,15 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ + /** + * A set of item types that should be prevented from being dropped on this type of actor sheet. + * @type {Set} + */ + static unsupportedItemTypes = new Set(); + + /* -------------------------------------------- */ + + /** @override */ get template() { if ( !game.user.isGM && this.actor.limited ) return "systems/sw5e/templates/actors/oldActor/limited-sheet.html"; @@ -53,44 +64,51 @@ export default class ActorSheet5e extends ActorSheet { /* -------------------------------------------- */ /** @override */ - getData() { + getData(options) { // Basic data - let isOwner = this.entity.owner; + let isOwner = this.actor.isOwner; const data = { owner: isOwner, - limited: this.entity.limited, + limited: this.actor.limited, options: this.options, editable: this.isEditable, cssClass: isOwner ? "editable" : "locked", - isCharacter: this.entity.data.type === "character", - isNPC: this.entity.data.type === "npc", - isStarship: this.entity.data.type === "starship", - isVehicle: this.entity.data.type === 'vehicle', + isCharacter: this.actor.type === "character", + isNPC: this.actor.type === "npc", + isStarship: this.actor.type === "starship", + isVehicle: this.actor.type === 'vehicle', config: CONFIG.SW5E, + rollData: this.actor.getRollData.bind(this.actor) }; - // The Actor and its Items - data.actor = duplicate(this.actor.data); - data.items = this.actor.items.map(i => { - i.data.labels = i.labels; - return i.data; - }); + // The Actor's data + const actorData = this.actor.data.toObject(false); + data.actor = actorData; + data.data = actorData.data; + + // Owned Items + data.items = actorData.items; + for ( let i of data.items ) { + const item = this.actor.items.get(i.data._id); + i.labels = item.labels; + } data.items.sort((a, b) => (a.sort || 0) - (b.sort || 0)); - data.data = data.actor.data; + + // Labels and filters data.labels = this.actor.labels || {}; data.filters = this._filters; // Ability Scores - for ( let [a, abl] of Object.entries(data.actor.data.abilities)) { + for ( let [a, abl] of Object.entries(actorData.data.abilities)) { abl.icon = this._getProficiencyIcon(abl.proficient); abl.hover = CONFIG.SW5E.proficiencyLevels[abl.proficient]; abl.label = CONFIG.SW5E.abilities[a]; } // Skills - if (data.actor.data.skills) { - for ( let [s, skl] of Object.entries(data.actor.data.skills)) { + if (actorData.data.skills) { + for ( let [s, skl] of Object.entries(actorData.data.skills)) { skl.ability = CONFIG.SW5E.abilityAbbreviations[skl.ability]; skl.icon = this._getProficiencyIcon(skl.value); skl.hover = CONFIG.SW5E.proficiencyLevels[skl.value]; @@ -99,19 +117,19 @@ export default class ActorSheet5e extends ActorSheet { } // Movement speeds - data.movement = this._getMovementSpeed(data.actor); + data.movement = this._getMovementSpeed(actorData); // Senses - data.senses = this._getSenses(data.actor); + data.senses = this._getSenses(actorData); // Update traits - this._prepareTraits(data.actor.data.traits); + this._prepareTraits(actorData.data.traits); // Prepare owned items this._prepareItems(data); // Prepare active effects - data.effects = prepareActiveEffectCategories(this.entity.effects); + data.effects = prepareActiveEffectCategories(this.actor.effects); // Return data to the sheet return data @@ -222,7 +240,7 @@ export default class ActorSheet5e extends ActorSheet { * @private */ _preparePowerbook(data, powers) { - const owner = this.actor.owner; + const owner = this.actor.isOwner; const levels = data.data.powers; const powerbook = {}; @@ -275,11 +293,14 @@ export default class ActorSheet5e extends ActorSheet { } // Pact magic users have cantrips and a pact magic section + // TODO: Check if this is needed, we've removed pacts everywhere else if ( levels.pact && levels.pact.max ) { if ( !powerbook["0"] ) registerSection("power0", 0, CONFIG.SW5E.powerLevels[0]); const l = levels.pact; const config = CONFIG.SW5E.powerPreparationModes.pact; - registerSection("pact", sections.pact, config, { + const level = game.i18n.localize(`SW5E.PowerLevel${levels.pact.level}`); + const label = `${config} — ${level}`; + registerSection("pact", sections.pact, label, { prepMode: "pact", value: l.value, max: l.max, @@ -424,11 +445,11 @@ export default class ActorSheet5e extends ActorSheet { html.find('.slot-max-override').click(this._onPowerSlotOverride.bind(this)); // Active Effect management - html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.entity)); + html.find(".effect-control").click(ev => onManageActiveEffect(ev, this.actor)); } // Owner Only Listeners - if ( this.actor.owner ) { + if ( this.actor.isOwner ) { // Ability Checks html.find('.ability-name').click(this._onRollAbilityTest.bind(this)); @@ -495,17 +516,25 @@ export default class ActorSheet5e extends ActorSheet { _onConfigMenu(event) { event.preventDefault(); const button = event.currentTarget; + let app; switch ( button.dataset.action ) { + case "hit-dice": + app = new ActorHitDiceConfig(this.object); + break; case "movement": - new ActorMovementConfig(this.object).render(true); + app = new ActorMovementConfig(this.object); break; case "flags": - new ActorSheetFlags(this.object).render(true); + app = new ActorSheetFlags(this.object); break; case "senses": - new ActorSensesConfig(this.object).render(true); + app = new ActorSensesConfig(this.object); + break; + case "type": + new ActorTypeConfig(this.object).render(true); break; } + app?.render(true); } /* -------------------------------------------- */ @@ -539,7 +568,7 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ async _onDropActor(event, data) { - const canPolymorph = game.user.isGM || (this.actor.owner && game.settings.get('sw5e', 'allowPolymorphing')); + const canPolymorph = game.user.isGM || (this.actor.isOwner && game.settings.get('sw5e', 'allowPolymorphing')); if ( !canPolymorph ) return false; // Get the target actor @@ -614,15 +643,41 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ async _onDropItemCreate(itemData) { + // Check to make sure items of this type are allowed on this actor + if ( this.constructor.unsupportedItemTypes.has(itemData.type) ) { + return ui.notifications.warn(game.i18n.format("SW5E.ActorWarningInvalidItem", { + itemType: game.i18n.localize(CONFIG.Item.typeLabels[itemData.type]), + actorType: game.i18n.localize(CONFIG.Actor.typeLabels[this.actor.type]) + })); + } + // Create a Consumable power scroll on the Inventory tab + // TODO: This is pretty non functional as the base items for the scrolls, and the powers, are not defined, maybe consider using holocrons if ( (itemData.type === "power") && (this._tabs[0].active === "inventory") ) { const scroll = await Item5e.createScrollFromPower(itemData); itemData = scroll.data; } - // Ignore certain statuses if ( itemData.data ) { - ["attunement", "equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + // Ignore certain statuses + ["equipped", "proficient", "prepared"].forEach(k => delete itemData.data[k]); + + // Downgrade ATTUNED to REQUIRED + itemData.data.attunement = Math.min(itemData.data.attunement, CONFIG.SW5E.attunementTypes.REQUIRED); + } + + // Stack identical consumables + if ( itemData.type === "consumable" && itemData.flags.core?.sourceId ) { + const similarItem = this.actor.items.find(i => { + const sourceId = i.getFlag("core", "sourceId"); + return sourceId && (sourceId === itemData.flags.core?.sourceId) && + (i.type === "consumable"); + }); + if ( similarItem ) { + return similarItem.update({ + 'data.quantity': similarItem.data.data.quantity + Math.max(itemData.data.quantity, 1) + }); + } } // Create the owned item as normal @@ -663,7 +718,7 @@ export default class ActorSheet5e extends ActorSheet { async _onUsesChange(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); const uses = Math.clamped(0, parseInt(event.target.value), item.data.data.uses.max); event.target.value = uses; return item.update({ 'data.uses.value': uses }); @@ -678,7 +733,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRoll(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.roll(); } @@ -692,7 +747,7 @@ export default class ActorSheet5e extends ActorSheet { _onItemRecharge(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); return item.rollRecharge(); }; @@ -705,8 +760,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemSummary(event) { event.preventDefault(); let li = $(event.currentTarget).parents(".item"), - item = this.actor.getOwnedItem(li.data("item-id")), - chatData = item.getChatData({secrets: this.actor.owner}); + item = this.actor.items.get(li.data("item-id")), + chatData = item.getChatData({secrets: this.actor.isOwner}); // Toggle summary if ( li.hasClass("expanded") ) { @@ -737,10 +792,10 @@ export default class ActorSheet5e extends ActorSheet { const itemData = { name: game.i18n.format("SW5E.ItemNew", {type: type.capitalize()}), type: type, - data: duplicate(header.dataset) + data: foundry.utils.deepClone(header.dataset) }; delete itemData.data["type"]; - return this.actor.createEmbeddedEntity("OwnedItem", itemData); + return this.actor.createEmbeddedDocuments("Item", [itemData]); } /* -------------------------------------------- */ @@ -753,8 +808,8 @@ export default class ActorSheet5e extends ActorSheet { _onItemEdit(event) { event.preventDefault(); const li = event.currentTarget.closest(".item"); - const item = this.actor.getOwnedItem(li.dataset.itemId); - item.sheet.render(true); + const item = this.actor.items.get(li.dataset.itemId); + return item.sheet.render(true); } /* -------------------------------------------- */ @@ -766,8 +821,8 @@ export default class ActorSheet5e extends ActorSheet { */ _onItemDelete(event) { event.preventDefault(); - const li = event.currentTarget.closest(".item"); - this.actor.deleteOwnedItem(li.dataset.itemId); + const item = this.actor.items.get(li.dataset.itemId); + if ( item ) return item.delete(); } /* -------------------------------------------- */ @@ -780,7 +835,7 @@ export default class ActorSheet5e extends ActorSheet { _onRollAbilityTest(event) { event.preventDefault(); let ability = event.currentTarget.parentElement.dataset.ability; - this.actor.rollAbility(ability, {event: event}); + return this.actor.rollAbility(ability, {event: event}); } /* -------------------------------------------- */ @@ -793,7 +848,7 @@ export default class ActorSheet5e extends ActorSheet { _onRollSkillCheck(event) { event.preventDefault(); const skill = event.currentTarget.parentElement.dataset.skill; - this.actor.rollSkill(skill, {event: event}); + return this.actor.rollSkill(skill, {event: event}); } /* -------------------------------------------- */ @@ -806,7 +861,7 @@ export default class ActorSheet5e extends ActorSheet { _onToggleAbilityProficiency(event) { event.preventDefault(); const field = event.currentTarget.previousElementSibling; - this.actor.update({[field.name]: 1 - parseInt(field.value)}); + return this.actor.update({[field.name]: 1 - parseInt(field.value)}); } /* -------------------------------------------- */ @@ -823,7 +878,7 @@ export default class ActorSheet5e extends ActorSheet { const filter = li.dataset.filter; if ( set.has(filter) ) set.delete(filter); else set.add(filter); - this.render(); + return this.render(); } /* -------------------------------------------- */ @@ -839,7 +894,7 @@ export default class ActorSheet5e extends ActorSheet { const label = a.parentElement.querySelector("label"); const choices = CONFIG.SW5E[a.dataset.options]; const options = { name: a.dataset.target, title: label.innerText, choices }; - new TraitSelector(this.actor, options).render(true) + return new TraitSelector(this.actor, options).render(true) } /* -------------------------------------------- */ @@ -847,15 +902,14 @@ export default class ActorSheet5e extends ActorSheet { /** @override */ _getHeaderButtons() { let buttons = super._getHeaderButtons(); - - // Add button to revert polymorph - if ( !this.actor.isPolymorphed || this.actor.isToken ) return buttons; - buttons.unshift({ - label: 'SW5E.PolymorphRestoreTransformation', - class: "restore-transformation", - icon: "fas fa-backward", - onclick: ev => this.actor.revertOriginalForm() - }); + if ( this.actor.isPolymorphed ) { + buttons.unshift({ + label: 'SW5E.PolymorphRestoreTransformation', + class: "restore-transformation", + icon: "fas fa-backward", + onclick: () => this.actor.revertOriginalForm() + }); + } return buttons; } } \ No newline at end of file diff --git a/module/actor/sheets/oldSheets/character.js b/module/actor/sheets/oldSheets/character.js index dc28b205..11219716 100644 --- a/module/actor/sheets/oldSheets/character.js +++ b/module/actor/sheets/oldSheets/character.js @@ -76,7 +76,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { let [items, powers, feats, classes, species, archetypes, classfeatures, backgrounds, fightingstyles, fightingmasteries, lightsaberforms] = data.items.reduce((arr, item) => { // Item details - item.img = item.img || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.attunement = { [CONFIG.SW5E.attunementTypes.REQUIRED]: { @@ -100,6 +100,9 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { // Item toggle state this._prepareItemToggleState(item); + // Primary Class + if ( item.type === "class" ) item.isOriginalClass = ( item.data._id === this.actor.data.data.details.originalClass ); + // Classify items into types if ( item.type === "power" ) arr[1].push(item); else if ( item.type === "feat" ) arr[2].push(item); @@ -151,7 +154,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { if ( f.data.activation.type ) features.active.items.push(f); else features.passive.items.push(f); } - classes.sort((a, b) => b.levels - a.levels); + classes.sort((a, b) => b.data.levels - a.data.levels); features.classes.items = classes; features.classfeatures.items = classfeatures; features.archetype.items = archetypes; @@ -243,7 +246,7 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { _onToggleItem(event) { event.preventDefault(); const itemId = event.currentTarget.closest(".item").dataset.itemId; - const item = this.actor.getOwnedItem(itemId); + const item = this.actor.items.get(itemId); const attr = item.data.type === "power" ? "data.preparation.prepared" : "data.equipped"; return item.update({[attr]: !getProperty(item.data, attr)}); } @@ -293,6 +296,6 @@ export default class ActorSheet5eCharacter extends ActorSheet5e { } // Default drop handling if levels were not added - super._onDropItemCreate(itemData); + return super._onDropItemCreate(itemData); } -} \ No newline at end of file +} diff --git a/module/actor/sheets/oldSheets/npc.js b/module/actor/sheets/oldSheets/npc.js index 366dc65e..12e85b1f 100644 --- a/module/actor/sheets/oldSheets/npc.js +++ b/module/actor/sheets/oldSheets/npc.js @@ -18,6 +18,11 @@ export default class ActorSheet5eNPC extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + /** * Organize Owned Items for rendering the NPC sheet * @private @@ -34,7 +39,7 @@ export default class ActorSheet5eNPC extends ActorSheet5e { // Start by classifying items into groups for rendering let [powers, other] = data.items.reduce((arr, item) => { - item.img = item.img || DEFAULT_TOKEN; + item.img = item.img || CONST.DEFAULT_TOKEN; item.isStack = Number.isNumeric(item.data.quantity) && (item.data.quantity !== 1); item.hasUses = item.data.uses && (item.data.uses.max > 0); item.isOnCooldown = item.data.recharge && !!item.data.recharge.value && (item.data.recharge.charged === false); @@ -70,14 +75,17 @@ export default class ActorSheet5eNPC extends ActorSheet5e { /* -------------------------------------------- */ - /** @override */ - getData() { - const data = super.getData(); + /** @inheritdoc */ + getData(options) { + const data = super.getData(options); // Challenge Rating const cr = parseFloat(data.data.details.cr || 0); const crLabels = {0: "0", 0.125: "1/8", 0.25: "1/4", 0.5: "1/2"}; data.labels["cr"] = cr >= 1 ? String(cr) : crLabels[cr] || 1; + + // Creature Type + data.labels["type"] = this.actor.labels.creatureType; return data; } @@ -86,7 +94,7 @@ export default class ActorSheet5eNPC extends ActorSheet5e { /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { + async _updateObject(event, formData) { // Format NPC Challenge Rating const crs = {"1/8": 0.125, "1/4": 0.25, "1/2": 0.5}; @@ -96,7 +104,7 @@ export default class ActorSheet5eNPC extends ActorSheet5e { if ( cr ) formData[crv] = cr < 1 ? cr : parseInt(cr); // Parent ActorSheet update steps - super._updateObject(event, formData); + return super._updateObject(event, formData); } /* -------------------------------------------- */ diff --git a/module/actor/sheets/oldSheets/vehicle.js b/module/actor/sheets/oldSheets/vehicle.js index cba36ebd..35570186 100644 --- a/module/actor/sheets/oldSheets/vehicle.js +++ b/module/actor/sheets/oldSheets/vehicle.js @@ -20,6 +20,12 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + static unsupportedItemTypes = new Set(["class"]); + + /* -------------------------------------------- */ + + /** * Creates a new cargo entry for a vehicle Actor. */ @@ -206,24 +212,39 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { } }; + // Classify items owned by the vehicle and compute total cargo weight let totalWeight = 0; for (const item of data.items) { this._prepareCrewedItem(item); - if (item.type === 'weapon') features.weapons.items.push(item); - else if (item.type === 'equipment') features.equipment.items.push(item); - else if (item.type === 'loot') { + + // Handle cargo explicitly + const isCargo = item.flags.sw5e?.vehicleCargo === true; + if ( isCargo ) { totalWeight += (item.data.weight || 0) * item.data.quantity; cargo.cargo.items.push(item); + continue; } - else if (item.type === 'feat') { - if (!item.data.activation.type || item.data.activation.type === 'none') { - features.passive.items.push(item); - } - else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); - else features.actions.items.push(item); + + // Handle non-cargo item types + switch ( item.type ) { + case "weapon": + features.weapons.items.push(item); + break; + case "equipment": + features.equipment.items.push(item); + break; + case "feat": + if (!item.data.activation.type || (item.data.activation.type === "none")) features.passive.items.push(item); + else if (item.data.activation.type === 'reaction') features.reactions.items.push(item); + else features.actions.items.push(item); + break; + default: + totalWeight += (item.data.weight || 0) * item.data.quantity; + cargo.cargo.items.push(item); } } + // Update the rendering context data data.features = Object.values(features); data.cargo = Object.values(cargo); data.data.attributes.encumbrance = this._computeEncumbrance(totalWeight, data); @@ -272,7 +293,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const property = row.classList.contains('crew') ? 'crew' : 'passengers'; // Get the cargo entry - const cargo = duplicate(this.actor.data.data.cargo[property]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[property]); const entry = cargo[idx]; if (!entry) return null; @@ -322,7 +343,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { const target = event.currentTarget; const type = target.dataset.type; if (type === 'crew' || type === 'passengers') { - const cargo = duplicate(this.actor.data.data.cargo[type]); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]); cargo.push(this.constructor.newCargo); return this.actor.update({[`data.cargo.${type}`]: cargo}); } @@ -343,7 +364,7 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { if (row.classList.contains('cargo-row')) { const idx = Number(row.dataset.itemId); const type = row.classList.contains('crew') ? 'crew' : 'passengers'; - const cargo = duplicate(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); + const cargo = foundry.utils.deepClone(this.actor.data.data.cargo[type]).filter((_, i) => i !== idx); return this.actor.update({[`data.cargo.${type}`]: cargo}); } @@ -352,6 +373,16 @@ export default class ActorSheet5eVehicle extends ActorSheet5e { /* -------------------------------------------- */ + /** @override */ + async _onDropItemCreate(itemData) { + const cargoTypes = ["weapon", "equipment", "consumable", "tool", "loot", "backpack"]; + const isCargo = cargoTypes.includes(itemData.type) && (this._tabs[0].active === "cargo"); + foundry.utils.setProperty(itemData, "flags.sw5e.vehicleCargo", isCargo); + return super._onDropItemCreate(itemData); + } + + /* -------------------------------------------- */ + /** * Special handling for editing HP to clamp it within appropriate range. * @param event {Event} diff --git a/module/apps/ability-use-dialog.js b/module/apps/ability-use-dialog.js index bcbc9b25..ca9067d3 100644 --- a/module/apps/ability-use-dialog.js +++ b/module/apps/ability-use-dialog.js @@ -44,7 +44,7 @@ export default class AbilityUseDialog extends Dialog { consumePowerSlot: false, consumeRecharge: recharges, consumeResource: !!itemData.consume.target, - consumeUses: uses.max, + consumeUses: uses.per && (uses.max > 0), canUse: recharges ? recharge.charged : sufficientUses, createTemplate: game.user.can("TEMPLATE_CREATE") && item.hasAreaTarget, errors: [] @@ -169,7 +169,7 @@ export default class AbilityUseDialog extends Dialog { })); // Merge power casting data - return mergeObject(data, { isPower: true, consumePowerSlot, powerLevels }); + return foundry.utils.mergeObject(data, { isPower: true, consumePowerSlot, powerLevels }); } /* -------------------------------------------- */ @@ -219,10 +219,4 @@ export default class AbilityUseDialog extends Dialog { }); } } - - /* -------------------------------------------- */ - - static _handleSubmit(formData, item) { - - } } diff --git a/module/apps/actor-flags.js b/module/apps/actor-flags.js index 86293dab..d4599996 100644 --- a/module/apps/actor-flags.js +++ b/module/apps/actor-flags.js @@ -1,11 +1,10 @@ /** * An application class which provides advanced configuration for special character flags which modify an Actor - * @implements {BaseEntitySheet} + * @extends {DocumentSheet} */ -export default class ActorSheetFlags extends BaseEntitySheet { +export default class ActorSheetFlags extends DocumentSheet { static get defaultOptions() { - const options = super.defaultOptions; - return mergeObject(options, { + return foundry.utils.mergeObject(super.defaultOptions, { id: "actor-flags", classes: ["sw5e"], template: "systems/sw5e/templates/apps/actor-flags.html", @@ -27,6 +26,7 @@ export default class ActorSheetFlags extends BaseEntitySheet { getData() { const data = {}; data.actor = this.object; + data.classes = this._getClasses(); data.flags = this._getFlags(); data.bonuses = this._getBonuses(); return data; @@ -34,17 +34,33 @@ export default class ActorSheetFlags extends BaseEntitySheet { /* -------------------------------------------- */ + /** + * Prepare an object of sorted classes. + * @return {object} + * @private + */ + _getClasses() { + const classes = this.object.items.filter(i => i.type === "class"); + return classes.sort((a, b) => a.name.localeCompare(b.name)).reduce((obj, i) => { + obj[i.id] = i.name; + return obj; + }, {}); + } + + /* -------------------------------------------- */ + /** * Prepare an object of flags data which groups flags by section * Add some additional data for rendering * @return {object} + * @private */ _getFlags() { const flags = {}; - const baseData = this.entity._data; + const baseData = this.document.toJSON(); for ( let [k, v] of Object.entries(CONFIG.SW5E.characterFlags) ) { if ( !flags.hasOwnProperty(v.section) ) flags[v.section] = {}; - let flag = duplicate(v); + let flag = foundry.utils.deepClone(v); flag.type = v.type.name; flag.isCheckbox = v.type === Boolean; flag.isSelect = v.hasOwnProperty('choices'); diff --git a/module/apps/actor-type.js b/module/apps/actor-type.js new file mode 100644 index 00000000..ad56e72b --- /dev/null +++ b/module/apps/actor-type.js @@ -0,0 +1,110 @@ +import Actor5e from "../actor/entity.js"; + +/** + * A specialized form used to select from a checklist of attributes, traits, or properties + * @extends {FormApplication} + */ +export default class ActorTypeConfig extends FormApplication { + + /** @inheritdoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sw5e", "actor-type", "trait-selector"], + template: "systems/sw5e/templates/apps/actor-type.html", + title: "SW5E.CreatureTypeTitle", + width: 280, + height: "auto", + choices: {}, + allowCustom: true, + minimum: 0, + maximum: null + }); + } + + /* -------------------------------------------- */ + + /** @override */ + get id() { + return `actor-type-${this.object.id}`; + } + + /* -------------------------------------------- */ + + /** @override */ + getData(options) { + + // Get current value or new default + let attr = foundry.utils.getProperty(this.object.data.data, 'details.type'); + if ( foundry.utils.getType(attr) !== "Object" ) attr = { + value: (attr in CONFIG.SW5E.creatureTypes) ? attr : "humanoid", + subtype: "", + swarm: "", + custom: "" + }; + + // Populate choices + const types = {}; + for ( let [k, v] of Object.entries(CONFIG.SW5E.creatureTypes) ) { + types[k] = { + label: game.i18n.localize(v), + chosen: attr.value === k + } + } + + // Return data for rendering + return { + types: types, + custom: { + value: attr.custom, + label: game.i18n.localize("SW5E.CreatureTypeSelectorCustom"), + chosen: attr.value === "custom" + }, + subtype: attr.subtype, + swarm: attr.swarm, + sizes: Array.from(Object.entries(CONFIG.SW5E.actorSizes)).reverse().reduce((obj, e) => { + obj[e[0]] = e[1]; + return obj; + }, {}), + preview: Actor5e.formatCreatureType(attr) || "–" + } + } + + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + const typeObject = foundry.utils.expandObject(formData); + return this.object.update({ 'data.details.type': typeObject }); + } + + /* -------------------------------------------- */ + /* Event Listeners and Handlers */ + /* -------------------------------------------- */ + + /** @inheritdoc */ + activateListeners(html) { + super.activateListeners(html); + html.find("input[name='custom']").focusin(this._onCustomFieldFocused.bind(this)); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onChangeInput(event) { + super._onChangeInput(event); + const typeObject = foundry.utils.expandObject(this._getSubmitData()); + this.form["preview"].value = Actor5e.formatCreatureType(typeObject) || "—"; + } + + /* -------------------------------------------- */ + + /** + * Select the custom radio button when the custom text field is focused. + * @param {FocusEvent} event The original focusin event + * @private + */ + _onCustomFieldFocused(event) { + this.form.querySelector("input[name='value'][value='custom']").checked = true; + this._onChangeInput(event); + } +} diff --git a/module/apps/hit-dice-config.js b/module/apps/hit-dice-config.js new file mode 100644 index 00000000..d36d6bc2 --- /dev/null +++ b/module/apps/hit-dice-config.js @@ -0,0 +1,91 @@ +/** + * A simple form to set actor hit dice amounts + * @implements {DocumentSheet} + */ +export default class ActorHitDiceConfig extends DocumentSheet { + + /** @override */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + classes: ["sw5e", "hd-config", "dialog"], + template: "systems/sw5e/templates/apps/hit-dice-config.html", + width: 360, + height: "auto" + }); + } + + /* -------------------------------------------- */ + + /** @override */ + get title() { + return `${game.i18n.localize("SW5E.HitDiceConfig")}: ${this.object.name}`; + } + + /* -------------------------------------------- */ + + /** @override */ + getData(options) { + return { + classes: this.object.items.reduce((classes, item) => { + if (item.data.type === "class") { + // Add the appropriate data only if this item is a "class" + classes.push({ + classItemId: item.data._id, + name: item.data.name, + diceDenom: item.data.data.hitDice, + currentHitDice: item.data.data.levels - item.data.data.hitDiceUsed, + maxHitDice: item.data.data.levels, + canRoll: (item.data.data.levels - item.data.data.hitDiceUsed) > 0 + }); + } + return classes; + }, []).sort((a, b) => parseInt(b.diceDenom.slice(1)) - parseInt(a.diceDenom.slice(1))) + }; + } + + /* -------------------------------------------- */ + + /** @override */ + activateListeners(html) { + super.activateListeners(html); + + // Hook up -/+ buttons to adjust the current value in the form + html.find("button.increment,button.decrement").click(event => { + const button = event.currentTarget; + const current = button.parentElement.querySelector(".current"); + const max = button.parentElement.querySelector(".max"); + const direction = button.classList.contains("increment") ? 1 : -1; + current.value = Math.clamped(parseInt(current.value) + direction, 0, parseInt(max.value)); + }); + + html.find("button.roll-hd").click(this._onRollHitDie.bind(this)); + } + + /* -------------------------------------------- */ + + /** @override */ + async _updateObject(event, formData) { + const actorItems = this.object.items; + const classUpdates = Object.entries(formData).map(([id, hd]) => ({ + _id: id, + "data.hitDiceUsed": actorItems.get(id).data.data.levels - hd, + })); + return this.object.updateEmbeddedDocuments("Item", classUpdates); + } + + /* -------------------------------------------- */ + + /** + * Rolls the hit die corresponding with the class row containing the event's target button. + * @param {MouseEvent} event + * @private + */ + async _onRollHitDie(event) { + event.preventDefault(); + const button = event.currentTarget; + await this.object.rollHitDie(button.dataset.hdDenom); + + // Re-render dialog to reflect changed hit dice quantities + this.render(); + } +} diff --git a/module/apps/long-rest.js b/module/apps/long-rest.js index ce7e90d4..6287e0f8 100644 --- a/module/apps/long-rest.js +++ b/module/apps/long-rest.js @@ -46,11 +46,9 @@ export default class LongRestDialog extends Dialog { icon: '', label: "Rest", callback: html => { - let newDay = false; - if (game.settings.get("sw5e", "restVariant") === "normal") + let newDay = true; + if (game.settings.get("sw5e", "restVariant") !== "gritty") newDay = html.find('input[name="newDay"]')[0].checked; - else if(game.settings.get("sw5e", "restVariant") === "gritty") - newDay = true; resolve(newDay); } }, diff --git a/module/apps/movement-config.js b/module/apps/movement-config.js index a57de39c..c507965d 100644 --- a/module/apps/movement-config.js +++ b/module/apps/movement-config.js @@ -1,12 +1,12 @@ /** * A simple form to set actor movement speeds - * @implements {BaseEntitySheet} + * @extends {DocumentSheet} */ -export default class ActorMovementConfig extends BaseEntitySheet { +export default class ActorMovementConfig extends DocumentSheet { /** @override */ static get defaultOptions() { - return mergeObject(super.defaultOptions, { + return foundry.utils.mergeObject(super.defaultOptions, { classes: ["sw5e"], template: "systems/sw5e/templates/apps/movement-config.html", width: 300, @@ -18,17 +18,18 @@ export default class ActorMovementConfig extends BaseEntitySheet { /** @override */ get title() { - return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.entity.name}`; + return `${game.i18n.localize("SW5E.MovementConfig")}: ${this.document.name}`; } /* -------------------------------------------- */ /** @override */ getData(options) { + const sourceMovement = foundry.utils.getProperty(this.document.data._source, "data.attributes.movement") || {}; const data = { - movement: duplicate(this.entity._data.data.attributes.movement), + movement: foundry.utils.deepClone(sourceMovement), units: CONFIG.SW5E.movementUnits - } + }; for ( let [k, v] of Object.entries(data.movement) ) { if ( ["units", "hover"].includes(k) ) continue; data.movement[k] = Number.isNumeric(v) ? v.toNearest(0.1) : 0; diff --git a/module/apps/select-items-prompt.js b/module/apps/select-items-prompt.js new file mode 100644 index 00000000..0eac6497 --- /dev/null +++ b/module/apps/select-items-prompt.js @@ -0,0 +1,68 @@ +/** + * A Dialog to prompt the user to select from a list of items. + * @type {Dialog} + */ +export default class SelectItemsPrompt extends Dialog { + constructor(items, dialogData={}, options={}) { + super(dialogData, options); + this.options.classes = ["sw5e", "dialog", "select-items-prompt", "sheet"]; + + /** + * Store a reference to the Item entities being used + * @type {Array} + */ + this.items = items; + } + + activateListeners(html) { + super.activateListeners(html); + + // render the item's sheet if its image is clicked + html.on('click', '.item-image', (event) => { + const item = this.items.find((feature) => feature.id === event.currentTarget.dataset?.itemId); + + item?.sheet.render(true); + }) + } + + /** + * A constructor function which displays the AddItemPrompt app for a given Actor and Item set. + * Returns a Promise which resolves to the dialog FormData once the workflow has been completed. + * @param {Array} items + * @param {Object} options + * @param {string} options.hint - Localized hint to display at the top of the prompt + * @return {Promise} - list of item ids which the user has selected + */ + static async create(items, { + hint + }) { + // Render the ability usage template + const html = await renderTemplate("systems/sw5e/templates/apps/select-items-prompt.html", {items, hint}); + + return new Promise((resolve) => { + const dlg = new this(items, { + title: game.i18n.localize('SW5E.SelectItemsPromptTitle'), + content: html, + buttons: { + apply: { + icon: ``, + label: game.i18n.localize('SW5E.Apply'), + callback: html => { + const fd = new FormDataExtended(html[0].querySelector("form")).toObject(); + const selectedIds = Object.keys(fd).filter(itemId => fd[itemId]); + resolve(selectedIds); + } + }, + cancel: { + icon: '', + label: game.i18n.localize('SW5E.Skip'), + callback: () => resolve([]) + } + }, + default: "apply", + close: () => resolve([]) + }); + dlg.render(true); + }); + } +} diff --git a/module/apps/senses-config.js b/module/apps/senses-config.js index 71f99bf1..69309493 100644 --- a/module/apps/senses-config.js +++ b/module/apps/senses-config.js @@ -1,12 +1,12 @@ /** * A simple form to set actor movement speeds - * @implements {BaseEntitySheet} + * @extends {DocumentSheet} */ -export default class ActorSensesConfig extends BaseEntitySheet { +export default class ActorSensesConfig extends DocumentSheet { - /** @override */ + /** @inheritdoc */ static get defaultOptions() { - return mergeObject(super.defaultOptions, { + return foundry.utils.mergeObject(super.defaultOptions, { classes: ["sw5e"], template: "systems/sw5e/templates/apps/senses-config.html", width: 300, @@ -16,16 +16,16 @@ export default class ActorSensesConfig extends BaseEntitySheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ get title() { - return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.entity.name}`; + return `${game.i18n.localize("SW5E.SensesConfig")}: ${this.document.name}`; } /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ getData(options) { - const senses = this.entity._data.data.attributes?.senses ?? {}; + const senses = foundry.utils.getProperty(this.document.data._source, "data.attributes.senses") || {}; const data = { senses: {}, special: senses.special ?? "", diff --git a/module/apps/short-rest.js b/module/apps/short-rest.js index 6791cbac..8a164af7 100644 --- a/module/apps/short-rest.js +++ b/module/apps/short-rest.js @@ -40,7 +40,7 @@ export default class ShortRestDialog extends Dialog { // Determine Hit Dice data.availableHD = this.actor.data.items.reduce((hd, item) => { if ( item.type === "class" ) { - const d = item.data; + const d = item.data.data; const denom = d.hitDice || "d6"; const available = parseInt(d.levels || 1) - parseInt(d.hitDiceUsed || 0); hd[denom] = denom in hd ? hd[denom] + available : available; diff --git a/module/apps/trait-selector.js b/module/apps/trait-selector.js index 5e63d63d..5550aa3e 100644 --- a/module/apps/trait-selector.js +++ b/module/apps/trait-selector.js @@ -1,14 +1,14 @@ /** * A specialized form used to select from a checklist of attributes, traits, or properties - * @implements {FormApplication} + * @extends {DocumentSheet} */ -export default class TraitSelector extends FormApplication { +export default class TraitSelector extends DocumentSheet { - /** @override */ - static get defaultOptions() { - return mergeObject(super.defaultOptions, { - id: "trait-selector", - classes: ["sw5e"], + /** @inheritDoc */ + static get defaultOptions() { + return foundry.utils.mergeObject(super.defaultOptions, { + id: "trait-selector", + classes: ["sw5e", "trait-selector", "subconfig"], title: "Actor Trait Selection", template: "systems/sw5e/templates/apps/trait-selector.html", width: 320, @@ -16,7 +16,9 @@ export default class TraitSelector extends FormApplication { choices: {}, allowCustom: true, minimum: 0, - maximum: null + maximum: null, + valueKey: "value", + customKey: "custom" }); } @@ -24,7 +26,7 @@ export default class TraitSelector extends FormApplication { /** * Return a reference to the target attribute - * @type {String} + * @type {string} */ get attribute() { return this.options.name; @@ -34,52 +36,50 @@ export default class TraitSelector extends FormApplication { /** @override */ getData() { - - // Get current values - let attr = getProperty(this.object._data, this.attribute); - if ( getType(attr) !== "Object" ) attr = {value: [], custom: ""}; + const attr = foundry.utils.getProperty(this.object.data, this.attribute); + const o = this.options; + const value = (o.valueKey) ? attr[o.valueKey] ?? [] : attr; + const custom = (o.customKey) ? attr[o.customKey] ?? "" : ""; // Populate choices - const choices = duplicate(this.options.choices); - for ( let [k, v] of Object.entries(choices) ) { - choices[k] = { - label: v, - chosen: attr ? attr.value.includes(k) : false - } - } + const choices = Object.entries(o.choices).reduce((obj, e) => { + let [k, v] = e; + obj[k] = { label: v, chosen: attr ? value.includes(k) : false }; + return obj; + }, {}) // Return data return { - allowCustom: this.options.allowCustom, - choices: choices, - custom: attr ? attr.custom : "" + allowCustom: o.allowCustom, + choices: choices, + custom: custom } } /* -------------------------------------------- */ /** @override */ - _updateObject(event, formData) { - const updateData = {}; + async _updateObject(event, formData) { + const o = this.options; // Obtain choices const chosen = []; for ( let [k, v] of Object.entries(formData) ) { if ( (k !== "custom") && v ) chosen.push(k); } - updateData[`${this.attribute}.value`] = chosen; + + // Object including custom data + const updateData = {}; + if ( o.valueKey ) updateData[`${this.attribute}.${o.valueKey}`] = chosen; + else updateData[this.attribute] = chosen; + if ( o.allowCustom ) updateData[`${this.attribute}.${o.customKey}`] = formData.custom; // Validate the number chosen - if ( this.options.minimum && (chosen.length < this.options.minimum) ) { - return ui.notifications.error(`You must choose at least ${this.options.minimum} options`); + if ( o.minimum && (chosen.length < o.minimum) ) { + return ui.notifications.error(`You must choose at least ${o.minimum} options`); } - if ( this.options.maximum && (chosen.length > this.options.maximum) ) { - return ui.notifications.error(`You may choose no more than ${this.options.maximum} options`); - } - - // Include custom - if ( this.options.allowCustom ) { - updateData[`${this.attribute}.custom`] = formData.custom; + if ( o.maximum && (chosen.length > o.maximum) ) { + return ui.notifications.error(`You may choose no more than ${o.maximum} options`); } // Update the object diff --git a/module/canvas.js b/module/canvas.js index 1767d4d8..622e2a30 100644 --- a/module/canvas.js +++ b/module/canvas.js @@ -35,20 +35,4 @@ export const measureDistances = function(segments, options={}) { // Standard PHB Movement else return (ns + nd) * canvas.scene.data.gridDistance; }); -}; - -/* -------------------------------------------- */ - -/** - * Hijack Token health bar rendering to include temporary and temp-max health in the bar display - * TODO: This should probably be replaced with a formal Token class extension - */ -const _TokenGetBarAttribute = Token.prototype.getBarAttribute; -export const getBarAttribute = function(...args) { - const data = _TokenGetBarAttribute.bind(this)(...args); - if ( data && (data.attribute === "attributes.hp") ) { - data.value += parseInt(getProperty(this.actor.data, "data.attributes.hp.temp") || 0); - data.max += parseInt(getProperty(this.actor.data, "data.attributes.hp.tempmax") || 0); - } - return data; -}; +}; \ No newline at end of file diff --git a/module/chat.js b/module/chat.js index f8ab035f..d024d8aa 100644 --- a/module/chat.js +++ b/module/chat.js @@ -40,7 +40,7 @@ export const displayChatActionButtons = function(message, html, data) { // If the user is the message author or the actor owner, proceed let actor = game.actors.get(data.message.speaker.actor); - if ( actor && actor.owner ) return; + if ( actor && actor.isOwner ) return; else if ( game.user.isGM || (data.author.id === game.user.id)) return; // Otherwise conceal action buttons except for saving throw @@ -66,7 +66,7 @@ export const displayChatActionButtons = function(message, html, data) { export const addChatMessageContextOptions = function(html, options) { let canApply = li => { const message = game.messages.get(li.data("messageId")); - return message?.isRoll && message?.isContentVisible && canvas?.tokens.controlled.length; + return message?.isRoll && message?.isContentVisible && canvas.tokens?.controlled.length; }; options.push( { diff --git a/module/combat.js b/module/combat.js index 8132a5ac..58535411 100644 --- a/module/combat.js +++ b/module/combat.js @@ -5,20 +5,19 @@ * Apply the dexterity score as a decimal tiebreaker if requested * See Combat._getInitiativeFormula for more detail. */ -export const _getInitiativeFormula = function(combatant) { - const actor = combatant.actor; +export const _getInitiativeFormula = function() { + const actor = this.actor; if ( !actor ) return "1d20"; const init = actor.data.data.attributes.init; + // Construct initiative formula parts let nd = 1; let mods = ""; - if (actor.getFlag("sw5e", "halflingLucky")) mods += "r1=1"; if (actor.getFlag("sw5e", "initiativeAdv")) { nd = 2; mods += "kh"; } - const parts = [`${nd}d20${mods}`, init.mod, (init.prof !== 0) ? init.prof : null, (init.bonus !== 0) ? init.bonus : null]; // Optionally apply Dexterity tiebreaker diff --git a/module/config.js b/module/config.js index 71c8a593..e4137d53 100644 --- a/module/config.js +++ b/module/config.js @@ -63,7 +63,7 @@ SW5E.attunementTypes = { NONE: 0, REQUIRED: 1, ATTUNED: 2, -} +}; /** * An enumeration of item attunement states @@ -77,7 +77,6 @@ SW5E.attunements = { /* -------------------------------------------- */ - SW5E.weaponProficiencies = { "blp": "SW5E.WeaponBlasterPistolProficiency", "chk": "SW5E.WeaponChakramProficiency", @@ -89,13 +88,13 @@ SW5E.weaponProficiencies = { "imp": "SW5E.WeaponImprovisedProficiency", "lfl": "SW5E.WeaponLightFoilProficiency", "lrg": "SW5E.WeaponLightRingProficiency", - "mar": "SW5E.WeaponMartialProficiency", + "mar": "SW5E.WeaponMartialProficiency", "mrb": "SW5E.WeaponMartialBlasterProficiency", "mlw": "SW5E.WeaponMartialLightweaponProficiency", "mvb": "SW5E.WeaponMartialVibroweaponProficiency", "ntl": "SW5E.WeaponNaturalProficiency", "swh": "SW5E.WeaponSaberWhipProficiency", - "sim": "SW5E.WeaponSimpleProficiency", + "sim": "SW5E.WeaponSimpleProficiency", "smb": "SW5E.WeaponSimpleBlasterProficiency", "slw": "SW5E.WeaponSimpleLightweaponProficiency", "svb": "SW5E.WeaponSimpleVibroweaponProficiency", @@ -104,6 +103,59 @@ SW5E.weaponProficiencies = { "vbw": "SW5E.WeaponVibrowhipProficiency" }; +// TODO: Check to see if this can be used +// It's not actually been used anywhere in DND5e 1.3.2 +// Note name mapped to ID in compendium +/** + * The basic weapon types in 5e. This enables specific weapon proficiencies or + * starting equipment provided by classes and backgrounds. + * + * @enum {string} + +SW5E.weaponIds = { + "battleaxe": "I0WocDSuNpGJayPb", + "blowgun": "wNWK6yJMHG9ANqQV", + "club": "nfIRTECQIG81CvM4", + "dagger": "0E565kQUBmndJ1a2", + "dart": "3rCO8MTIdPGSW6IJ", + "flail": "UrH3sMdnUDckIHJ6", + "glaive": "rOG1OM2ihgPjOvFW", + "greataxe": "1Lxk6kmoRhG8qQ0u", + "greatclub": "QRCsxkCwWNwswL9o", + "greatsword": "xMkP8BmFzElcsMaR", + "halberd": "DMejWAc8r8YvDPP1", + "handaxe": "eO7Fbv5WBk5zvGOc", + "handcrossbow": "qaSro7kFhxD6INbZ", + "heavycrossbow": "RmP0mYRn2J7K26rX", + "javelin": "DWLMnODrnHn8IbAG", + "lance": "RnuxdHUAIgxccVwj", + "lightcrossbow": "ddWvQRLmnnIS0eLF", + "lighthammer": "XVK6TOL4sGItssAE", + "longbow": "3cymOVja8jXbzrdT", + "longsword": "10ZP2Bu3vnCuYMIB", + "mace": "Ajyq6nGwF7FtLhDQ", + "maul": "DizirD7eqjh8n95A", + "morningstar": "dX8AxCh9o0A9CkT3", + "net": "aEiM49V8vWpWw7rU", + "pike": "tC0kcqZT9HHAO0PD", + "quarterstaff": "g2dWN7PQiMRYWzyk", + "rapier": "Tobce1hexTnDk4sV", + "scimitar": "fbC0Mg1a73wdFbqO", + "shortsword": "osLzOwQdPtrK3rQH", + "sickle": "i4NeNZ30ycwPDHMx", + "spear": "OG4nBBydvmfWYXIk", + "shortbow": "GJv6WkD7D2J6rP6M", + "sling": "3gynWO9sN4OLGMWD", + "trident": "F65ANO66ckP8FDMa", + "warpick": "2YdfjN1PIIrSHZii", + "warhammer": "F0Df164Xv1gWcYt0", + "whip": "QKTyxoO0YDnAsbYe" +}; + + */ + +/* -------------------------------------------- */ + SW5E.toolProficiencies = { "armor": "SW5E.ToolArmormech", "arms": "SW5E.ToolArmstech", @@ -137,6 +189,51 @@ SW5E.toolProficiencies = { "vehicle": "SW5E.ToolVehicle" }; +// TODO: Same as weapon IDs +// Also unused, and SW5E.toolProficiencies is already pretty verbose anyway +/** + * The basic tool types in 5e. This enables specific tool proficiencies or + * starting equipment provided by classes and backgrounds. + * + * @enum {string} +SW5E.toolIds = { + "alchemist": "SztwZhbhZeCqyAes", + "bagpipes": "yxHi57T5mmVt0oDr", + "brewer": "Y9S75go1hLMXUD48", + "calligrapher": "jhjo20QoiD5exf09", + "card": "YwlHI3BVJapz4a3E", + "carpenter": "8NS6MSOdXtUqD7Ib", + "cartographer": "fC0lFK8P4RuhpfaU", + "cobbler": "hM84pZnpCqKfi8XH", + "cook": "Gflnp29aEv5Lc1ZM", + "dice": "iBuTM09KD9IoM5L8", + "disg": "IBhDAr7WkhWPYLVn", + "drum": "69Dpr25pf4BjkHKb", + "dulcimer": "NtdDkjmpdIMiX7I2", + "flute": "eJOrPcAz9EcquyRQ", + "forg": "cG3m4YlHfbQlLEOx", + "glassblower": "rTbVrNcwApnuTz5E", + "herb": "i89okN7GFTWHsvPy", + "horn": "aa9KuBy4dst7WIW9", + "jeweler": "YfBwELTgPFHmQdHh", + "leatherworker": "PUMfwyVUbtyxgYbD", + "lute": "qBydtUUIkv520DT7", + "lyre": "EwG1EtmbgR3bM68U", + "mason": "skUih6tBvcBbORzA", + "navg": "YHCmjsiXxZ9UdUhU", + "painter": "ccm5xlWhx74d6lsK", + "panflute": "G5m5gYIx9VAUWC3J", + "pois": "il2GNi8C0DvGLL9P", + "potter": "hJS8yEVkqgJjwfWa", + "shawm": "G3cqbejJpfB91VhP", + "smith": "KndVe2insuctjIaj", + "thief": "woWZ1sO5IUVGzo58", + "tinker": "0d08g1i5WXnNrCNA", + "viol": "baoe3U5BfMMMxhCU", + "weaver": "ap9prThUB2y9lDyj", + "woodcarver": "xKErqkLo4ASYr5EP", +}; +*/ /* -------------------------------------------- */ @@ -210,6 +307,35 @@ SW5E.tokenSizes = { "grg": 4 }; +/** + * Colors used to visualize temporary and temporary maximum HP in token health bars + * @enum {number} + */ +SW5E.tokenHPColors = { + temp: 0x66CCFF, + tempmax: 0x440066, + negmax: 0x550000 +} + +/* -------------------------------------------- */ + +/** + * Creature types + * @type {Object} + */ +SW5E.creatureTypes = { + "Aberration": "SW5E.CreatureAberration", + "Beast": "SW5E.CreatureBeast", + "Construct": "SW5E.CreatureConstruct", + "Droid": "SW5E.CreatureDroid", + "Elemental": "SW5E.CreatureElemental", + "Human": "SW5E.CreatureHuman", + "Humanoid": "SW5E.CreatureHumanoid", + "Plant": "SW5E.CreaturePlant", + "Undead": "SW5E.CreatureUndead" +}; + + /* -------------------------------------------- */ /** @@ -254,7 +380,7 @@ SW5E.limitedUsePeriods = { /* -------------------------------------------- */ /** - * The set of equipment types for armor, clothing, and other objects which can ber worn by the character + * The set of equipment types for armor, clothing, and other objects which can be worn by the character * @type {Object} */ SW5E.equipmentTypes = { @@ -338,7 +464,7 @@ SW5E.damageTypes = { }; // Damage Resistance Types -SW5E.damageResistanceTypes = duplicate(SW5E.damageTypes); +SW5E.damageResistanceTypes = foundry.utils.deepClone(SW5E.damageTypes); /* -------------------------------------------- */ @@ -392,7 +518,7 @@ SW5E.movementTypes = { "swim": "SW5E.MovementSwim", "turn": "SW5E.MovementTurn", "walk": "SW5E.MovementWalk", -} +}; /** * The valid units of measure for movement distances in the game system. @@ -402,7 +528,7 @@ SW5E.movementTypes = { SW5E.movementUnits = { "ft": "SW5E.DistFt", "mi": "SW5E.DistMi" -} +}; /** * The valid units of measure for the range of an action or effect. @@ -495,7 +621,7 @@ SW5E.healingTypes = { /** * Enumerate the denominations of hit dice which can apply to classes in the SW5E system - * @type {Array.} + * @type {string[]} */ SW5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12", "d20"]; @@ -504,7 +630,7 @@ SW5E.hitDieTypes = ["d4", "d6", "d8", "d10", "d12", "d20"]; /** * Enumerate the denominations of power dice which can apply to starships in the SW5E system - * @type {Array.} + * @enum {string} */ SW5E.powerDieTypes = [1, "d4", "d6", "d8", "d10", "d12"]; @@ -896,6 +1022,32 @@ SW5E.powerLevels = { 9: "SW5E.PowerLevel9" }; +// TODO: This is used for spell scrolls, it maps the level to the compendium ID of the item the spell would be bound to +// We could use this wit, say, holocrons to produce scrolls +/* +// Power Scroll Compendium UUIDs +SW5E.powerScrollIds = { + 0: "rQ6sO7HDWzqMhSI3", + 1: "9GSfMg0VOA2b4uFN", + 2: "XdDp6CKh9qEvPTuS", + 3: "hqVKZie7x9w3Kqds", + 4: "DM7hzgL836ZyUFB1", + 5: "wa1VF8TXHmkrrR35", + 6: "tI3rWx4bxefNCexS", + 7: "mtyw4NS1s7j2EJaD", + 8: "aOrinPg7yuDZEuWr", + 9: "O4YbkJkLlnsgUszZ" +}; + */ + +/** + * Compendium packs used for localized items. + * @enum {string} + */ +SW5E.sourcePacks = { + ITEMS: "sw5e.items" +} + // Polymorph options. SW5E.polymorphSettings = { keepPhysical: 'SW5E.PolymorphKeepPhysical', diff --git a/module/dice.js b/module/dice.js index cea097fd..7d3c2489 100644 --- a/module/dice.js +++ b/module/dice.js @@ -1,3 +1,6 @@ +export {default as D20Roll} from "./dice/d20-roll.js"; +export {default as DamageRoll} from "./dice/damage-roll.js"; + /** * A standardized helper function for simplifying the constant parts of a multipart roll formula * @@ -20,28 +23,29 @@ export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) const constantTerms = []; // Terms that are constant, and their associated operators let operators = []; // Temporary storage for operators before they are moved to one of the above - for (let term of terms) { // For each term - if (["+", "-"].includes(term)) operators.push(term); // If the term is an addition/subtraction operator, push the term into the operators array - else { // Otherwise the term is not an operator - if (term instanceof DiceTerm) { // If the term is something rollable - rollableTerms.push(...operators); // Place all the operators into the rollableTerms array - rollableTerms.push(term); // Then place this rollable term into it as well - } // - else { // Otherwise, this must be a constant - constantTerms.push(...operators); // Place the operators into the constantTerms array - constantTerms.push(term); // Then also add this constant term to that array. - } // - operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration. + for (let term of terms) { // For each term + if (term instanceof OperatorTerm) operators.push(term); // If the term is an addition/subtraction operator, push the term into the operators array + else { // Otherwise the term is not an operator + if (term instanceof DiceTerm) { // If the term is something rollable + rollableTerms.push(...operators); // Place all the operators into the rollableTerms array + rollableTerms.push(term); // Then place this rollable term into it as well + } // + else { // Otherwise, this must be a constant + constantTerms.push(...operators); // Place the operators into the constantTerms array + constantTerms.push(term); // Then also add this constant term to that array. + } // + operators = []; // Finally, the operators have now all been assigend to one of the arrays, so empty this before the next iteration. } } - const constantFormula = Roll.cleanFormula(constantTerms); // Cleans up the constant terms and produces a new formula string - const rollableFormula = Roll.cleanFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string + const constantFormula = Roll.getFormula(constantTerms); // Cleans up the constant terms and produces a new formula string + const rollableFormula = Roll.getFormula(rollableTerms); // Cleans up the non-constant terms and produces a new formula string - const constantPart = roll._safeEval(constantFormula); // Mathematically evaluate the constant formula to produce a single constant term + // Mathematically evaluate the constant formula to produce a single constant term + const constantPart = constantFormula ? Roll.safeEval(constantFormula) : undefined; - const parts = constantFirst ? // Order the rollable and constant terms, either constant first or second depending on the optional argumen - [constantPart, rollableFormula] : [rollableFormula, constantPart]; + // Order the rollable and constant terms, either constant first or second depending on the optional argument + const parts = constantFirst ? [constantPart, rollableFormula] : [rollableFormula, constantPart]; // Join the parts with a + sign, pass them to `Roll` once again to clean up the formula return new Roll(parts.filterJoin(" + ")).formula; @@ -55,316 +59,203 @@ export function simplifyRollFormula(formula, data, {constantFirst = false} = {}) * @return {Boolean} True when unsupported, false if supported */ function _isUnsupportedTerm(term) { - const diceTerm = term instanceof DiceTerm; - const operator = ["+", "-"].includes(term); - const number = !isNaN(Number(term)); + const diceTerm = term instanceof DiceTerm; + const operator = term instanceof OperatorTerm && ["+", "-"].includes(term.operator); + const number = term instanceof NumericTerm; - return !(diceTerm || operator || number); + return !(diceTerm || operator || number); } +/* -------------------------------------------- */ +/* D20 Roll */ /* -------------------------------------------- */ /** - * A standardized helper function for managing core 5e "d20 rolls" - * + * A standardized helper function for managing core 5e d20 rolls. * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward". * This chooses the default options of a normal attack with no bonus, Advantage, or Disadvantage respectively * - * @param {Array} parts The dice roll component parts, excluding the initial d20 - * @param {Object} data Actor or item data against which to parse the roll - * @param {Event|object} event The triggering event which initiated the roll - * @param {string} rollMode A specific roll mode to apply as the default for the resulting roll - * @param {string|null} template The HTML template used to render the roll dialog - * @param {string|null} title The dice roll UI window title - * @param {Object} speaker The ChatMessage speaker to pass when creating the chat - * @param {string|null} flavor Flavor text to use in the posted chat message - * @param {Boolean} fastForward Allow fast-forward advantage selection - * @param {Function} onClose Callback for actions to take when the dialog form is closed - * @param {Object} dialogOptions Modal dialog options - * @param {boolean} advantage Apply advantage to the roll (unless otherwise specified) - * @param {boolean} disadvantage Apply disadvantage to the roll (unless otherwise specified) - * @param {number} critical The value of d20 result which represents a critical success - * @param {number} fumble The value of d20 result which represents a critical failure - * @param {number} targetValue Assign a target value against which the result of this roll should be compared - * @param {boolean} elvenAccuracy Allow Elven Accuracy to modify this roll? - * @param {boolean} halflingLucky Allow Halfling Luck to modify this roll? - * @param {boolean} reliableTalent Allow Reliable Talent to modify this roll? - * @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll - * @param {object} messageData Additional data which is applied to the created Chat Message, if any + * @param {string[]} parts The dice roll component parts, excluding the initial d20 + * @param {object} data Actor or item data against which to parse the roll * - * @return {Promise} A Promise which resolves once the roll workflow has completed + * @param {boolean} [advantage] Apply advantage to the roll (unless otherwise specified) + * @param {boolean} [disadvantage] Apply disadvantage to the roll (unless otherwise specified) + * @param {number} [critical] The value of d20 result which represents a critical success + * @param {number} [fumble] The value of d20 result which represents a critical failure + * @param {number} [targetValue] Assign a target value against which the result of this roll should be compared + * @param {boolean} [elvenAccuracy] Allow Elven Accuracy to modify this roll? + * @param {boolean} [halflingLucky] Allow Halfling Luck to modify this roll? + * @param {boolean} [reliableTalent] Allow Reliable Talent to modify this roll? + + * @param {boolean} [chooseModifier=false] Choose the ability modifier that should be used when the roll is made + * @param {boolean} [fastForward=false] Allow fast-forward advantage selection + * @param {Event} [event] The triggering event which initiated the roll + * @param {string} [rollMode] A specific roll mode to apply as the default for the resulting roll + * @param {string} [template] The HTML template used to render the roll dialog + * @param {string} [title] The dialog window title + * @param {Object} [dialogOptions] Modal dialog options + * + * @param {boolean} [chatMessage=true] Automatically create a Chat Message for the result of this roll + * @param {object} [messageData={}] Additional data which is applied to the created Chat Message, if any + * @param {string} [rollMode] A specific roll mode to apply as the default for the resulting roll + * @param {object} [speaker] The ChatMessage speaker to pass when creating the chat + * @param {string} [flavor] Flavor text to use in the posted chat message + * + * @return {Promise} The evaluated D20Roll, or null if the workflow was cancelled */ -export async function d20Roll({parts=[], data={}, event={}, rollMode=null, template=null, title=null, speaker=null, - flavor=null, fastForward=null, dialogOptions, - advantage=null, disadvantage=null, critical=20, fumble=1, targetValue=null, - elvenAccuracy=false, halflingLucky=false, reliableTalent=false, - chatMessage=true, messageData={}}={}) { +export async function d20Roll({ + parts=[], data={}, // Roll creation + advantage, disadvantage, fumble=1, critical=20, targetValue, elvenAccuracy, halflingLucky, reliableTalent, // Roll customization + chooseModifier=false, fastForward=false, event, template, title, dialogOptions, // Dialog configuration + chatMessage=true, messageData={}, rollMode, speaker, flavor // Chat Message customization + }={}) { - // Prepare Message Data - messageData.flavor = flavor || title; - messageData.speaker = speaker || ChatMessage.getSpeaker(); - const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")}; - parts = parts.concat(["@bonus"]); + // Handle input arguments + const formula = ["1d20"].concat(parts).join(" + "); + const {advantageMode, isFF} = _determineAdvantageMode({advantage, disadvantage, fastForward, event}); + const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); + if ( chooseModifier && !isFF ) data["mod"] = "@mod"; - // Handle fast-forward events - let adv = 0; - fastForward = fastForward ?? (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); - if (fastForward) { - if ( advantage ?? event.altKey ) adv = 1; - else if ( disadvantage ?? (event.ctrlKey || event.metaKey) ) adv = -1; + // Construct the D20Roll instance + const roll = new CONFIG.Dice.D20Roll(formula, data, { + flavor: flavor || title, + advantageMode, + defaultRollMode, + critical, + fumble, + targetValue, + elvenAccuracy, + halflingLucky, + reliableTalent + }); + + // Prompt a Dialog to further configure the D20Roll + if ( !isFF ) { + const configured = await roll.configureDialog({ + title, + chooseModifier, + defaultRollMode: defaultRollMode, + defaultAction: advantageMode, + defaultAbility: data?.item?.ability, + template + }, dialogOptions); + if ( configured === null ) return null; } - // Define the inner roll function - const _roll = (parts, adv, form) => { - - // Determine the d20 roll and modifiers - let nd = 1; - let mods = halflingLucky ? "r1=1" : ""; - - // Handle advantage - if (adv === 1) { - nd = elvenAccuracy ? 3 : 2; - messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`; - if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].advantage = true; - mods += "kh"; - } - - // Handle disadvantage - else if (adv === -1) { - nd = 2; - messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`; - if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].disadvantage = true; - mods += "kl"; - } - - // Prepend the d20 roll - let formula = `${nd}d20${mods}`; - if (reliableTalent) formula = `{${nd}d20${mods},10}kh`; - parts.unshift(formula); - - // Optionally include a situational bonus - if ( form ) { - data['bonus'] = form.bonus.value; - messageOptions.rollMode = form.rollMode.value; - } - if (!data["bonus"]) parts.pop(); - - // Optionally include an ability score selection (used for tool checks) - const ability = form ? form.ability : null; - if (ability && ability.value) { - data.ability = ability.value; - const abl = data.abilities[data.ability]; - if (abl) { - data.mod = abl.mod; - messageData.flavor += ` (${CONFIG.SW5E.abilities[data.ability]})`; - } - } - - // Execute the roll - let roll = new Roll(parts.join(" + "), data); - try { - roll.roll(); - } catch (err) { - console.error(err); - ui.notifications.error(`Dice roll evaluation failed: ${err.message}`); - return null; - } - - // Flag d20 options for any 20-sided dice in the roll - for (let d of roll.dice) { - if (d.faces === 20) { - d.options.critical = critical; - d.options.fumble = fumble; - if ( adv === 1 ) d.options.advantage = true; - else if ( adv === -1 ) d.options.disadvantage = true; - if (targetValue) d.options.target = targetValue; - } - } - - // If reliable talent was applied, add it to the flavor text - if (reliableTalent && roll.dice[0].total < 10) { - messageData.flavor += ` (${game.i18n.localize("SW5E.FlagsReliableTalent")})`; - } - return roll; - }; - - // Create the Roll instance - const roll = fastForward ? _roll(parts, adv) : - await _d20RollDialog({template, title, parts, data, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll}); + // Evaluate the configured roll + await roll.evaluate({async: true}); // Create a Chat Message - if ( roll && chatMessage ) roll.toMessage(messageData, messageOptions); + if ( speaker ) { + console.warn(`You are passing the speaker argument to the d20Roll function directly which should instead be passed as an internal key of messageData`); + messageData.speaker = speaker; + } + if ( roll && chatMessage ) await roll.toMessage(messageData); return roll; } /* -------------------------------------------- */ /** - * Present a Dialog form which creates a d20 roll once submitted - * @return {Promise} - * @private + * Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied + * @returns {{isFF: boolean, advantageMode: number}} Whether the roll is fast-forward, and its advantage mode */ -async function _d20RollDialog({template, title, parts, data, rollMode, dialogOptions, roll}={}) { - - // Render modal dialog - template = template || "systems/sw5e/templates/chat/roll-dialog.html"; - let dialogData = { - formula: parts.join(" + "), - data: data, - rollMode: rollMode, - rollModes: CONFIG.Dice.rollModes, - config: CONFIG.SW5E - }; - const html = await renderTemplate(template, dialogData); - - // Create the Dialog window - return new Promise(resolve => { - new Dialog({ - title: title, - content: html, - buttons: { - advantage: { - label: game.i18n.localize("SW5E.Advantage"), - callback: html => resolve(roll(parts, 1, html[0].querySelector("form"))) - }, - normal: { - label: game.i18n.localize("SW5E.Normal"), - callback: html => resolve(roll(parts, 0, html[0].querySelector("form"))) - }, - disadvantage: { - label: game.i18n.localize("SW5E.Disadvantage"), - callback: html => resolve(roll(parts, -1, html[0].querySelector("form"))) - } - }, - default: "normal", - close: () => resolve(null) - }, dialogOptions).render(true); - }); +function _determineAdvantageMode({event, advantage=false, disadvantage=false, fastForward=false}={}) { + const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); + let advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.NORMAL; + if ( advantage || event?.altKey ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.ADVANTAGE; + else if ( disadvantage || event?.ctrlKey || event?.metaKey ) advantageMode = CONFIG.Dice.D20Roll.ADV_MODE.DISADVANTAGE; + return {isFF, advantageMode}; } +/* -------------------------------------------- */ +/* Damage Roll */ /* -------------------------------------------- */ /** - * A standardized helper function for managing core 5e "d20 rolls" + * A standardized helper function for managing core 5e damage rolls. * * Holding SHIFT, ALT, or CTRL when the attack is rolled will "fast-forward". * This chooses the default options of a normal attack with no bonus, Critical, or no bonus respectively * - * @param {Array} parts The dice roll component parts, excluding the initial d20 - * @param {Actor} actor The Actor making the damage roll - * @param {Object} data Actor or item data against which to parse the roll - * @param {Event|object}[event The triggering event which initiated the roll - * @param {string} rollMode A specific roll mode to apply as the default for the resulting roll - * @param {String} template The HTML template used to render the roll dialog - * @param {String} title The dice roll UI window title - * @param {Object} speaker The ChatMessage speaker to pass when creating the chat - * @param {string} flavor Flavor text to use in the posted chat message - * @param {boolean} allowCritical Allow the opportunity for a critical hit to be rolled - * @param {Boolean} critical Flag this roll as a critical hit for the purposes of fast-forward rolls - * @param {number} criticalBonusDice A number of bonus damage dice that are added for critical hits - * @param {number} criticalMultiplier A critical hit multiplier which is applied to critical hits - * @param {Boolean} fastForward Allow fast-forward advantage selection - * @param {Function} onClose Callback for actions to take when the dialog form is closed - * @param {Object} dialogOptions Modal dialog options - * @param {boolean} chatMessage Automatically create a Chat Message for the result of this roll - * @param {object} messageData Additional data which is applied to the created Chat Message, if any + * @param {string[]} parts The dice roll component parts, excluding the initial d20 + * @param {object} [data] Actor or item data against which to parse the roll * - * @return {Promise} A Promise which resolves once the roll workflow has completed + * @param {boolean} [critical=false] Flag this roll as a critical hit for the purposes of fast-forward or default dialog action + * @param {number} [criticalBonusDice=0] A number of bonus damage dice that are added for critical hits + * @param {number} [criticalMultiplier=2] A critical hit multiplier which is applied to critical hits + * @param {boolean} [multiplyNumeric=false] Multiply numeric terms by the critical multiplier + * @param {boolean} [powerfulCritical=false] Apply the "powerful criticals" house rule to critical hits + + * @param {boolean} [fastForward=false] Allow fast-forward advantage selection + * @param {Event}[event] The triggering event which initiated the roll + * @param {boolean} [allowCritical=true] Allow the opportunity for a critical hit to be rolled + * @param {string} [template] The HTML template used to render the roll dialog + * @param {string} [title] The dice roll UI window title + * @param {object} [dialogOptions] Configuration dialog options + * + * @param {boolean} [chatMessage=true] Automatically create a Chat Message for the result of this roll + * @param {object} [messageData={}] Additional data which is applied to the created Chat Message, if any + * @param {string} [rollMode] A specific roll mode to apply as the default for the resulting roll + * @param {object} [speaker] The ChatMessage speaker to pass when creating the chat + * @param {string} [flavor] Flavor text to use in the posted chat message + * + * @return {Promise} The evaluated DamageRoll, or null if the workflow was canceled */ -export async function damageRoll({parts, actor, data, event={}, rollMode=null, template, title, speaker, flavor, - allowCritical=true, critical=false, criticalBonusDice=0, criticalMultiplier=2, fastForward=null, - dialogOptions={}, chatMessage=true, messageData={}}={}) { +export async function damageRoll({ + parts=[], data, // Roll creation + critical=false, criticalBonusDice, criticalMultiplier, multiplyNumeric, powerfulCritical, // Damage customization + fastForward=false, event, allowCritical=true, template, title, dialogOptions, // Dialog configuration + chatMessage=true, messageData={}, rollMode, speaker, flavor, // Chat Message customization + }={}) { - // Prepare Message Data - messageData.flavor = flavor || title; - messageData.speaker = speaker || ChatMessage.getSpeaker(); - const messageOptions = {rollMode: rollMode || game.settings.get("core", "rollMode")}; - parts = parts.concat(["@bonus"]); + // Handle input arguments + const defaultRollMode = rollMode || game.settings.get("core", "rollMode"); - // Define inner roll function - const _roll = function(parts, crit, form) { - - // Optionally include a situational bonus - if ( form ) { - data['bonus'] = form.bonus.value; - messageOptions.rollMode = form.rollMode.value; - } - if (!data["bonus"]) parts.pop(); - - // Create the damage roll - let roll = new Roll(parts.join("+"), data); - - // Modify the damage formula for critical hits - if ( crit === true ) { - roll.alter(criticalMultiplier, 0); // Multiply all dice - if ( roll.terms[0] instanceof Die ) { // Add bonus dice for only the main dice term - roll.terms[0].alter(1, criticalBonusDice); - roll._formula = roll.formula; - } - messageData.flavor += ` (${game.i18n.localize("SW5E.Critical")})`; - if ( "flags.sw5e.roll" in messageData ) messageData["flags.sw5e.roll"].critical = true; - } - - // Execute the roll - try { - roll.evaluate() - if ( crit ) roll.dice.forEach(d => d.options.critical = true); // TODO workaround core bug which wipes Roll#options on roll - return roll; - } catch(err) { - console.error(err); - ui.notifications.error(`Dice roll evaluation failed: ${err.message}`); - return null; - } - }; - - // Create the Roll instance - const roll = fastForward ? _roll(parts, critical) : await _damageRollDialog({ - template, title, parts, data, allowCritical, rollMode: messageOptions.rollMode, dialogOptions, roll: _roll + // Construct the DamageRoll instance + const formula = parts.join(" + "); + const {isCritical, isFF} = _determineCriticalMode({critical, fastForward, event}); + const roll = new CONFIG.Dice.DamageRoll(formula, data, { + flavor: flavor || title, + critical: isCritical, + criticalBonusDice, + criticalMultiplier, + multiplyNumeric, + powerfulCritical }); - // Create a Chat Message - if ( roll && chatMessage ) roll.toMessage(messageData, messageOptions); - return roll; + // Prompt a Dialog to further configure the DamageRoll + if ( !isFF ) { + const configured = await roll.configureDialog({ + title, + defaultRollMode: defaultRollMode, + defaultCritical: isCritical, + template, + allowCritical + }, dialogOptions); + if ( configured === null ) return null; + } + // Evaluate the configured roll + await roll.evaluate({async: true}); + + // Create a Chat Message + if ( speaker ) { + console.warn(`You are passing the speaker argument to the damageRoll function directly which should instead be passed as an internal key of messageData`); + messageData.speaker = speaker; + } + if ( roll && chatMessage ) await roll.toMessage(messageData); + return roll; } /* -------------------------------------------- */ /** - * Present a Dialog form which creates a damage roll once submitted - * @return {Promise} - * @private + * Determines whether this d20 roll should be fast-forwarded, and whether advantage or disadvantage should be applied + * @returns {{isFF: boolean, isCritical: boolean}} Whether the roll is fast-forward, and whether it is a critical hit */ -async function _damageRollDialog({template, title, parts, data, allowCritical, rollMode, dialogOptions, roll}={}) { - - // Render modal dialog - template = template || "systems/sw5e/templates/chat/roll-dialog.html"; - let dialogData = { - formula: parts.join(" + "), - data: data, - rollMode: rollMode, - rollModes: CONFIG.Dice.rollModes - }; - const html = await renderTemplate(template, dialogData); - - // Create the Dialog window - return new Promise(resolve => { - new Dialog({ - title: title, - content: html, - buttons: { - critical: { - condition: allowCritical, - label: game.i18n.localize("SW5E.CriticalHit"), - callback: html => resolve(roll(parts, true, html[0].querySelector("form"))) - }, - normal: { - label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), - callback: html => resolve(roll(parts, false, html[0].querySelector("form"))) - }, - }, - default: "normal", - close: () => resolve(null) - }, dialogOptions).render(true); - }); +function _determineCriticalMode({event, critical=false, fastForward=false}={}) { + const isFF = fastForward || (event && (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)); + if ( event?.altKey ) critical = true; + return {isFF, isCritical: critical}; } diff --git a/module/dice/d20-roll.js b/module/dice/d20-roll.js new file mode 100644 index 00000000..c4b40824 --- /dev/null +++ b/module/dice/d20-roll.js @@ -0,0 +1,217 @@ +/** + * A type of Roll specific to a d20-based check, save, or attack roll in the 5e system. + * @param {string} formula The string formula to parse + * @param {object} data The data object against which to parse attributes within the formula + * @param {object} [options={}] Extra optional arguments which describe or modify the D20Roll + * @param {number} [options.advantageMode] What advantage modifier to apply to the roll (none, advantage, disadvantage) + * @param {number} [options.critical] The value of d20 result which represents a critical success + * @param {number} [options.fumble] The value of d20 result which represents a critical failure + * @param {(number)} [options.targetValue] Assign a target value against which the result of this roll should be compared + * @param {boolean} [options.elvenAccuracy=false] Allow Elven Accuracy to modify this roll? + * @param {boolean} [options.halflingLucky=false] Allow Halfling Luck to modify this roll? + * @param {boolean} [options.reliableTalent=false] Allow Reliable Talent to modify this roll? + */ +// TODO: Check elven accuracy, halfling lucky, and reliable talent are required +// Elven Accuracy is Supreme accuracy feat, Reliable Talent is operative's Reliable Talent Class Feat +export default class D20Roll extends Roll { + constructor(formula, data, options) { + super(formula, data, options); + if ( !((this.terms[0] instanceof Die) && (this.terms[0].faces === 20)) ) { + throw new Error(`Invalid D20Roll formula provided ${this._formula}`); + } + this.configureModifiers(); + } + + /* -------------------------------------------- */ + + /** + * Advantage mode of a 5e d20 roll + * @enum {number} + */ + static ADV_MODE = { + NORMAL: 0, + ADVANTAGE: 1, + DISADVANTAGE: -1, + } + + /** + * The HTML template path used to configure evaluation of this Roll + * @type {string} + */ + static EVALUATION_TEMPLATE = "systems/sw5e/templates/chat/roll-dialog.html"; + + /* -------------------------------------------- */ + + /** + * A convenience reference for whether this D20Roll has advantage + * @type {boolean} + */ + get hasAdvantage() { + return this.options.advantageMode === D20Roll.ADV_MODE.ADVANTAGE; + } + + /** + * A convenience reference for whether this D20Roll has disadvantage + * @type {boolean} + */ + get hasDisadvantage() { + return this.options.advantageMode === D20Roll.ADV_MODE.DISADVANTAGE; + } + + /* -------------------------------------------- */ + /* D20 Roll Methods */ + /* -------------------------------------------- */ + + /** + * Apply optional modifiers which customize the behavior of the d20term + * @private + */ + configureModifiers() { + const d20 = this.terms[0]; + d20.modifiers = []; + + // Halfling Lucky + if ( this.options.halflingLucky ) d20.modifiers.push("r1=1"); + + // Reliable Talent + if ( this.options.reliableTalent ) d20.modifiers.push("min10"); + + // Handle Advantage or Disadvantage + if ( this.hasAdvantage ) { + d20.number = this.options.elvenAccuracy ? 3 : 2; + d20.modifiers.push("kh"); + d20.options.advantage = true; + } + else if ( this.hasDisadvantage ) { + d20.number = 2; + d20.modifiers.push("kl"); + d20.options.disadvantage = true; + } + else d20.number = 1; + + // Assign critical and fumble thresholds + if ( this.options.critical ) d20.options.critical = this.options.critical; + if ( this.options.fumble ) d20.options.fumble = this.options.fumble; + if ( this.options.targetValue ) d20.options.target = this.options.targetValue; + + // Re-compile the underlying formula + this._formula = this.constructor.getFormula(this.terms); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + async toMessage(messageData={}, options={}) { + + // Evaluate the roll now so we have the results available to determine whether reliable talent came into play + if ( !this._evaluated ) await this.evaluate({async: true}); + + // Add appropriate advantage mode message flavor and sw5e roll flags + messageData.flavor = messageData.flavor || this.options.flavor; + if ( this.hasAdvantage ) messageData.flavor += ` (${game.i18n.localize("SW5E.Advantage")})`; + else if ( this.hasDisadvantage ) messageData.flavor += ` (${game.i18n.localize("SW5E.Disadvantage")})`; + + // Add reliable talent to the d20-term flavor text if it applied + if ( this.options.reliableTalent ) { + const d20 = this.dice[0]; + const isRT = d20.results.every(r => !r.active || (r.result < 10)); + const label = `(${game.i18n.localize("SW5E.FlagsReliableTalent")})`; + if ( isRT ) d20.options.flavor = d20.options.flavor ? `${d20.options.flavor} (${label})` : label; + } + + // Record the preferred rollMode + options.rollMode = options.rollMode ?? this.options.rollMode; + return super.toMessage(messageData, options); + } + + /* -------------------------------------------- */ + /* Configuration Dialog */ + /* -------------------------------------------- */ + + /** + * Create a Dialog prompt used to configure evaluation of an existing D20Roll instance. + * @param {object} data Dialog configuration data + * @param {string} [data.title] The title of the shown dialog window + * @param {number} [data.defaultRollMode] The roll mode that the roll mode select element should default to + * @param {number} [data.defaultAction] The button marked as default + * @param {boolean} [data.chooseModifier] Choose which ability modifier should be applied to the roll? + * @param {string} [data.defaultAbility] For tool rolls, the default ability modifier applied to the roll + * @param {string} [data.template] A custom path to an HTML template to use instead of the default + * @param {object} options Additional Dialog customization options + * @returns {Promise} A resulting D20Roll object constructed with the dialog, or null if the dialog was closed + */ + async configureDialog({title, defaultRollMode, defaultAction=D20Roll.ADV_MODE.NORMAL, chooseModifier=false, defaultAbility, template}={}, options={}) { + + // Render the Dialog inner HTML + const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, { + formula: `${this.formula} + @bonus`, + defaultRollMode, + rollModes: CONFIG.Dice.rollModes, + chooseModifier, + defaultAbility, + abilities: CONFIG.SW5E.abilities + }); + + let defaultButton = "normal"; + switch (defaultAction) { + case D20Roll.ADV_MODE.ADVANTAGE: defaultButton = "advantage"; break; + case D20Roll.ADV_MODE.DISADVANTAGE: defaultButton = "disadvantage"; break; + } + + // Create the Dialog window and await submission of the form + return new Promise(resolve => { + new Dialog({ + title, + content, + buttons: { + advantage: { + label: game.i18n.localize("SW5E.Advantage"), + callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.ADVANTAGE)) + }, + normal: { + label: game.i18n.localize("SW5E.Normal"), + callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.NORMAL)) + }, + disadvantage: { + label: game.i18n.localize("SW5E.Disadvantage"), + callback: html => resolve(this._onDialogSubmit(html, D20Roll.ADV_MODE.DISADVANTAGE)) + } + }, + default: defaultButton, + close: () => resolve(null) + }, options).render(true); + }); + } + + /* -------------------------------------------- */ + + /** + * Handle submission of the Roll evaluation configuration Dialog + * @param {jQuery} html The submitted dialog content + * @param {number} advantageMode The chosen advantage mode + * @private + */ + _onDialogSubmit(html, advantageMode) { + const form = html[0].querySelector("form"); + + // Append a situational bonus term + if ( form.bonus.value ) { + const bonus = new Roll(form.bonus.value, this.data); + if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"})); + this.terms = this.terms.concat(bonus.terms); + } + + // Customize the modifier + if ( form.ability?.value ) { + const abl = this.data.abilities[form.ability.value]; + this.terms.findSplice(t => t.term === "@mod", new NumericTerm({number: abl.mod})); + this.options.flavor += ` (${CONFIG.SW5E.abilities[form.ability.value]})`; + } + + // Apply advantage or disadvantage + this.options.advantageMode = advantageMode; + this.options.rollMode = form.rollMode.value; + this.configureModifiers(); + return this; + } +} diff --git a/module/dice/damage-roll.js b/module/dice/damage-roll.js new file mode 100644 index 00000000..cc901fb2 --- /dev/null +++ b/module/dice/damage-roll.js @@ -0,0 +1,181 @@ +/** + * A type of Roll specific to a damage (or healing) roll in the 5e system. + * @param {string} formula The string formula to parse + * @param {object} data The data object against which to parse attributes within the formula + * @param {object} [options={}] Extra optional arguments which describe or modify the DamageRoll + * @param {number} [options.criticalBonusDice=0] A number of bonus damage dice that are added for critical hits + * @param {number} [options.criticalMultiplier=2] A critical hit multiplier which is applied to critical hits + * @param {boolean} [options.multiplyNumeric=false] Multiply numeric terms by the critical multiplier + * @param {boolean} [options.powerfulCritical=false] Apply the "powerful criticals" house rule to critical hits + * + */ +export default class DamageRoll extends Roll { + constructor(formula, data, options) { + super(formula, data, options); + // For backwards compatibility, skip rolls which do not have the "critical" option defined + if ( this.options.critical !== undefined ) this.configureDamage(); + } + + /** + * The HTML template path used to configure evaluation of this Roll + * @type {string} + */ + static EVALUATION_TEMPLATE = "systems/sw5e/templates/chat/roll-dialog.html"; + + /* -------------------------------------------- */ + + /** + * A convenience reference for whether this DamageRoll is a critical hit + * @type {boolean} + */ + get isCritical() { + return this.options.critical; + } + + /* -------------------------------------------- */ + /* Damage Roll Methods */ + /* -------------------------------------------- */ + + /** + * Apply optional modifiers which customize the behavior of the d20term + * @private + */ + configureDamage() { + let flatBonus = 0; + for ( let [i, term] of this.terms.entries() ) { + + // Multiply dice terms + if ( term instanceof DiceTerm ) { + term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back + term.number = term.options.baseNumber; + if ( this.isCritical ) { + let cm = this.options.criticalMultiplier ?? 2; + + // Powerful critical - maximize damage and reduce the multiplier by 1 + if ( this.options.powerfulCritical ) { + flatBonus += (term.number * term.faces); + cm = Math.max(1, cm-1); + } + + // Alter the damage term + let cb = (this.options.criticalBonusDice && (i === 0)) ? this.options.criticalBonusDice : 0; + term.alter(cm, cb); + term.options.critical = true; + } + + } + + // Multiply numeric terms + else if ( this.options.multiplyNumeric && (term instanceof NumericTerm) ) { + term.options.baseNumber = term.options.baseNumber ?? term.number; // Reset back + term.number = term.options.baseNumber; + if ( this.isCritical ) { + term.number *= (this.options.criticalMultiplier ?? 2); + term.options.critical = true; + } + } + } + + // Add powerful critical bonus + if ( this.options.powerfulCritical && (flatBonus > 0) ) { + this.terms.push(new OperatorTerm({operator: "+"})); + this.terms.push(new NumericTerm({number: flatBonus}, {flavor: game.i18n.localize("SW5E.PowerfulCritical")})); + } + + // Re-compile the underlying formula + this._formula = this.constructor.getFormula(this.terms); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + toMessage(messageData={}, options={}) { + messageData.flavor = messageData.flavor || this.options.flavor; + if ( this.isCritical ) { + const label = game.i18n.localize("SW5E.CriticalHit"); + messageData.flavor = messageData.flavor ? `${messageData.flavor} (${label})` : label; + } + options.rollMode = options.rollMode ?? this.options.rollMode; + return super.toMessage(messageData, options); + } + + /* -------------------------------------------- */ + /* Configuration Dialog */ + /* -------------------------------------------- */ + + /** + * Create a Dialog prompt used to configure evaluation of an existing D20Roll instance. + * @param {object} data Dialog configuration data + * @param {string} [data.title] The title of the shown dialog window + * @param {number} [data.defaultRollMode] The roll mode that the roll mode select element should default to + * @param {string} [data.defaultCritical] Should critical be selected as default + * @param {string} [data.template] A custom path to an HTML template to use instead of the default + * @param {boolean} [data.allowCritical=true] Allow critical hit to be chosen as a possible damage mode + * @param {object} options Additional Dialog customization options + * @returns {Promise} A resulting D20Roll object constructed with the dialog, or null if the dialog was closed + */ + async configureDialog({title, defaultRollMode, defaultCritical=false, template, allowCritical=true}={}, options={}) { + + // Render the Dialog inner HTML + const content = await renderTemplate(template ?? this.constructor.EVALUATION_TEMPLATE, { + formula: `${this.formula} + @bonus`, + defaultRollMode, + rollModes: CONFIG.Dice.rollModes, + }); + + // Create the Dialog window and await submission of the form + return new Promise(resolve => { + new Dialog({ + title, + content, + buttons: { + critical: { + condition: allowCritical, + label: game.i18n.localize("SW5E.CriticalHit"), + callback: html => resolve(this._onDialogSubmit(html, true)) + }, + normal: { + label: game.i18n.localize(allowCritical ? "SW5E.Normal" : "SW5E.Roll"), + callback: html => resolve(this._onDialogSubmit(html, false)) + } + }, + default: defaultCritical ? "critical" : "normal", + close: () => resolve(null) + }, options).render(true); + }); + } + + /* -------------------------------------------- */ + + /** + * Handle submission of the Roll evaluation configuration Dialog + * @param {jQuery} html The submitted dialog content + * @param {boolean} isCritical Is the damage a critical hit? + * @private + */ + _onDialogSubmit(html, isCritical) { + const form = html[0].querySelector("form"); + + // Append a situational bonus term + if ( form.bonus.value ) { + const bonus = new Roll(form.bonus.value, this.data); + if ( !(bonus.terms[0] instanceof OperatorTerm) ) this.terms.push(new OperatorTerm({operator: "+"})); + this.terms = this.terms.concat(bonus.terms); + } + + // Apply advantage or disadvantage + this.options.critical = isCritical; + this.options.rollMode = form.rollMode.value; + this.configureDamage(); + return this; + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + static fromData(data) { + const roll = super.fromData(data); + roll._formula = this.getFormula(roll.terms); + return roll; + } +} diff --git a/module/dice/roll-dialog.js b/module/dice/roll-dialog.js new file mode 100644 index 00000000..724b3999 --- /dev/null +++ b/module/dice/roll-dialog.js @@ -0,0 +1,15 @@ +/** + * @deprecated since 1.3.0 + * @ignore + */ +async function d20Dialog(data, options) { + throw new Error(`The d20Dialog helper method is deprecated in favor of D20Roll#configureDialog`); +} + +/** + * @deprecated since 1.3.0 + * @ignore + */ +async function damageDialog(data, options) { + throw new Error(`The damageDialog helper method is deprecated in favor of DamageRoll#configureDialog`); +} diff --git a/module/effects.js b/module/effects.js index 0d957fdb..7a70430b 100644 --- a/module/effects.js +++ b/module/effects.js @@ -10,13 +10,13 @@ export function onManageActiveEffect(event, owner) { const effect = li.dataset.effectId ? owner.effects.get(li.dataset.effectId) : null; switch ( a.dataset.action ) { case "create": - return ActiveEffect.create({ + return owner.createEmbeddedDocuments("ActiveEffect", [{ label: "New Effect", icon: "icons/svg/aura.svg", origin: owner.uuid, "duration.rounds": li.dataset.effectType === "temporary" ? 1 : undefined, disabled: li.dataset.effectType === "inactive" - }, owner).create(); + }]); case "edit": return effect.sheet.render(true); case "delete": diff --git a/module/item/entity.js b/module/item/entity.js index 7709c71a..2f1f682b 100644 --- a/module/item/entity.js +++ b/module/item/entity.js @@ -2,7 +2,8 @@ import {simplifyRollFormula, d20Roll, damageRoll} from "../dice.js"; import AbilityUseDialog from "../apps/ability-use-dialog.js"; /** - * Override and extend the basic :class:`Item` implementation + * Override and extend the basic Item implementation + * @extends {Item} */ export default class Item5e extends Item { @@ -44,12 +45,15 @@ export default class Item5e extends Item { else if (this.data.type === "weapon") { const wt = itemData.weaponType; - // Melee weapons - Str or Dex if Finesse (PHB pg. 147) - if ( ["simpleVW", "martialVW", "simpleLW", "martialLW"].includes(wt) ) { - if (itemData.properties.fin === true) { // Finesse weapons - return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "str"; - } - return "str"; + // Weapons using the powercasting modifier + // No current SW5e weapons use this, but it's worth checking just in case + if (["mpak", "rpak"].includes(itemData.actionType)) { + return actorData.attributes.powercasting || "int"; + } + + // Finesse weapons - Str or Dex (PHB pg. 147) + else if (itemData.properties.fin === true) { + return (actorData.abilities["dex"].mod >= actorData.abilities["str"].mod) ? "dex" : "str"; } // Ranged weapons - Dex (PH p.194) @@ -144,7 +148,7 @@ export default class Item5e extends Item { get hasLimitedUses() { let chg = this.data.data.recharge || {}; let uses = this.data.data.uses || {}; - return !!chg.value || (!!uses.per && (uses.max > 0)); + return !!chg.value || (uses.per && (uses.max > 0)); } /* -------------------------------------------- */ @@ -154,8 +158,8 @@ export default class Item5e extends Item { /** * Augment the basic Item data model with additional dynamic data. */ - prepareData() { - super.prepareData(); + prepareDerivedData() { + super.prepareDerivedData(); // Get the Item's data const itemData = this.data; @@ -190,6 +194,7 @@ export default class Item5e extends Item { else labels.featType = game.i18n.localize("SW5E.Passive"); } + // TODO: Something with all this // Species Items else if ( itemData.type === "species" ) { // labels.species = C.species[data.species]; @@ -268,34 +273,64 @@ export default class Item5e extends Item { // Item Actions if ( data.hasOwnProperty("actionType") ) { - // if this item is owned, we populate the label and saving throw during actor init - if (!this.isOwned) { - // Saving throws - this.getSaveDC(); - - // To Hit - this.getAttackToHit(); - } - // Damage let dam = data.damage || {}; - if ( dam.parts ) { + if (dam.parts) { labels.damage = dam.parts.map(d => d[0]).join(" + ").replace(/\+ -/g, "- "); labels.damageTypes = dam.parts.map(d => C.damageTypes[d[1]]).join(", "); } + } + + // if this item is owned, we prepareFinalAttributes() at the end of actor init + if (!this.isOwned) this.prepareFinalAttributes(); + } + + /* -------------------------------------------- */ + + /** + * Compute item attributes which might depend on prepared actor data. + */ + prepareFinalAttributes() { + if ( this.data.data.hasOwnProperty("actionType") ) { + // Saving throws + this.getSaveDC(); + + // To Hit + this.getAttackToHit(); // Limited Uses - if ( this.isOwned && !!data.uses?.max ) { - let max = data.uses.max; - if ( !Number.isNumeric(max) ) { - max = Roll.replaceFormulaData(max, this.actor.getRollData()); - if ( Roll.MATH_PROXY.safeEval ) max = Roll.MATH_PROXY.safeEval(max); - } - data.uses.max = Number(max); - } + this.prepareMaxUses(); + + // Damage Label + this.getDerivedDamageLabel(); } } + /* -------------------------------------------- */ + + /** + * Populate a label with the compiled and simplified damage formula + * based on owned item actor data. This is only used for display + * purposes and is not related to Item5e#rollDamage + * + * @returns {Array} array of objects with `formula` and `damageType` + */ + getDerivedDamageLabel() { + const itemData = this.data.data; + if ( !this.hasAttack || !itemData || !this.isOwned ) return []; + + const rollData = this.getRollData(); + + const derivedDamage = itemData.damage?.parts?.map((damagePart) => ({ + formula: simplifyRollFormula(damagePart[0], rollData, { constantFirst: false }), + damageType: damagePart[1], + })); + + this.labels.derivedDamage = derivedDamage + + return derivedDamage; + } + /* -------------------------------------------- */ /** @@ -409,6 +444,31 @@ export default class Item5e extends Item { /* -------------------------------------------- */ + /** + * Populates the max uses of an item. + * If the item is an owned item and the `max` is not numeric, calculate based on actor data. + */ + prepareMaxUses() { + const data = this.data.data; + if (!data.uses?.max) return; + let max = data.uses.max; + + // if this is an owned item and the max is not numeric, we need to calculate it + if (this.isOwned && !Number.isNumeric(max)) { + if (this.actor.data === undefined) return; + try { + max = Roll.replaceFormulaData(max, this.actor.getRollData(), {missing: 0, warn: true}); + max = Roll.safeEval(max); + } catch(e) { + console.error('Problem preparing Max uses for', this.data.name, e); + return; + } + } + data.uses.max = Number(max); + } + + /* -------------------------------------------- */ + /** * Roll the item to Chat, creating a chat card which contains follow up attack or damage roll options * @param {boolean} [configureDialog] Display a configuration dialog for the item roll, if applicable? @@ -419,16 +479,18 @@ export default class Item5e extends Item { */ async roll({configureDialog=true, rollMode, createMessage=true}={}) { let item = this; + const id = this.data.data; // Item system data const actor = this.actor; + const ad = actor.data.data; // Actor system data // Reference aspects of the item data necessary for usage - const id = this.data.data; // Item data const hasArea = this.hasAreaTarget; // Is the ability usage an AoE? const resource = id.consume || {}; // Resource consumption const recharge = id.recharge || {}; // Recharge mechanic const uses = id?.uses ?? {}; // Limited uses const isPower = this.type === "power"; // Does the item require a power slot? // TODO: Possibly Mod this to not consume slots based on class? + // We could use this for feats and architypes that let a character cast one slot every rest or so const requirePowerSlot = isPower && (id.level > 0) && CONFIG.SW5E.powerUpcastModes.includes(id.preparation.mode); // Define follow-up actions resulting from the item usage @@ -438,6 +500,8 @@ export default class Item5e extends Item { let consumePowerSlot = requirePowerSlot; // Consume a power slot let consumeUsage = !!uses.per; // Consume limited uses let consumeQuantity = uses.autoDestroy; // Consume quantity of the item in lieu of uses + let consumePowerLevel = null; // Consume a specific category of power slot + if ( requirePowerSlot ) consumePowerLevel = id.preparation.mode === "pact" ? "pact" : `power${id.level}`; // Display a configuration dialog to customize the usage const needsConfiguration = createMeasuredTemplate || consumeRecharge || consumeResource || consumePowerSlot || consumeUsage; @@ -455,28 +519,28 @@ export default class Item5e extends Item { // Handle power upcasting if ( requirePowerSlot ) { - const slotLevel = configuration.level; - const powerLevel = parseInt(slotLevel); - - if (powerLevel !== id.level) { - const upcastData = mergeObject(this.data, {"data.level": powerLevel}, {inplace: false}); - item = this.constructor.createOwned(upcastData, actor); // Replace the item with an upcast version + consumePowerLevel = `power${configuration.level}`; + if (consumePowerSlot === false) consumePowerLevel = null; + const upcastLevel = parseInt(configuration.level); + if (upcastLevel !== id.level) { + item = this.clone({"data.level": upcastLevel}, {keepId: true}); + item.data.update({_id: this.id}); // Retain the original ID (needed until 0.8.2+) + item.prepareFinalAttributes(); // Power save DC, etc... } - if ( consumePowerSlot ) consumePowerSlot = `power${powerLevel}`; } } // Determine whether the item can be used by testing for resource consumption - const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerSlot, consumeUsage, consumeQuantity}); + const usage = item._getUsageUpdates({consumeRecharge, consumeResource, consumePowerLevel, consumeUsage, consumeQuantity}); if ( !usage ) return; const {actorUpdates, itemUpdates, resourceUpdates} = usage; // Commit pending data updates - if ( !isObjectEmpty(itemUpdates) ) await item.update(itemUpdates); - if ( consumeQuantity && (item.data.data.quantity === 0) ) await item.delete(); - if ( !isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates); - if ( !isObjectEmpty(resourceUpdates) ) { + if ( !foundry.utils.isObjectEmpty(itemUpdates) ) await item.update(itemUpdates); + if ( consumeQuantity && (id.quantity === 0) ) await item.delete(); + if ( !foundry.utils.isObjectEmpty(actorUpdates) ) await actor.update(actorUpdates); + if ( !foundry.utils.isObjectEmpty(resourceUpdates) ) { const resource = actor.items.get(id.consume?.target); if ( resource ) await resource.update(resourceUpdates); } @@ -499,12 +563,12 @@ export default class Item5e extends Item { * @param {boolean} consumeQuantity Consume quantity of the item if other consumption modes are not available? * @param {boolean} consumeRecharge Whether the item consumes the recharge mechanic * @param {boolean} consumeResource Whether the item consumes a limited resource - * @param {string|boolean} consumePowerSlot A level of power slot consumed, or false + * @param {string|null} consumePowerLevel The category of power slot to consume, or null * @param {boolean} consumeUsage Whether the item consumes a limited usage * @returns {object|boolean} A set of data changes to apply when the item is used, or false * @private */ - _getUsageUpdates({consumeQuantity=false, consumeRecharge=false, consumeResource=false, consumePowerSlot=false, consumeUsage=false}) { + _getUsageUpdates({consumeQuantity, consumeRecharge, consumeResource, consumePowerLevel, consumeUsage}) { // Reference item data const id = this.data.data; @@ -529,8 +593,9 @@ export default class Item5e extends Item { } // Consume Power Slots and Force/Tech Points - if ( consumePowerSlot ) { - const level = this.actor?.data.data.powers[consumePowerSlot]; + if ( consumePowerLevel ) { + if ( Number.isNumeric(consumePowerLevel) ) consumePowerLevel = `power${consumePowerLevel}`; + const level = this.actor?.data.data.powers[consumePowerLevel]; const fp = this.actor.data.data.attributes.force.points; const tp = this.actor.data.data.attributes.tech.points; const powerCost = id.level + 1; @@ -546,7 +611,7 @@ export default class Item5e extends Item { ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label})); return false; } - actorUpdates[`data.powers.${consumePowerSlot}.fvalue`] = Math.max(powers - 1, 0); + actorUpdates[`data.powers.${consumePowerLevel}.fvalue`] = Math.max(powers - 1, 0); if (fp.temp >= powerCost) { actorUpdates["data.attributes.force.points.temp"] = fp.temp - powerCost; }else{ @@ -562,7 +627,7 @@ export default class Item5e extends Item { ui.notifications.warn(game.i18n.format("SW5E.PowerCastNoSlots", {name: this.name, level: label})); return false; } - actorUpdates[`data.powers.${consumePowerSlot}.tvalue`] = Math.max(powers - 1, 0); + actorUpdates[`data.powers.${consumePowerLevel}.tvalue`] = Math.max(powers - 1, 0); if (tp.temp >= powerCost) { actorUpdates["data.attributes.tech.points.temp"] = tp.temp - powerCost; }else{ @@ -701,11 +766,11 @@ export default class Item5e extends Item { */ async displayCard({rollMode, createMessage=true}={}) { - // Basic template rendering data + // Render the chat card template const token = this.actor.token; const templateData = { actor: this.actor, - tokenId: token ? `${token.scene._id}.${token.id}` : null, + tokenId: token?.uuid || null, item: this.data, data: this.getChatData(), labels: this.labels, @@ -715,17 +780,14 @@ export default class Item5e extends Item { isVersatile: this.isVersatile, isPower: this.data.type === "power", hasSave: this.hasSave, - hasAreaTarget: this.hasAreaTarget + hasAreaTarget: this.hasAreaTarget, + isTool: this.data.type === "tool" }; - - // Render the chat card template - const templateType = ["tool"].includes(this.data.type) ? this.data.type : "item"; - const template = `systems/sw5e/templates/chat/${templateType}-card.html`; - const html = await renderTemplate(template, templateData); + const html = await renderTemplate("systems/sw5e/templates/chat/item-card.html", templateData); // Create the ChatMessage data object const chatData = { - user: game.user._id, + user: game.user.data._id, type: CONST.CHAT_MESSAGE_TYPES.OTHER, content: html, flavor: this.data.data.chatFlavor || this.name, @@ -755,7 +817,7 @@ export default class Item5e extends Item { * @return {Object} An object of chat data to render */ getChatData(htmlOptions={}) { - const data = duplicate(this.data.data); + const data = foundry.utils.deepClone(this.data.data); const labels = this.labels; // Rich text description @@ -949,12 +1011,11 @@ export default class Item5e extends Item { } // Elven Accuracy - if ( ["weapon", "power"].includes(this.data.type) ) { - if (flags.elvenAccuracy && ["dex", "int", "wis", "cha"].includes(this.abilityMod)) { - rollConfig.elvenAccuracy = true; - } + if ( flags.elvenAccuracy && ["dex", "int", "wis", "cha"].includes(this.abilityMod) ) { + rollConfig.elvenAccuracy = true; } + // Apply Halfling Lucky if ( flags.halflingLucky ) rollConfig.halflingLucky = true; @@ -1189,11 +1250,17 @@ export default class Item5e extends Item { const parts = [`@mod`, "@prof"]; const title = `${this.name} - ${game.i18n.localize("SW5E.ToolCheck")}`; + // Add global actor bonus + const bonuses = getProperty(this.actor.data.data, "bonuses.abilities") || {}; + if ( bonuses.check ) { + parts.push("@checkBonus"); + rollData.checkBonus = bonuses.check; + } + // Compose the roll data const rollConfig = mergeObject({ parts: parts, data: rollData, - template: "systems/sw5e/templates/chat/tool-roll-dialog.html", title: title, speaker: ChatMessage.getSpeaker({actor: this.actor}), flavor: title, @@ -1202,6 +1269,7 @@ export default class Item5e extends Item { top: options.event ? options.event.clientY - 80 : null, left: window.innerWidth - 710, }, + chooseModifier: true, halflingLucky: this.actor.getFlag("sw5e", "halflingLucky" ) || false, reliableTalent: (this.data.data.proficient >= 1) && this.actor.getFlag("sw5e", "reliableTalent"), messageData: {"flags.sw5e.roll": {type: "tool", itemId: this.id }} @@ -1221,7 +1289,7 @@ export default class Item5e extends Item { getRollData() { if ( !this.actor ) return null; const rollData = this.actor.getRollData(); - rollData.item = duplicate(this.data.data); + rollData.item = foundry.utils.deepClone(this.data.data); // Include an ability score modifier if one exists const abl = this.abilityMod; @@ -1269,12 +1337,12 @@ export default class Item5e extends Item { if ( !( isTargetted || game.user.isGM || message.isAuthor ) ) return; // Recover the actor for the chat card - const actor = this._getChatCardActor(card); + const actor = await this._getChatCardActor(card); if ( !actor ) return; // Get the Item from stored flag data or by the item ID on the Actor const storedData = message.getFlag("sw5e", "itemData"); - const item = storedData ? this.createOwned(storedData, actor) : actor.getOwnedItem(card.dataset.itemId); + const item = storedData ? new this.constructor(storedData, {parent: actor}) : actor.items.get(card.dataset.itemId); if ( !item ) { return ui.notifications.error(game.i18n.format("SW5E.ActionWarningNoItem", {item: card.dataset.itemId, name: actor.name})) } @@ -1337,17 +1405,12 @@ export default class Item5e extends Item { * @return {Actor|null} The Actor entity or null * @private */ - static _getChatCardActor(card) { + static async _getChatCardActor(card) { // Case 1 - a synthetic actor from a Token - const tokenKey = card.dataset.tokenId; - if (tokenKey) { - const [sceneId, tokenId] = tokenKey.split("."); - const scene = game.scenes.get(sceneId); - if (!scene) return null; - const tokenData = scene.getEmbeddedEntity("Token", tokenId); - if (!tokenData) return null; - const token = new Token(tokenData); + if ( card.dataset.tokenId ) { + const token = await fromUuid(card.dataset.tokenId); + if ( !token ) return null; return token.actor; } @@ -1361,7 +1424,7 @@ export default class Item5e extends Item { /** * Get the Actor which is the author of a chat card * @param {HTMLElement} card The chat card being used - * @return {Array.} An Array of Actor entities, if any + * @return {Actor[]} An Array of Actor entities, if any * @private */ static _getChatCardTargets(card) { @@ -1372,14 +1435,169 @@ export default class Item5e extends Item { } /* -------------------------------------------- */ - /* Factory Methods */ + /* Event Handlers */ /* -------------------------------------------- */ + /** @inheritdoc */ + async _preCreate(data, options, user) { + await super._preCreate(data, options, user); + if ( !this.isEmbedded || (this.parent.type === "vehicle") ) return; + const actorData = this.parent.data; + const isNPC = this.parent.type === "npc"; + switch (data.type) { + case "equipment": + return this._onCreateOwnedEquipment(data, actorData, isNPC); + case "weapon": + return this._onCreateOwnedWeapon(data, actorData, isNPC); + case "power": + return this._onCreateOwnedPower(data, actorData, isNPC); + } + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onCreate(data, options, userId) { + super._onCreate(data, options, userId); + + // The below options are only needed for character classes + if ( userId !== game.user.id ) return; + const isCharacterClass = this.parent && (this.parent.type !== "vehicle") && (this.type === "class"); + if ( !isCharacterClass ) return; + + // Assign a new primary class + const pc = this.parent.items.get(this.parent.data.data.details.originalClass); + if ( !pc ) this.parent._assignPrimaryClass(); + + // Prompt to add new class features + if (options.addFeatures === false) return; + this.parent.getClassFeatures({ + className: this.name, + archetypeName: this.data.data.archetype, + level: this.data.data.levels + }).then(features => { + return this.parent.addEmbeddedItems(features, options.promptAddFeatures); + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onUpdate(changed, options, userId) { + super._onUpdate(changed, options, userId); + + // The below options are only needed for character classes + if ( userId !== game.user.id ) return; + const isCharacterClass = this.parent && (this.parent.type !== "vehicle") && (this.type === "class"); + if ( !isCharacterClass ) return; + + // Prompt to add new class features + const addFeatures = changed["name"] || (changed.data && ["archetype", "levels"].some(k => k in changed.data)); + if ( !addFeatures || (options.addFeatures === false) ) return; + this.parent.getClassFeatures({ + className: changed.name || this.name, + archetypeName: changed.data?.archetype || this.data.data.archetype, + level: changed.data?.levels || this.data.data.levels + }).then(features => { + return this.parent.addEmbeddedItems(features, options.promptAddFeatures); + }); + } + + /* -------------------------------------------- */ + + /** @inheritdoc */ + _onDelete(options, userId) { + super._onDelete(options, userId); + + // Assign a new primary class + if ( this.parent && (this.type === "class") && (userId === game.user.id) ) { + if ( this.id !== this.parent.data.data.details.originalClass ) return; + this.parent._assignPrimaryClass(); + } + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned equipment type Items + * @private + */ + _onCreateOwnedEquipment(data, actorData, isNPC) { + const updates = {}; + if ( foundry.utils.getProperty(data, "data.equipped") === undefined ) { + updates["data.equipped"] = isNPC; // NPCs automatically equip equipment + } + if ( foundry.utils.getProperty(data, "data.proficient") === undefined ) { + if ( isNPC ) { + updates["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + const armorProf = { + "natural": true, + "clothing": true, + "light": "lgt", + "medium": "med", + "heavy": "hvy", + "shield": "shl" + }[data.data?.armor?.type]; // Player characters check proficiency + const actorArmorProfs = actorData.data.traits?.armorProf?.value || []; + updates["data.proficient"] = (armorProf === true) || actorArmorProfs.includes(armorProf); + } + } + foundry.utils.mergeObject(data, updates); + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned power type Items + * @private + */ + _onCreateOwnedPower(data, actorData, isNPC) { + const updates = {}; + updates["data.prepared"] = true; // Automatically prepare powers for everyone + foundry.utils.mergeObject(data, updates); + } + + /* -------------------------------------------- */ + + /** + * Pre-creation logic for the automatic configuration of owned weapon type Items + * @private + */ + _onCreateOwnedWeapon(data, actorData, isNPC) { + const updates = {}; + if ( foundry.utils.getProperty(data, "data.equipped") === undefined ) { + updates["data.equipped"] = isNPC; // NPCs automatically equip weapons + } + if ( foundry.utils.getProperty(data, "data.proficient") === undefined ) { + if ( isNPC ) { + updates["data.proficient"] = true; // NPCs automatically have equipment proficiency + } else { + // TODO: With the changes to make weapon proficiencies more verbose, this may need revising + const weaponProf = { + "natural": true, + "simpleVW": "sim", + "simpleB": "sim", + "simpleLW": "sim", + "martialVW": "mar", + "martialB": "mar", + "martialLW": "mar" + }[data.data?.weaponType]; // Player characters check proficiency + const actorWeaponProfs = actorData.data.traits?.weaponProf?.value || []; + updates["data.proficient"] = (weaponProf === true) || actorWeaponProfs.includes(weaponProf); + } + } + foundry.utils.mergeObject(data, updates); + } + + /* -------------------------------------------- */ + /* Factory Methods */ + /* -------------------------------------------- */ +// TODO: Make work properly /** * Create a consumable power scroll Item from a power Item. * @param {Item5e} power The power to be made into a scroll * @return {Item5e} The created scroll consumable item - * @private */ static async createScrollFromPower(power) { @@ -1388,7 +1606,7 @@ export default class Item5e extends Item { const {actionType, description, source, activation, duration, target, range, damage, save, level} = itemData.data; // Get scroll data - const scrollUuid = CONFIG.SW5E.powerScrollIds[level]; + const scrollUuid = `Compendium.${CONFIG.SW5E.sourcePacks.ITEMS}.${CONFIG.SW5E.powerScrollIds[level]}`; const scrollItem = await fromUuid(scrollUuid); const scrollData = scrollItem.data; delete scrollData._id; diff --git a/module/item/sheet.js b/module/item/sheet.js index 02e8a550..556032e7 100644 --- a/module/item/sheet.js +++ b/module/item/sheet.js @@ -18,9 +18,9 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ static get defaultOptions() { - return mergeObject(super.defaultOptions, { + return foundry.utils.mergeObject(super.defaultOptions, { width: 560, height: 400, classes: ["sw5e", "sheet", "item"], @@ -32,7 +32,7 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ get template() { const path = "systems/sw5e/templates/items/"; return `${path}/${this.item.data.type}.html`; @@ -43,33 +43,39 @@ export default class ItemSheet5e extends ItemSheet { /** @override */ async getData(options) { const data = super.getData(options); + const itemData = data.data; data.labels = this.item.labels; data.config = CONFIG.SW5E; // Item Type, Status, and Details data.itemType = game.i18n.localize(`ITEM.Type${data.item.type.titleCase()}`); - data.itemStatus = this._getItemStatus(data.item); - data.itemProperties = this._getItemProperties(data.item); - data.isPhysical = data.item.data.hasOwnProperty("quantity"); + data.itemStatus = this._getItemStatus(itemData); + data.itemProperties = this._getItemProperties(itemData); + data.isPhysical = itemData.data.hasOwnProperty("quantity"); // Potential consumption targets - data.abilityConsumptionTargets = this._getItemConsumptionTargets(data.item); + data.abilityConsumptionTargets = this._getItemConsumptionTargets(itemData); // Action Detail data.hasAttackRoll = this.item.hasAttack; - data.isHealing = data.item.data.actionType === "heal"; - data.isFlatDC = getProperty(data.item.data, "save.scaling") === "flat"; - data.isLine = ["line", "wall"].includes(data.item.data.target?.type); + data.isHealing = itemData.data.actionType === "heal"; + data.isFlatDC = getProperty(itemData, "data.save.scaling") === "flat"; + data.isLine = ["line", "wall"].includes(itemData.data.target?.type); // Original maximum uses formula - if (this.item._data.data?.uses?.max) data.data.uses.max = this.item._data.data.uses.max; + const sourceMax = foundry.utils.getProperty(this.item.data._source, "data.uses.max"); + if ( sourceMax ) itemData.data.uses.max = sourceMax; // Vehicles - data.isCrewed = data.item.data.activation?.type === "crew"; - data.isMountable = this._isItemMountable(data.item); + data.isCrewed = itemData.data.activation?.type === "crew"; + data.isMountable = this._isItemMountable(itemData); // Prepare Active Effects - data.effects = prepareActiveEffectCategories(this.entity.effects); + data.effects = prepareActiveEffectCategories(this.item.effects); + + // Re-define the template data references (backwards compatible) + data.item = itemData; + data.data = itemData.data; return data; } @@ -102,9 +108,11 @@ export default class ItemSheet5e extends ItemSheet { // Attributes else if (consume.type === "attribute") { - const attributes = Object.values(CombatTrackerConfig.prototype.getAttributeChoices())[0]; // Bit of a hack - return attributes.reduce((obj, a) => { - obj[a] = a; + const attributes = TokenDocument.getTrackedAttributes(actor.data.data); + attributes.bar.forEach(a => a.push("value")); + return attributes.bar.concat(attributes.value).reduce((obj, a) => { + let k = a.join("."); + obj[k] = k; return obj; }, {}); } @@ -186,6 +194,7 @@ export default class ItemSheet5e extends ItemSheet { props.push(labels.armor); } else if (item.type === "feat") { props.push(labels.featType); + //TODO: Work out these } else if (item.type === "species") { //props.push(labels.species); } else if (item.type === "archetype") { @@ -238,7 +247,7 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ setPosition(position = {}) { if (!(this._minimized || position.height)) { position.height = this._tabs[0].active === "details" ? "auto" : this.options.height; @@ -250,7 +259,7 @@ export default class ItemSheet5e extends ItemSheet { /* Form Submission */ /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ _getSubmitData(updateData = {}) { // Create the expanded update data object const fd = new FormDataExtended(this.form, { editors: this.editors }); @@ -268,17 +277,14 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ activateListeners(html) { super.activateListeners(html); if (this.isEditable) { html.find(".damage-control").click(this._onDamageControl.bind(this)); - html.find(".trait-selector.class-skills").click(this._onConfigureClassSkills.bind(this)); + html.find(".trait-selector.class-skills").click(this._onConfigureTraits.bind(this)); html.find(".effect-control").click((ev) => { - if (this.item.isOwned) - return ui.notifications.warn( - "Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update." - ); + if (this.item.isOwned) return ui.notifications.warn("Managing Active Effects within an Owned Item is not currently supported and will be added in a subsequent update."); onManageActiveEffect(ev, this.item); }); } @@ -307,7 +313,7 @@ export default class ItemSheet5e extends ItemSheet { if (a.classList.contains("delete-damage")) { await this._onSubmit(event); // Submit any unsaved changes const li = a.closest(".damage-part"); - const damage = duplicate(this.item.data.data.damage); + const damage = foundry.utils.deepClone(this.item.data.data.damage); damage.parts.splice(Number(li.dataset.damagePart), 1); return this.item.update({ "data.damage.parts": damage.parts }); } @@ -316,33 +322,39 @@ export default class ItemSheet5e extends ItemSheet { /* -------------------------------------------- */ /** - * Handle spawning the TraitSelector application which allows a checkbox of multiple trait options + * Handle spawning the TraitSelector application for selection various options. * @param {Event} event The click event which originated the selection * @private */ - _onConfigureClassSkills(event) { + _onConfigureTraits(event) { event.preventDefault(); - const skills = this.item.data.data.skills; - const choices = skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills); const a = event.currentTarget; - const label = a.parentElement; - // Render the Trait Selector dialog - new TraitSelector(this.item, { + const options = { name: a.dataset.target, - title: label.innerText, - choices: Object.entries(CONFIG.SW5E.skills).reduce((obj, e) => { - if (choices.includes(e[0])) obj[e[0]] = e[1]; - return obj; - }, {}), - minimum: skills.number, - maximum: skills.number - }).render(true); + title: a.parentElement.innerText, + choices: [], + allowCustom: false + }; + + switch(a.dataset.options) { + case 'saves': + options.choices = CONFIG.SW5E.abilities; + options.valueKey = null; + break; + case 'skills': + const skills = this.item.data.data.skills; + const choiceSet = skills.choices && skills.choices.length ? skills.choices : Object.keys(CONFIG.SW5E.skills); + options.choices = Object.fromEntries(Object.entries(CONFIG.SW5E.skills).filter(skill => choiceSet.includes(skill[0]))); + options.maximum = skills.number; + break; + } + new TraitSelector(this.item, options).render(true); } /* -------------------------------------------- */ - /** @override */ + /** @inheritdoc */ async _onSubmit(...args) { if (this._tabs[0].active === "details") this.position.height = "auto"; await super._onSubmit(...args); diff --git a/module/migration.js b/module/migration.js index 60401489..27b877f5 100644 --- a/module/migration.js +++ b/module/migration.js @@ -1,3 +1,4 @@ +// TODO: Update /** * Perform a system migration for the entire World, applying migrations for Actors, Items, and Compendium packs * @return {Promise} A Promise which resolves once the migration is completed diff --git a/module/pixi/ability-template.js b/module/pixi/ability-template.js index 702343b2..d72cb886 100644 --- a/module/pixi/ability-template.js +++ b/module/pixi/ability-template.js @@ -19,7 +19,7 @@ export default class AbilityTemplate extends MeasuredTemplate { // Prepare template data const templateData = { t: templateShape, - user: game.user._id, + user: game.user.data._id, distance: target.value, direction: 0, x: 0, @@ -45,10 +45,12 @@ export default class AbilityTemplate extends MeasuredTemplate { } // Return the template constructed from the item data - const template = new this(templateData); - template.item = item; - template.actorSheet = item.actor?.sheet || null; - return template; + const cls = CONFIG.MeasuredTemplate.documentClass; + const template = new cls(templateData, {parent: canvas.scene}); + const object = new this(template); + object.item = item; + object.actorSheet = item.actor?.sheet || null; + return object; } /* -------------------------------------------- */ @@ -108,14 +110,9 @@ export default class AbilityTemplate extends MeasuredTemplate { // Confirm the workflow (left-click) handlers.lc = event => { handlers.rc(event); - - // Confirm final snapped position - const destination = canvas.grid.getSnappedPosition(this.x, this.y, 2); - this.data.x = destination.x; - this.data.y = destination.y; - - // Create the template - canvas.scene.createEmbeddedEntity("MeasuredTemplate", this.data); + const destination = canvas.grid.getSnappedPosition(this.data.x, this.data.y, 2); + this.data.update(destination); + canvas.scene.createEmbeddedDocuments("MeasuredTemplate", [this.data]); }; // Rotate the template by 3 degree increments (mouse-wheel) @@ -124,7 +121,7 @@ export default class AbilityTemplate extends MeasuredTemplate { event.stopPropagation(); let delta = canvas.grid.type > CONST.GRID_TYPES.SQUARE ? 30 : 15; let snap = event.shiftKey ? delta : 5; - this.data.direction += (snap * Math.sign(event.deltaY)); + this.data.update({direction: this.data.direction + (snap * Math.sign(event.deltaY))}); this.refresh(); }; diff --git a/module/token.js b/module/token.js new file mode 100644 index 00000000..db811603 --- /dev/null +++ b/module/token.js @@ -0,0 +1,99 @@ +/** + * Extend the base TokenDocument class to implement system-specific HP bar logic. + * @extends {TokenDocument} + */ +export class TokenDocument5e extends TokenDocument { + + /** @inheritdoc */ + getBarAttribute(...args) { + const data = super.getBarAttribute(...args); + if ( data && (data.attribute === "attributes.hp") ) { + data.value += parseInt(getProperty(this.actor.data, "data.attributes.hp.temp") || 0); + data.max += parseInt(getProperty(this.actor.data, "data.attributes.hp.tempmax") || 0); + } + return data; + } +} + + +/* -------------------------------------------- */ + + +/** + * Extend the base Token class to implement additional system-specific logic. + * @extends {Token} + */ +export class Token5e extends Token { + + /** @inheritdoc */ + _drawBar(number, bar, data) { + if ( data.attribute === "attributes.hp" ) return this._drawHPBar(number, bar, data); + return super._drawBar(number, bar, data); + } + + /* -------------------------------------------- */ + + /** + * Specialized drawing function for HP bars. + * @param {number} number The Bar number + * @param {PIXI.Graphics} bar The Bar container + * @param {object} data Resource data for this bar + * @private + */ + _drawHPBar(number, bar, data) { + + // Extract health data + let {value, max, temp, tempmax} = this.document.actor.data.data.attributes.hp; + temp = Number(temp || 0); + tempmax = Number(tempmax || 0); + + // Differentiate between effective maximum and displayed maximum + const effectiveMax = Math.max(0, max + tempmax); + let displayMax = max + (tempmax > 0 ? tempmax : 0); + + // Allocate percentages of the total + const tempPct = Math.clamped(temp, 0, displayMax) / displayMax; + const valuePct = Math.clamped(value, 0, effectiveMax) / displayMax; + const colorPct = Math.clamped(value, 0, effectiveMax) / displayMax; + + // Determine colors to use + const blk = 0x000000; + const hpColor = PIXI.utils.rgb2hex([(1-(colorPct/2)), colorPct, 0]); + const c = CONFIG.SW5E.tokenHPColors; + + // Determine the container size (logic borrowed from core) + const w = this.w; + let h = Math.max((canvas.dimensions.size / 12), 8); + if ( this.data.height >= 2 ) h *= 1.6; + const bs = Math.clamped(h / 8, 1, 2); + const bs1 = bs+1; + + // Overall bar container + bar.clear() + bar.beginFill(blk, 0.5).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, w, h, 3); + + // Temporary maximum HP + if (tempmax > 0) { + const pct = max / effectiveMax; + bar.beginFill(c.tempmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2); + } + + // Maximum HP penalty + else if (tempmax < 0) { + const pct = (max + tempmax) / max; + bar.beginFill(c.negmax, 1.0).lineStyle(1, blk, 1.0).drawRoundedRect(pct*w, 0, (1-pct)*w, h, 2); + } + + // Health bar + bar.beginFill(hpColor, 1.0).lineStyle(bs, blk, 1.0).drawRoundedRect(0, 0, valuePct*w, h, 2) + + // Temporary hit points + if ( temp > 0 ) { + bar.beginFill(c.temp, 1.0).lineStyle(0).drawRoundedRect(bs1, bs1, (tempPct*w)-(2*bs1), h-(2*bs1), 1); + } + + // Set position + let posY = (number === 0) ? (this.h - h) : 0; + bar.position.set(0, posY); + } +} diff --git a/sw5e-dark.css b/sw5e-dark.css index 9a0e1178..424da398 100644 --- a/sw5e-dark.css +++ b/sw5e-dark.css @@ -791,6 +791,9 @@ body.dark-theme .sw5e.sheet.actor .swalt-sheet .tab.notes section > input { color: #E81111; border-bottom: 2px solid #0d99cc; } +body.dark-theme .sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hover { + border-color: #E81111; +} body.dark-theme .sw5e.sheet.actor.npc .swalt-sheet header .experience { color: #4f4f4f; } diff --git a/sw5e-global.css b/sw5e-global.css index e2402c6c..b59b4414 100644 --- a/sw5e-global.css +++ b/sw5e-global.css @@ -206,7 +206,8 @@ input[type="password"], input[type="date"], input[type="time"], select, -textarea { +textarea, +.roundTransition { border-radius: 4px; transition: all 0.3s; } @@ -216,7 +217,8 @@ input[type="password"]:hover, input[type="date"]:hover, input[type="time"]:hover, select:hover, -textarea:hover { +textarea:hover, +.roundTransition:hover { box-shadow: none; } input[type="text"]:focus, @@ -225,7 +227,8 @@ input[type="password"]:focus, input[type="date"]:focus, input[type="time"]:focus, select:focus, -textarea:focus { +textarea:focus, +.roundTransition:focus { box-shadow: none; } input[type=range] { @@ -858,6 +861,7 @@ input[type="reset"]:disabled { font-weight: 400; letter-spacing: 0.5px; line-height: 24px; + width: 100%; } .sw5e.sheet.actor .swalt-sheet header .summary .proficiency { line-height: 26px; @@ -1686,13 +1690,34 @@ input[type="reset"]:disabled { .sw5e.sheet.actor.npc .swalt-sheet header h1.character-name { align-self: auto; } -.sw5e.sheet.actor.npc .swalt-sheet header .npc-size { +.sw5e.sheet.actor.npc .swalt-sheet header .npc-size, +.sw5e.sheet.actor.npc .swalt-sheet header .creature-type { font-family: 'Russo One'; font-size: 18px; font-weight: 400; letter-spacing: 0.5px; line-height: 28px; } +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type { + display: flex; + justify-content: space-between; + padding: 1px 4px; + border: 1px solid transparent; +} +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; +} +.sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hover .config-button { + display: block; +} .sw5e.sheet.actor.npc .swalt-sheet header .attributes { grid-template-columns: repeat(3, 1fr); } diff --git a/sw5e-light.css b/sw5e-light.css index 3b240d7a..20f09b81 100644 --- a/sw5e-light.css +++ b/sw5e-light.css @@ -778,6 +778,9 @@ body.light-theme .sw5e.sheet.actor .swalt-sheet .tab.notes section > input { color: #c40f0f; border-bottom: 2px solid #0d99cc; } +body.light-theme .sw5e.sheet.actor.npc .swalt-sheet header div.creature-type:hover { + border-color: #c40f0f; +} body.light-theme .sw5e.sheet.actor.npc .swalt-sheet header .experience { color: #4f4f4f; } diff --git a/sw5e.css b/sw5e.css index 556d0642..b0a5e8b6 100644 --- a/sw5e.css +++ b/sw5e.css @@ -91,9 +91,7 @@ /* Tags */ } .sw5e .window-content { - background: linear-gradient(90deg, #afc6d6 0%, #D6D6D6 30%, #D6D6D6 70%, #afc6d6); font-size: 13px; - color: #191813; } .sw5e input[type="text"], .sw5e input[type="number"], @@ -114,6 +112,8 @@ .sw5e select:disabled, .sw5e textarea:disabled { color: #4b4a44; + border: 1px solid transparent !important; + outline: none !important; } .sw5e input:disabled:hover, .sw5e select:disabled:hover, @@ -129,24 +129,6 @@ background: rgba(0, 0, 0, 0.1); border: 2px groove #eeede0; } -.sw5e label.checkbox { - flex: auto; - padding: 0; - margin: 0; - height: 22px; - line-height: 22px; - font-size: 11px; -} -.sw5e label.checkbox > input[type="checkbox"] { - width: 16px; - height: 16px; - margin: 0 2px 0 0; - position: relative; - top: 4px; -} -.sw5e label.checkbox.right > input[type="checkbox"] { - margin: 0 0 0 2px; -} .sw5e .form-group label { flex: 2; color: #4b4a44; @@ -179,11 +161,12 @@ .sw5e .form-group .form-fields > *:last-child { margin-right: 0; } -.sw5e .form-group.stacked label { +.sw5e .form-group.stacked > label { flex: 0 0 100%; margin: 0; } -.sw5e .form-group.stacked label.checkbox { +.sw5e .form-group.stacked label.checkbox, +.sw5e .form-group.stacked label.radio { flex: auto; text-align: left; } @@ -207,6 +190,26 @@ background: rgba(0, 0, 0, 0.05); } /* ----------------------------------------- */ +/* Hit Dice Config Sheet Specifically */ +/* ----------------------------------------- */ +.sw5e.hd-config .form-group button.increment, +.sw5e.hd-config .form-group button.decrement { + flex: 0 0 1rem; + line-height: 1rem; +} +.sw5e.hd-config .form-group button.decrement { + margin-right: 0; +} +.sw5e.hd-config .form-group span.sep { + margin: 0; +} +.sw5e.hd-config .form-group input { + flex: 0 0 2rem; + text-align: center; + margin-left: 2px; + margin-right: 2px; +} +/* ----------------------------------------- */ /* Entity Sheets Specifically */ /* ----------------------------------------- */ .sw5e.sheet { @@ -499,16 +502,60 @@ /* ----------------------------------------- */ /* Trait Selector /* ----------------------------------------- */ -#trait-selector .trait-list { +.trait-selector .trait-list { list-style: none; margin: 0; padding: 0; } -#trait-selector input[type="text"] { +.trait-selector input[type="text"] { height: 24px; margin: 2px; } /* ----------------------------------------- */ +/* Actor Type Config Sheet Specifically */ +/* ----------------------------------------- */ +.actor-type .trait-list { + display: flex; + flex-wrap: wrap; +} +.actor-type .trait-list li { + flex-basis: 50%; + flex-grow: 1; +} +.actor-type .trait-list li.form-group { + flex-basis: 100%; +} +.actor-type label.radio { + display: flex; + flex: auto; + font-size: 12px; + line-height: 20px; + font-weight: normal; +} +.actor-type label.radio > input[type="radio"] { + margin: 0 5px 0 0; +} +.actor-type li.custom-type input[type="radio"] { + display: none; +} +/* ----------------------------------------- */ +/* Add Feature Prompt Specifically */ +/* ----------------------------------------- */ +.sw5e.select-items-prompt .dialog-content { + margin-bottom: 1em; +} +.sw5e.select-items-prompt .items-list { + margin-top: 0.5em; +} +.sw5e.select-items-prompt .item-name > label, +.sw5e.select-items-prompt .item-image, +.sw5e.select-items-prompt input { + cursor: pointer; +} +.sw5e.select-items-prompt .item-name > label { + align-items: center; +} +/* ----------------------------------------- */ /* HUD /* ----------------------------------------- */ .placeable-hud .control-icon { @@ -574,6 +621,9 @@ /* Powerbook */ /* ----------------------------------------- */ /* ----------------------------------------- */ + /* Features Tab */ + /* ----------------------------------------- */ + /* ----------------------------------------- */ /* TinyMCE */ /* ----------------------------------------- */ } @@ -627,10 +677,12 @@ height: 30px; line-height: 30px; } -.sw5e.sheet.actor .sheet-header .attributes .movement h4.attribute-name { +.sw5e.sheet.actor .sheet-header .attributes .movement h4.attribute-name, +.sw5e.sheet.actor .sheet-header .attributes .hit-dice h4.attribute-name { position: relative; } -.sw5e.sheet.actor .sheet-header .attributes .movement .config-button { +.sw5e.sheet.actor .sheet-header .attributes .movement .config-button, +.sw5e.sheet.actor .sheet-header .attributes .hit-dice .config-button { position: absolute; display: none; right: 0; @@ -638,7 +690,8 @@ font-size: 12px; font-weight: normal; } -.sw5e.sheet.actor .sheet-header .attributes .movement:hover .config-button { +.sw5e.sheet.actor .sheet-header .attributes .movement:hover .config-button, +.sw5e.sheet.actor .sheet-header .attributes .hit-dice:hover .config-button { display: block; } .sw5e.sheet.actor .sheet-header .attributes input.temphp { @@ -1113,6 +1166,9 @@ .sw5e.sheet.actor .powerbook-empty .item-controls { flex: 1; } +.sw5e.sheet.actor .features i.original-class { + color: #4b4a44; +} .sw5e.sheet.actor .editor { padding: 0 8px; } @@ -1745,7 +1801,7 @@ text-align: right; padding-right: 5px; } -.sw5e.sheet.actor.character .resource .attribute-value input { +.sw5e.sheet.actor.character .resource .attribute-value > input { flex: 0 0 25%; } .sw5e.sheet.actor.character .resource .attribute-value label.recharge { @@ -1755,6 +1811,7 @@ font-size: 11px; text-align: center; color: #4b4a44; + align-items: center; } .sw5e.sheet.actor.character .resource .attribute-value label.recharge input[type="checkbox"] { height: 14px; @@ -1853,6 +1910,26 @@ .sw5e.sheet.actor.npc .summary { font-size: 18px; } +.sw5e.sheet.actor.npc .summary li.creature-type { + display: flex; + justify-content: space-between; + width: 1em; + padding: 0 3px; +} +.sw5e.sheet.actor.npc .summary li.creature-type span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sw5e.sheet.actor.npc .summary li.creature-type .config-button { + display: none; + font-size: 12px; + font-weight: normal; + line-height: 2em; +} +.sw5e.sheet.actor.npc .summary li.creature-type:hover .config-button { + display: block; +} .sw5e.sheet.actor.vehicle .features .item-controls { flex: 0 0 68px; } diff --git a/sw5e.js b/sw5e.js index e412f4f3..4f084c0d 100644 --- a/sw5e.js +++ b/sw5e.js @@ -12,12 +12,13 @@ import { SW5E } from "./module/config.js"; import { registerSystemSettings } from "./module/settings.js"; import { preloadHandlebarsTemplates } from "./module/templates.js"; import { _getInitiativeFormula } from "./module/combat.js"; -import { measureDistances, getBarAttribute } from "./module/canvas.js"; +import { measureDistances } from "./module/canvas.js"; -// Import Entities +// Import Documents import Actor5e from "./module/actor/entity.js"; import Item5e from "./module/item/entity.js"; import CharacterImporter from "./module/characterImporter.js"; +import { TokenDocument5e, Token5e } from "./module/token.js" // Import Applications import AbilityTemplate from "./module/pixi/ability-template.js"; @@ -45,6 +46,9 @@ import * as migrations from "./module/migration.js"; /* Foundry VTT Initialization */ /* -------------------------------------------- */ +// Keep on while migrating to Foundry version 0.8 +CONFIG.debug.hooks = true; + Hooks.once("init", function() { console.log(`SW5e | Initializing SW5E System\n${SW5E.ASCII}`); @@ -61,7 +65,8 @@ Hooks.once("init", function() { ItemSheet5e, ShortRestDialog, TraitSelector, - ActorMovementConfig + ActorMovementConfig, + ActorSensesConfig }, canvas: { AbilityTemplate @@ -71,6 +76,8 @@ Hooks.once("init", function() { entities: { Actor5e, Item5e, + TokenDocument5e, + Token5e, }, macros: macros, migrations: migrations, @@ -79,8 +86,10 @@ Hooks.once("init", function() { // Record Configuration Values CONFIG.SW5E = SW5E; - CONFIG.Actor.entityClass = Actor5e; - CONFIG.Item.entityClass = Item5e; + CONFIG.Actor.documentClass = Actor5e; + CONFIG.Item.documentClass = Item5e; + CONFIG.Token.documentClass = TokenDocument5e; + CONFIG.Token.objectClass = Token5e; CONFIG.time.roundTime = 6; CONFIG.fontFamilies = [ "Engli-Besh", @@ -88,6 +97,9 @@ Hooks.once("init", function() { "Russo One" ]; + CONFIG.Dice.DamageRoll = dice.DamageRoll; + CONFIG.Dice.D20Roll = dice.D20Roll; + // 5e cone RAW should be 53.13 degrees CONFIG.MeasuredTemplate.defaults.angle = 53.13; @@ -100,7 +112,11 @@ Hooks.once("init", function() { // Patch Core Functions CONFIG.Combat.initiative.formula = "1d20 + @attributes.init.mod + @attributes.init.prof + @attributes.init.bonus"; - Combat.prototype._getInitiativeFormula = _getInitiativeFormula; + Combatant.prototype._getInitiativeFormula = _getInitiativeFormula; + + // Register Roll Extensions + CONFIG.Dice.rolls.push(dice.D20Roll); + CONFIG.Dice.rolls.push(dice.DamageRoll); // Register sheet application classes Actors.unregisterSheet("core", ActorSheet); @@ -142,7 +158,7 @@ Hooks.once("init", function() { }); // Preload Handlebars Templates - preloadHandlebarsTemplates(); + return preloadHandlebarsTemplates(); }); @@ -191,7 +207,7 @@ Hooks.once("setup", function() { }); /* -------------------------------------------- */ - +//TODO: Setup Migration /** * Once the entire VTT framework is initialized, check to see if we should perform a data migration */ @@ -223,13 +239,9 @@ Hooks.once("ready", function() { /* -------------------------------------------- */ Hooks.on("canvasInit", function() { - // Extend Diagonal Measurement canvas.grid.diagonalRule = game.settings.get("sw5e", "diagonalMovement"); SquareGrid.prototype.measureDistances = measureDistances; - - // Extend Token Resource Bars - Token.prototype.getBarAttribute = getBarAttribute; }); @@ -272,7 +284,7 @@ Hooks.on("renderRollTableDirectory", (app, html, data)=> { Hooks.on("ActorSheet5eCharacterNew", (app, html, data) => { console.log("renderSwaltSheet"); }); -// TODO I should remove this +// FIXME: This helper is needed for the vehicle sheet. It should probably be refactored. Handlebars.registerHelper('getProperty', function (data, property) { return getProperty(data, property); }); diff --git a/system.json b/system.json index 54436f2c..d7b21d52 100644 --- a/system.json +++ b/system.json @@ -194,8 +194,8 @@ "gridUnits": "ft", "primaryTokenAttribute": "attributes.hp", "secondaryTokenAttribute": null, - "minimumCoreVersion": "0.7.7", - "compatibleCoreVersion": "0.7.9", + "minimumCoreVersion": "0.8.3", + "compatibleCoreVersion": "0.8.5", "url": "https://github.com/unrealkakeman89/sw5e", "manifest": "https://raw.githubusercontent.com/unrealkakeman89/sw5e/master/system.json", "download": "https://github.com/unrealkakeman89/sw5e/archive/master.zip" diff --git a/template.json b/template.json index 4df8e6b6..91c7e8c5 100644 --- a/template.json +++ b/template.json @@ -333,6 +333,7 @@ }, "details": { "background": "", + "originalClass": "", "xp": { "value": 0, "min": 0, @@ -382,7 +383,12 @@ "npc": { "templates": ["common", "creature"], "details": { - "type": "", + "type": { + "value": "", + "subtype": "", + "swarm": "", + "custom": "" + }, "environment": "", "cr": 1, "powerForceLevel": 0, @@ -880,13 +886,17 @@ "archetype": "", "hitDice": "d6", "hitDiceUsed": 0, + "saves": [], "skills": { "number": 2, "choices": [], "value": [] }, "source": "", - "powercasting": "none" + "powercasting": { + "progression": "none", + "ability": "" + } }, "classfeature": { "templates": ["itemDescription", "activatedEffect", "action"], diff --git a/templates/actors/newActor/character-sheet.html b/templates/actors/newActor/character-sheet.html index 0fac8d59..b9e12be5 100644 --- a/templates/actors/newActor/character-sheet.html +++ b/templates/actors/newActor/character-sheet.html @@ -63,7 +63,10 @@ {{!-- HIT DICE / SHORT & LONG REST BUTTONS --}}
-

{{ localize "SW5E.HitDice" }}

+

+ {{ localize "SW5E.HitDice" }} + +

{{data.attributes.hd}} / diff --git a/templates/actors/newActor/item.hbs b/templates/actors/newActor/item.hbs index cdd84ff9..d94cd26b 100644 --- a/templates/actors/newActor/item.hbs +++ b/templates/actors/newActor/item.hbs @@ -1,4 +1,4 @@ -
  • +
  • {{item.name}}

    diff --git a/templates/actors/newActor/limited-sheet.html b/templates/actors/newActor/limited-sheet.html index 69ae83eb..40ca7a3c 100644 --- a/templates/actors/newActor/limited-sheet.html +++ b/templates/actors/newActor/limited-sheet.html @@ -13,11 +13,11 @@

    Description

    - {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable}} + {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable rollData=rollData}}

    Background

    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/newActor/npc-sheet.html b/templates/actors/newActor/npc-sheet.html index 06ce97c5..367aa29a 100644 --- a/templates/actors/newActor/npc-sheet.html +++ b/templates/actors/newActor/npc-sheet.html @@ -21,8 +21,10 @@ {{lookup config.actorSizes data.traits.size}} - +
    + {{labels.type}} + +
    @@ -111,18 +113,20 @@

    {{localize "SW5E.Skills"}}

      - {{#each data.skills as |skill s|}} -
    1. - - - {{skill.label}} - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - {{!-- --}} - {{!-- ({{skill.passive}}) --}} -
    2. + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    3. + + + {{label}} + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + {{!-- --}} + {{!-- ({{skill.passive}}) --}} +
    4. + {{/with}} {{/each}}
    @@ -191,7 +195,7 @@

    {{localize "SW5E.Biography"}}

    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/newActor/parts/swalt-biography.html b/templates/actors/newActor/parts/swalt-biography.html index 104e0aaa..03591c8b 100644 --- a/templates/actors/newActor/parts/swalt-biography.html +++ b/templates/actors/newActor/parts/swalt-biography.html @@ -1,22 +1,22 @@

    {{localize "SW5E.PersonalityTraits" }}

    - {{editor content=data.details.trait target="data.details.trait" button=true owner=owner editable=editable}} + {{editor content=data.details.trait target="data.details.trait" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Ideals" }}

    - {{editor content=data.details.ideal target="data.details.ideal" button=true owner=owner editable=editable}} + {{editor content=data.details.ideal target="data.details.ideal" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Bonds" }}

    - {{editor content=data.details.bond target="data.details.bond" button=true owner=owner editable=editable}} + {{editor content=data.details.bond target="data.details.bond" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Flaws" }}

    - {{editor content=data.details.flaw target="data.details.flaw" button=true owner=owner editable=editable}} + {{editor content=data.details.flaw target="data.details.flaw" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Description" }}

    - {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable}} + {{editor content=data.details.description.value target="data.details.description.value" button=true owner=owner editable=editable rollData=rollData}}

    {{localize "SW5E.Background" }}

    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    \ No newline at end of file diff --git a/templates/actors/newActor/parts/swalt-core.html b/templates/actors/newActor/parts/swalt-core.html index cc0861be..0e722c29 100644 --- a/templates/actors/newActor/parts/swalt-core.html +++ b/templates/actors/newActor/parts/swalt-core.html @@ -26,18 +26,20 @@

    {{localize "SW5E.Skills" }}

      - {{#each data.skills as |skill s|}} -
    1. - - - {{skill.label}} - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - {{!-- --}} - {{!-- ({{skill.passive}}) --}} -
    2. + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    3. + + + {{label}} + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + {{!-- --}} + {{!-- ({{skill.passive}}) --}} +
    4. + {{/with}} {{/each}}
    diff --git a/templates/actors/newActor/parts/swalt-notes.html b/templates/actors/newActor/parts/swalt-notes.html index 3d007d32..fe0d140e 100644 --- a/templates/actors/newActor/parts/swalt-notes.html +++ b/templates/actors/newActor/parts/swalt-notes.html @@ -3,30 +3,30 @@ - {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable rollData=rollData}}
  • - {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/newActor/parts/swalt-resources.html b/templates/actors/newActor/parts/swalt-resources.html index 521779f6..1297b8b7 100644 --- a/templates/actors/newActor/parts/swalt-resources.html +++ b/templates/actors/newActor/parts/swalt-resources.html @@ -9,28 +9,28 @@ - {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes.value target="data.details.notes.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes1.value target="data.details.notes1.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes2.value target="data.details.notes2.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes3.value target="data.details.notes3.value" button=true owner=owner editable=editable rollData=rollData}}
    - {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable}} + {{editor content=data.details.notes4.value target="data.details.notes4.value" button=true owner=owner editable=editable rollData=rollData}}
    \ No newline at end of file diff --git a/templates/actors/newActor/vehicle-sheet.html b/templates/actors/newActor/vehicle-sheet.html index bbbff5d7..c0214b6e 100644 --- a/templates/actors/newActor/vehicle-sheet.html +++ b/templates/actors/newActor/vehicle-sheet.html @@ -158,7 +158,7 @@
    {{editor content=data.details.biography.value target='data.details.biography.value' - button=true owner=owner editable=editable}} + button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/character-sheet.html b/templates/actors/oldActor/character-sheet.html index fe66b89b..05f14052 100644 --- a/templates/actors/oldActor/character-sheet.html +++ b/templates/actors/oldActor/character-sheet.html @@ -60,8 +60,11 @@ -
  • -

    {{ localize "SW5E.HitDice" }}

    +
  • +

    + {{ localize "SW5E.HitDice" }} + +

    @@ -143,15 +146,17 @@ {{!-- Skills --}}
      - {{#each data.skills as |skill s|}} -
    • - - {{{skill.icon}}} -

      {{skill.label}}

      - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - ({{skill.passive}}) -
    • + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    • + + {{{skill.icon}}} +

      {{label}}

      + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + ({{skill.passive}}) +
    • + {{/with}} {{/each}}
    @@ -166,16 +171,16 @@ placeholder="{{res.placeholder}}" />
    -
  • @@ -251,7 +256,7 @@
    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/limited-sheet.html b/templates/actors/oldActor/limited-sheet.html index 3ece51c7..ac2f4250 100644 --- a/templates/actors/oldActor/limited-sheet.html +++ b/templates/actors/oldActor/limited-sheet.html @@ -14,7 +14,7 @@ {{!-- Sheet Body --}}
    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/npc-sheet.html b/templates/actors/oldActor/npc-sheet.html index bd1c6cc8..76001e43 100644 --- a/templates/actors/oldActor/npc-sheet.html +++ b/templates/actors/oldActor/npc-sheet.html @@ -27,8 +27,9 @@
  • -
  • - +
  • + {{labels.type}} +
  • @@ -108,15 +109,17 @@ {{!-- Skills --}}
      - {{#each data.skills as |skill s|}} -
    • - - {{{skill.icon}}} -

      {{skill.label}}

      - {{skill.ability}} - {{numberFormat skill.total decimals=0 sign=true}} - ({{skill.passive}}) -
    • + {{#each config.skills as |label s|}} + {{#with (lookup ../data.skills s) as |skill|}} +
    • + + {{{skill.icon}}} +

      {{label}}

      + {{skill.ability}} + {{numberFormat skill.total decimals=0 sign=true}} + ({{skill.passive}}) +
    • + {{/with}} {{/each}}
    @@ -172,7 +175,7 @@ {{!-- Biography Tab --}}
    - {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable}} + {{editor content=data.details.biography.value target="data.details.biography.value" button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/actors/oldActor/parts/actor-features.html b/templates/actors/oldActor/parts/actor-features.html index 491181b7..c2a0c27b 100644 --- a/templates/actors/oldActor/parts/actor-features.html +++ b/templates/actors/oldActor/parts/actor-features.html @@ -38,7 +38,10 @@
  • -

    {{item.name}}

    +

    + {{item.name}} + {{#if item.isOriginalClass}} {{/if}} +

    {{#if section.hasActions}} @@ -52,7 +55,6 @@ / {{item.data.uses.max}} {{/if}} -
    {{#if item.data.activation.type }} {{item.labels.activation}} @@ -106,7 +108,7 @@ {{#if section.columns}} {{#each section.columns}}
    - {{#with (getProperty item property)}} + {{#with (lookup item property)}} {{#if ../editable}} diff --git a/templates/actors/oldActor/parts/actor-powerbook.html b/templates/actors/oldActor/parts/actor-powerbook.html index fa4bac9f..c51a4c27 100644 --- a/templates/actors/oldActor/parts/actor-powerbook.html +++ b/templates/actors/oldActor/parts/actor-powerbook.html @@ -1,7 +1,7 @@
    {{#unless isNPC}} - + {{else}} {{editor content=data.details.biography.value target='data.details.biography.value' - button=true owner=owner editable=editable}} + button=true owner=owner editable=editable rollData=rollData}}
    diff --git a/templates/apps/actor-flags.html b/templates/apps/actor-flags.html index 2cbeeff4..93d0c826 100644 --- a/templates/apps/actor-flags.html +++ b/templates/apps/actor-flags.html @@ -2,6 +2,15 @@

    {{localize 'SW5E.FlagsInstructions'}}

    +

    {{localize "SW5E.ItemTypeClass"}}

    +
    + + +

    {{localize "SW5E.ClassMakeOriginalHint"}}

    +
    + {{#each flags as |fs section|}}

    {{localize section}}

    {{#each fs as |flag key|}} diff --git a/templates/apps/actor-type.html b/templates/apps/actor-type.html new file mode 100644 index 00000000..53534ec2 --- /dev/null +++ b/templates/apps/actor-type.html @@ -0,0 +1,38 @@ +
    + + +
    + +
      + {{#each types as |type key|}} +
    • + +
    • + {{/each}} +
    • + + + +
    • +
    • + + +
    • +
    +
    + +
    + + +
    + + +
    + diff --git a/templates/apps/hit-dice-config.html b/templates/apps/hit-dice-config.html new file mode 100644 index 00000000..061c23da --- /dev/null +++ b/templates/apps/hit-dice-config.html @@ -0,0 +1,23 @@ +
    +

    {{ localize "SW5E.HitDiceConfigHint" }}

    + {{#each classes}} +
    + +
    + + + / + + + + +
    +
    + {{/each}} +
    + + +
    +
    diff --git a/templates/apps/select-items-promt.html b/templates/apps/select-items-promt.html new file mode 100644 index 00000000..f67e1bfd --- /dev/null +++ b/templates/apps/select-items-promt.html @@ -0,0 +1,18 @@ +
    +

    {{hint}}

    + +
      + {{#each items}} +
    • +
      +
      + + +
      +
    • + {{/each}} +
    +
    \ No newline at end of file diff --git a/templates/chat/item-card.html b/templates/chat/item-card.html index faafcceb..1c2b3016 100644 --- a/templates/chat/item-card.html +++ b/templates/chat/item-card.html @@ -1,4 +1,4 @@ -
    @@ -39,6 +39,11 @@ {{#if hasAreaTarget}} {{/if}} + + + {{#if isTool}} + + {{/if}}
    diff --git a/templates/chat/roll-dialog.html b/templates/chat/roll-dialog.html index 6070c11d..1c580746 100644 --- a/templates/chat/roll-dialog.html +++ b/templates/chat/roll-dialog.html @@ -3,6 +3,14 @@
    + {{#if chooseModifier}} +
    + + +
    + {{/if}}
    @@ -10,11 +18,7 @@
    \ No newline at end of file diff --git a/templates/chat/tool-card.html b/templates/chat/tool-card.html deleted file mode 100644 index 944cf982..00000000 --- a/templates/chat/tool-card.html +++ /dev/null @@ -1,18 +0,0 @@ -
    -
    - -

    {{item.name}}

    -
    - -
    {{{data.description.value}}}
    - -
    - -
    - -
    - {{#each data.properties}} - {{this}} - {{/each}} -
    -
    diff --git a/templates/chat/tool-roll-dialog.html b/templates/chat/tool-roll-dialog.html deleted file mode 100644 index cc47a637..00000000 --- a/templates/chat/tool-roll-dialog.html +++ /dev/null @@ -1,33 +0,0 @@ -
    -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    -
    \ No newline at end of file