diff --git a/daggerheart.mjs b/daggerheart.mjs index 3abcd210..b1b2a0ca 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -3,13 +3,15 @@ import * as applications from './module/applications/_module.mjs'; import * as data from './module/data/_module.mjs'; import * as models from './module/data/_module.mjs'; import * as documents from './module/documents/_module.mjs'; +import * as collections from './module/documents/collections/_module.mjs'; import * as dice from './module/dice/_module.mjs'; import * as fields from './module/data/fields/_module.mjs'; import RegisterHandlebarsHelpers from './module/helpers/handlebarsHelper.mjs'; import { enricherConfig, enricherRenderSetup } from './module/enrichers/_module.mjs'; import { getCommandTarget, rollCommandToJSON } from './module/helpers/utils.mjs'; -import { BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll } from './module/dice/_module.mjs'; +import { BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll, FateRoll } from './module/dice/_module.mjs'; import { enrichedDualityRoll } from './module/enrichers/DualityRollEnricher.mjs'; +import { enrichedFateRoll, getFateTypeData } from './module/enrichers/FateRollEnricher.mjs'; import { handlebarsRegistration, runMigrations, @@ -24,16 +26,18 @@ import TokenManager from './module/documents/tokenManager.mjs'; CONFIG.DH = SYSTEM; CONFIG.TextEditor.enrichers.push(...enricherConfig); -CONFIG.Dice.rolls = [BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll]; +CONFIG.Dice.rolls = [BaseRoll, DHRoll, DualityRoll, D20Roll, DamageRoll, FateRoll]; CONFIG.Dice.daggerheart = { DHRoll: DHRoll, DualityRoll: DualityRoll, D20Roll: D20Roll, - DamageRoll: DamageRoll + DamageRoll: DamageRoll, + FateRoll: FateRoll }; CONFIG.Actor.documentClass = documents.DhpActor; CONFIG.Actor.dataModels = models.actors.config; +CONFIG.Actor.collection = collections.DhActorCollection; CONFIG.Item.documentClass = documents.DHItem; CONFIG.Item.dataModels = models.items.config; @@ -56,6 +60,9 @@ CONFIG.Canvas.layers.tokens.layerClass = DhTokenLayer; CONFIG.MeasuredTemplate.objectClass = placeables.DhMeasuredTemplate; +CONFIG.RollTable.documentClass = documents.DhRollTable; +CONFIG.RollTable.resultTemplate = 'systems/daggerheart/templates/ui/chat/table-result.hbs'; + CONFIG.Scene.documentClass = documents.DhScene; CONFIG.Token.documentClass = documents.DhToken; @@ -103,7 +110,7 @@ Hooks.once('init', () => { type: game.i18n.localize(typePath) }); - const { Items, Actors } = foundry.documents.collections; + const { Items, Actors, RollTables } = foundry.documents.collections; Items.unregisterSheet('core', foundry.applications.sheets.ItemSheetV2); Items.registerSheet(SYSTEM.id, applications.sheets.items.Ancestry, { types: ['ancestry'], @@ -188,6 +195,12 @@ Hooks.once('init', () => { label: sheetLabel('TYPES.Actor.party') }); + RollTables.unregisterSheet('core', foundry.applications.sheets.RollTableSheet); + RollTables.registerSheet(SYSTEM.id, applications.sheets.rollTables.RollTableSheet, { + types: ['base'], + makeDefault: true + }); + DocumentSheetConfig.unregisterSheet( CONFIG.ActiveEffect.documentClass, 'core', @@ -296,13 +309,15 @@ Hooks.on('chatMessage', (_, message) => { ? CONFIG.DH.ACTIONS.advantageState.disadvantage.value : undefined; const difficulty = rollCommand.difficulty; + const grantResources = Boolean(rollCommand.grantResources); const target = getCommandTarget({ allowNull: true }); - const title = traitValue - ? game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', { - ability: game.i18n.localize(SYSTEM.ACTOR.abilities[traitValue].label) - }) - : game.i18n.localize('DAGGERHEART.GENERAL.duality'); + const title = + (flavor ?? traitValue) + ? game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', { + ability: game.i18n.localize(SYSTEM.ACTOR.abilities[traitValue].label) + }) + : game.i18n.localize('DAGGERHEART.GENERAL.duality'); enrichedDualityRoll({ reaction, @@ -312,7 +327,36 @@ Hooks.on('chatMessage', (_, message) => { title, label: game.i18n.localize('DAGGERHEART.GENERAL.dualityRoll'), actionType: null, - advantage + advantage, + grantResources + }); + return false; + } + + if (message.startsWith('/fr')) { + const result = + message.trim().toLowerCase() === '/fr' ? { result: {} } : rollCommandToJSON(message.replace(/\/fr\s?/, '')); + + if (!result) { + ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateParsing')); + return false; + } + + const { result: rollCommand, flavor } = result; + const fateTypeData = getFateTypeData(rollCommand?.type); + + if (!fateTypeData) + return ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing')); + + const { value: fateType, label: fateTypeLabel } = fateTypeData; + const target = getCommandTarget({ allowNull: true }); + const title = flavor ?? game.i18n.localize('DAGGERHEART.GENERAL.fateRoll'); + + enrichedFateRoll({ + target, + title, + label: fateTypeLabel, + fateType }); return false; } @@ -381,8 +425,8 @@ Hooks.on('targetToken', () => { debouncedRangeEffectCall(); }); -Hooks.on('refreshToken', (_, options) => { - if (options.refreshPosition) { +Hooks.on('refreshToken', (token, options) => { + if (options.refreshPosition && !token._original) { debouncedRangeEffectCall(); } }); diff --git a/lang/en.json b/lang/en.json index 33f8c514..4d6815c3 100755 --- a/lang/en.json +++ b/lang/en.json @@ -237,10 +237,13 @@ "confirmText": "Would you like to level up your companion {name} by {levelChange} levels at this time? (You can do it manually later)" }, "viewLevelups": "View Levelups", + "resetCharacter": "Reset Character", "viewParty": "View Party", "InvalidOldCharacterImportTitle": "Old Character Import", "InvalidOldCharacterImportText": "Character data exported prior to system version 1.1 will not generate a complete character. Do you wish to continue?", - "cancelBeastform": "Cancel Beastform" + "cancelBeastform": "Cancel Beastform", + "resetCharacterConfirmationTitle": "Reset Character", + "resetCharacterConfirmationContent": "You are reseting all character data except name and portrait. Are you sure?" }, "Companion": { "FIELDS": { @@ -314,6 +317,8 @@ "selectPrimaryWeapon": "Select Primary Weapon", "selectSecondaryWeapon": "Select Secondary Weapon", "selectSubclass": "Select Subclass", + "setupSkipTitle": "Skipping Character Setup", + "setupSkipContent": "You are skipping the Character Setup by adding this manually. The character setup is the blinking arrows in the top-right. Are you sure you want to continue?", "startingItems": "Starting Items", "story": "Story", "storyExplanation": "Select which background and connection prompts you want to copy into your character's background.", @@ -325,6 +330,12 @@ "title": "{actor} - Character Setup", "traitIncreases": "Trait Increases" }, + "CharacterReset": { + "title": "Reset Character", + "alwaysDeleteSection": "Deleted Data", + "optionalDeleteSection": "Optional Data", + "headerTitle": "Select which data you'd like to keep" + }, "CombatTracker": { "combatStarted": "Active", "giveSpotlight": "Give The Spotlight", @@ -477,7 +488,9 @@ "tokenHUD": { "genericEffects": "Foundry Effects", "depositPartyTokens": "Deposit Party Tokens", - "retrievePartyTokens": "Retrieve Party Tokens" + "retrievePartyTokens": "Retrieve Party Tokens", + "depositCompanionTokens": "Deposit Companion Token", + "retrieveCompanionTokens": "Retrieve Companion Token" } }, "ImageSelect": { @@ -615,6 +628,13 @@ "title": "{name} Resource", "rerollDice": "Reroll Dice" }, + "RiskItAllDialog": { + "title": "{name} - Risk It All", + "subtitle": "Clear Stress and Hit Points", + "remainingTitle": "Remaining Points", + "clearResource": "Clear {resource}", + "finalTitle": "Final Character Resources" + }, "TagTeamSelect": { "title": "Tag Team Roll", "leaderTitle": "Initiating Character", @@ -962,6 +982,10 @@ "outsideRange": "Outside Range" }, "Condition": { + "deathMove": { + "name": "Death Move", + "description": "The character is about to make a Death Move" + }, "dead": { "name": "Dead", "description": "The character is dead" @@ -1013,15 +1037,15 @@ "DeathMoves": { "avoidDeath": { "name": "Avoid Death", - "description": "You drop unconscious temporarily and work with the GM to describe how the situation gets much worse because of it. Then roll your Fear die; if its value is equal to or under your Level, take a Scar." + "description": "Your character avoids death and faces the consequences. They temporarily drop unconscious, and then you work with the GM to describe how the situation worsens. While unconscious, your character can't move or act, and they can't be targeted by an attack. They return to consciousness when an ally clears 1 or more of their marked Hit Points or when the party finishes a long rest. After your character falls unconscious, roll your Hope Die. If its value is equal to or less than your character's level, they gain a scar: permanently cross out a Hope slot and work with the GM to determine its lasting narrative impact and how, if possible, it can be restored. If you ever cross out your last Hope slot, your character's journey ends." }, "riskItAll": { "name": "Risk It All", - "description": "Roll your Duality Dice. If Hope is higher, you stay on your feet and clear an amount of Hit Points and/or Stress equal to the value of the Hope die (divide the Hope die value up between these however you’d prefer). If your Fear die is higher, you cross through the veil of death. If the Duality Dice are tied, you stay on your feet and clear all Hit Points and Stress." + "description": "Roll your Duality Dice. If the Hope Die is higher, your character stays on their feet and clears a number of Hit Points or Stress equal to the value of the Hope Die (you can divide the Hope Die value between Hit Points and Stress however you'd prefer). If the Fear Die is higher, your character crosses through the veil of death. If the Duality Dice show matching results, your character stays up and clears all Hit Points and Stress." }, "blazeOfGlory": { "name": "Blaze Of Glory", - "description": "With Blaze of Glory, the player is accepting death for the character. Take one action (at GM discretion), which becomes an automatic critical success, then cross through the veil of death." + "description": "Your character embraces death and goes out in a blaze of glory. Take one final action. It automatically critically succeeds (with GM approval), and then you cross through the veil of death. NOTE: A Blaze of Glory effect has been added to your character. Any Duality Roll will automatically be a critical." } }, "DomainCardTypes": { @@ -2062,6 +2086,7 @@ "description": "Description", "main": "Data", "information": "Information", + "itemFeatures": "Item Features", "notes": "Notes", "inventory": "Inventory", "loadout": "Loadout", @@ -2137,6 +2162,7 @@ "dropActorsHere": "Drop Actors here", "dropFeaturesHere": "Drop Features here", "duality": "Duality", + "dualityDice": "Duality Dice", "dualityRoll": "Duality Roll", "enabled": "Enabled", "evasion": "Evasion", @@ -2146,11 +2172,14 @@ "plural": "Experiences" }, "failure": "Failure", + "fate": "Fate", + "fateRoll": "Fate Roll", "fear": "Fear", "features": "Features", "formula": "Formula", "general": "General", "gm": "GM", + "guaranteedCriticalSuccess": "Guaranteed Critical Success", "healing": "Healing", "healingRoll": "Healing Roll", "hit": { @@ -2194,6 +2223,7 @@ "single": "Player", "plurial": "Players" }, + "portrait": "Portrait", "proficiency": "Proficiency", "quantity": "Quantity", "range": "Range", @@ -2210,6 +2240,7 @@ "rollWith": "{roll} Roll", "save": "Save", "scalable": "Scalable", + "scars": "Scars", "situationalBonus": "Situational Bonus", "spent": "Spent", "step": "Step", @@ -2352,6 +2383,12 @@ "secondaryWeapon": "Secondary Weapon" } }, + "ROLLTABLES": { + "FIELDS": { + "formulaName": { "label": "Formula Name" } + }, + "formula": "Formula" + }, "SETTINGS": { "Appearance": { "FIELDS": { @@ -2418,7 +2455,11 @@ "overlay": { "label": "Overlay Effect" }, "characterDefault": { "label": "Character Default Defeated Status" }, "adversaryDefault": { "label": "Adversary Default Defeated Status" }, - "companionDefault": { "label": "Companion Default Defeated Status" } + "companionDefault": { "label": "Companion Default Defeated Status" }, + "deathMove": { "label": "Death Move" }, + "dead": { "label": "Dead" }, + "defeated": { "label": "Defeated" }, + "unconscious": { "label": "Unconscious" } }, "hopeFear": { "label": "Hope & Fear", @@ -2451,10 +2492,6 @@ "label": "Show Resource Change Scrolltexts", "hint": "When a character is damaged, uses armor etc, a scrolling text will briefly appear by the token to signify this." }, - "playerCanEditSheet": { - "label": "Players Can Manually Edit Character Settings", - "hint": "Players are allowed to access the manual Character Settings and change their statistics beyond the rules." - }, "roll": { "roll": { "label": "Roll", @@ -2511,6 +2548,7 @@ "resetMovesText": "Are you sure you want to reset?", "FIELDS": { "maxFear": { "label": "Max Fear" }, + "maxHope": { "label": "Max Hope" }, "traitArray": { "label": "Initial Trait Modifiers" }, "maxLoadout": { "label": "Max Cards in Loadout", @@ -2658,7 +2696,16 @@ "currentTarget": "Current" }, "deathMove": { - "title": "Death Move" + "title": "Death Move", + "gainScar": "You gained a scar.", + "avoidScar": "You have avoided a new scar.", + "journeysEnd": "You have {scars} Scars and have crossed out your last Hope slot. Your character's journey ends.", + "riskItAllCritical": "Critical Rolled, clearing all marked Stress and Hit Points.", + "riskItAllFailure": "The fear die rolled higher. You have crossed through the veil of death.", + "blazeOfGlory": "Blaze of Glory Effect Added!", + "riskItAllDialogButton": "Clear Stress And Hit Points.", + "riskItAllSuccessWithEnoughHope": "The Hope value is more than the marked Stress and Hit Points. Both are cleared fully.", + "riskItAllSuccess": "The hope die rolled higher, clear up to {hope} Stress And Hit Points." }, "dicePool": { "title": "Dice Pool" @@ -2780,7 +2827,9 @@ "noAssignedPlayerCharacter": "You have no assigned character.", "noSelectedToken": "You have no selected token", "onlyUseableByPC": "This can only be used with a PC token", - "dualityParsing": "Duality roll not properly formated", + "dualityParsing": "Duality roll not properly formatted", + "fateParsing": "Fate roll not properly formatted", + "fateTypeParsing": "Fate roll not properly formatted, bad fate type. Valid types are 'Hope' and 'Fear'", "attributeFaulty": "The supplied Attribute doesn't exist", "domainCardWrongDomain": "You don't have access to that Domain", "domainCardToHighLevel": "The Domain Card is too high level to be selected", @@ -2844,7 +2893,8 @@ "documentIsMissing": "The {documentType} is missing from the world.", "tokenActorMissing": "{name} is missing an Actor", "tokenActorsMissing": "[{names}] missing Actors", - "domainTouchRequirement": "This domain card requires {nr} {domain} cards in the loadout to be used" + "domainTouchRequirement": "This domain card requires {nr} {domain} cards in the loadout to be used", + "knowTheTide": "Know The Tide gained a token" }, "Sidebar": { "actorDirectory": { diff --git a/module/applications/dialogs/_module.mjs b/module/applications/dialogs/_module.mjs index 92038c41..4eda8579 100644 --- a/module/applications/dialogs/_module.mjs +++ b/module/applications/dialogs/_module.mjs @@ -1,5 +1,6 @@ export { default as AttributionDialog } from './attributionDialog.mjs'; export { default as BeastformDialog } from './beastformDialog.mjs'; +export { default as CharacterResetDialog } from './characterResetDialog.mjs'; export { default as d20RollDialog } from './d20RollDialog.mjs'; export { default as DamageDialog } from './damageDialog.mjs'; export { default as DamageReductionDialog } from './damageReductionDialog.mjs'; @@ -14,3 +15,4 @@ export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs'; export { default as ActionSelectionDialog } from './actionSelectionDialog.mjs'; export { default as GroupRollDialog } from './group-roll-dialog.mjs'; export { default as TagTeamDialog } from './tagTeamDialog.mjs'; +export { default as RiskItAllDialog } from './riskItAllDialog.mjs'; diff --git a/module/applications/dialogs/characterResetDialog.mjs b/module/applications/dialogs/characterResetDialog.mjs new file mode 100644 index 00000000..0836af9c --- /dev/null +++ b/module/applications/dialogs/characterResetDialog.mjs @@ -0,0 +1,105 @@ +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export default class CharacterResetDialog extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(actor, options = {}) { + super(options); + + this.actor = actor; + this.data = { + delete: { + class: { keep: false, label: 'TYPES.Item.class' }, + subclass: { keep: false, label: 'TYPES.Item.subclass' }, + ancestry: { keep: false, label: 'TYPES.Item.ancestry' }, + community: { keep: false, label: 'TYPES.Item.community' } + }, + optional: { + portrait: { keep: true, label: 'DAGGERHEART.GENERAL.portrait' }, + name: { keep: true, label: 'Name' }, + biography: { keep: true, label: 'DAGGERHEART.GENERAL.Tabs.biography' }, + inventory: { keep: true, label: 'DAGGERHEART.GENERAL.inventory' } + } + }; + } + + static DEFAULT_OPTIONS = { + tag: 'form', + classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'character-reset'], + window: { + icon: 'fa-solid fa-arrow-rotate-left', + title: 'DAGGERHEART.APPLICATIONS.CharacterReset.title' + }, + actions: { + finishSelection: this.#finishSelection + }, + form: { + handler: this.updateData, + submitOnChange: true, + submitOnClose: false + } + }; + + /** @override */ + static PARTS = { + resourceDice: { + id: 'resourceDice', + template: 'systems/daggerheart/templates/dialogs/characterReset.hbs' + } + }; + + async _prepareContext(_options) { + const context = await super._prepareContext(_options); + context.data = this.data; + + return context; + } + + static async updateData(event, _, formData) { + const { data } = foundry.utils.expandObject(formData.object); + + this.data = foundry.utils.mergeObject(this.data, data); + this.render(); + } + + static getUpdateData() { + const update = {}; + if (!this.data.optional.portrait) update.if(!this.data.optional.biography); + + if (!this.data.optional.inventory) return update; + } + + static async #finishSelection() { + const update = {}; + if (!this.data.optional.name.keep) { + const defaultName = game.system.api.documents.DhpActor.defaultName({ type: 'character' }); + foundry.utils.setProperty(update, 'name', defaultName); + foundry.utils.setProperty(update, 'prototypeToken.name', defaultName); + } + + if (!this.data.optional.portrait.keep) { + foundry.utils.setProperty(update, 'img', this.actor.schema.fields.img.initial(this.actor)); + foundry.utils.setProperty(update, 'prototypeToken.==texture', {}); + foundry.utils.setProperty(update, 'prototypeToken.==ring', {}); + } + + if (this.data.optional.biography.keep) + foundry.utils.setProperty(update, 'system.biography', this.actor.system.biography); + + if (this.data.optional.inventory.keep) foundry.utils.setProperty(update, 'system.gold', this.actor.system.gold); + + const { system, ...rest } = update; + await this.actor.update({ + ...rest, + '==system': system ?? {} + }); + + const inventoryItemTypes = ['weapon', 'armor', 'consumable', 'loot']; + await this.actor.deleteEmbeddedDocuments( + 'Item', + this.actor.items + .filter(x => !inventoryItemTypes.includes(x.type) || !this.data.optional.inventory.keep) + .map(x => x.id) + ); + + this.close(); + } +} diff --git a/module/applications/dialogs/d20RollDialog.mjs b/module/applications/dialogs/d20RollDialog.mjs index 441842dc..4a4b1556 100644 --- a/module/applications/dialogs/d20RollDialog.mjs +++ b/module/applications/dialogs/d20RollDialog.mjs @@ -109,11 +109,17 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio context.roll = this.roll; context.rollType = this.roll?.constructor.name; context.rallyDie = this.roll.rallyChoices; - const experiences = this.config.data?.system?.experiences || {}; + + const actorExperiences = this.config.data?.system?.experiences || {}; + const companionExperiences = this.config.roll.companionRoll + ? (this.config.data?.companion?.system.experiences ?? {}) + : null; + const experiences = companionExperiences ?? actorExperiences; context.experiences = Object.keys(experiences).map(id => ({ id, ...experiences[id] })); + context.selectedExperiences = this.config.experiences; context.advantage = this.config.roll?.advantage; context.disadvantage = this.config.roll?.disadvantage; @@ -123,7 +129,7 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio context.formula = this.roll.constructFormula(this.config); if (this.actor?.system?.traits) context.abilities = this.getTraitModifiers(); - context.showReaction = !this.config.roll?.type || context.rollType === 'DualityRoll'; + context.showReaction = !this.config.skips?.reaction && context.rollType === 'DualityRoll'; context.reactionOverride = this.reactionOverride; } diff --git a/module/applications/dialogs/deathMove.mjs b/module/applications/dialogs/deathMove.mjs index d0686d2b..3eadede6 100644 --- a/module/applications/dialogs/deathMove.mjs +++ b/module/applications/dialogs/deathMove.mjs @@ -1,11 +1,16 @@ -const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +import { enrichedFateRoll } from '../../enrichers/FateRollEnricher.mjs'; +import { enrichedDualityRoll } from '../../enrichers/DualityRollEnricher.mjs'; -export default class DhpDeathMove extends HandlebarsApplicationMixin(ApplicationV2) { +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV2) { constructor(actor) { super({}); this.actor = actor; this.selectedMove = null; + this.showRiskItAllButton = false; + this.riskItAllButtonLabel = ''; + this.riskItAllHope = 0; } get title() { @@ -38,6 +43,111 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application return context; } + async handleAvoidDeath() { + const target = this.actor.uuid; + const config = await enrichedFateRoll({ + target, + title: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.avoidDeath.name'), + label: `${game.i18n.localize('DAGGERHEART.GENERAL.hope')} ${game.i18n.localize('DAGGERHEART.GENERAL.fateRoll')}`, + fateType: 'Hope' + }); + + if (!config.roll.fate) return; + + let returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar'); + if (config.roll.fate.value <= this.actor.system.levelData.level.current) { + const newScarAmount = this.actor.system.scars + 1; + await this.actor.update({ + system: { + scars: newScarAmount + } + }); + + if (newScarAmount >= this.actor.system.resources.hope.max) { + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id); + return game.i18n.format('DAGGERHEART.UI.Chat.deathMove.journeysEnd', { scars: newScarAmount }); + } + + returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.gainScar'); + } + + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.unconscious.id); + return returnMessage; + } + + async handleRiskItAll() { + const config = await enrichedDualityRoll({ + reaction: true, + traitValue: null, + target: this.actor, + difficulty: null, + title: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.riskItAll.name'), + label: game.i18n.localize('DAGGERHEART.GENERAL.dualityDice'), + actionType: null, + advantage: null, + grantResources: false, + customConfig: { skips: { resources: true, reaction: true } } + }); + + if (!config.roll.result) return; + + const clearAllStressAndHitpointsUpdates = [ + { key: 'hitPoints', clear: true }, + { key: 'stress', clear: true } + ]; + + let chatMessage = ''; + if (config.roll.isCritical) { + config.resourceUpdates.addResources(clearAllStressAndHitpointsUpdates); + chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllCritical'); + } + + if (config.roll.result.duality == 1) { + if ( + config.roll.hope.value >= + this.actor.system.resources.hitPoints.value + this.actor.system.resources.stress.value + ) { + config.resourceUpdates.addResources(clearAllStressAndHitpointsUpdates); + chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllSuccessWithEnoughHope'); + } else { + chatMessage = game.i18n.format('DAGGERHEART.UI.Chat.deathMove.riskItAllSuccess', { + hope: config.roll.hope.value + }); + this.showRiskItAllButton = true; + this.riskItAllHope = config.roll.hope.value; + this.riskItAllButtonLabel = game.i18n.format('DAGGERHEART.UI.Chat.deathMove.riskItAllDialogButton'); + } + } + + if (config.roll.result.duality == -1) { + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id); + chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllFailure'); + } + + await config.resourceUpdates.updateResources(); + return chatMessage; + } + + async handleBlazeOfGlory() { + this.actor.createEmbeddedDocuments('ActiveEffect', [ + { + name: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.name'), + description: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.description'), + img: CONFIG.DH.GENERAL.deathMoves.blazeOfGlory.img, + changes: [ + { + key: 'system.rules.roll.guaranteedCritical', + mode: 2, + value: 'true' + } + ] + } + ]); + + await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id); + return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.blazeOfGlory'); + } + static selectMove(_, button) { const move = button.dataset.move; this.selectedMove = CONFIG.DH.GENERAL.deathMoves[move]; @@ -46,23 +156,49 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application } static async takeMove() { + this.close(); + + let result = ''; + + if (CONFIG.DH.GENERAL.deathMoves.blazeOfGlory === this.selectedMove) { + result = await this.handleBlazeOfGlory(); + } + + if (CONFIG.DH.GENERAL.deathMoves.avoidDeath === this.selectedMove) { + result = await this.handleAvoidDeath(); + } + + if (CONFIG.DH.GENERAL.deathMoves.riskItAll === this.selectedMove) { + result = await this.handleRiskItAll(); + } + + if (!result) return; + + const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance) + .expandRollMessage?.desc; const cls = getDocumentClass('ChatMessage'); + const msg = { user: game.user.id, content: await foundry.applications.handlebars.renderTemplate( 'systems/daggerheart/templates/ui/chat/deathMove.hbs', { player: this.actor.name, - actor: { name: this.actor.name, img: this.actor.img }, + actor: this.actor, + actorId: this.actor._id, author: game.users.get(game.user.id), title: game.i18n.localize(this.selectedMove.name), img: this.selectedMove.img, - description: game.i18n.localize(this.selectedMove.description) + description: game.i18n.localize(this.selectedMove.description), + result: result, + open: autoExpandDescription ? 'open' : '', + chevron: autoExpandDescription ? 'fa-chevron-up' : 'fa-chevron-down', + showRiskItAllButton: this.showRiskItAllButton, + riskItAllButtonLabel: this.riskItAllButtonLabel, + riskItAllHope: this.riskItAllHope } ), - title: game.i18n.localize( - 'DAGGERHEART.UI.Chat.deathMove.title' - ), + title: game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.title'), speaker: cls.getSpeaker(), flags: { daggerheart: { @@ -72,7 +208,5 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application }; cls.create(msg); - - this.close(); } } diff --git a/module/applications/dialogs/riskItAllDialog.mjs b/module/applications/dialogs/riskItAllDialog.mjs new file mode 100644 index 00000000..10fa1bb4 --- /dev/null +++ b/module/applications/dialogs/riskItAllDialog.mjs @@ -0,0 +1,94 @@ +const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; +export default class RiskItAllDialog extends HandlebarsApplicationMixin(ApplicationV2) { + constructor(actor, resourceValue) { + super({}); + + this.actor = actor; + this.resourceValue = resourceValue; + this.choices = { + hitPoints: 0, + stress: 0 + }; + } + + get title() { + return game.i18n.format('DAGGERHEART.APPLICATIONS.RiskItAllDialog.title', { name: this.actor.name }); + } + + static DEFAULT_OPTIONS = { + classes: ['daggerheart', 'dh-style', 'dialog', 'views', 'risk-it-all'], + position: { width: 280, height: 'auto' }, + window: { icon: 'fa-solid fa-dice fa-xl' }, + actions: { + finish: RiskItAllDialog.#finish + } + }; + + static PARTS = { + application: { + id: 'risk-it-all', + template: 'systems/daggerheart/templates/dialogs/riskItAllDialog.hbs' + } + }; + + _attachPartListeners(partId, htmlElement, options) { + super._attachPartListeners(partId, htmlElement, options); + + for (const input of htmlElement.querySelectorAll('.resource-container input')) + input.addEventListener('change', this.updateChoice.bind(this)); + } + + async _prepareContext(_options) { + const context = await super._prepareContext(_options); + context.resourceValue = this.resourceValue; + context.maxHitPointsValue = Math.min(this.resourceValue, this.actor.system.resources.hitPoints.max); + context.maxStressValue = Math.min(this.resourceValue, this.actor.system.resources.stress.max); + context.remainingResource = this.resourceValue - this.choices.hitPoints - this.choices.stress; + context.unfinished = context.remainingResource !== 0; + + context.choices = this.choices; + context.final = { + hitPoints: { + value: this.actor.system.resources.hitPoints.value - this.choices.hitPoints, + max: this.actor.system.resources.hitPoints.max + }, + stress: { + value: this.actor.system.resources.stress.value - this.choices.stress, + max: this.actor.system.resources.stress.max + } + }; + + context; + + return context; + } + + updateChoice(event) { + let value = Number.parseInt(event.target.value); + const choiceKey = event.target.dataset.choice; + const actorValue = this.actor.system.resources[choiceKey].value; + const remaining = this.resourceValue - this.choices.hitPoints - this.choices.stress; + const changeAmount = value - this.choices[choiceKey]; + + /* If trying to increase beyond remaining resource points, just increase to max available */ + if (remaining - changeAmount < 0) value = this.choices[choiceKey] + remaining; + else if (actorValue - value < 0) value = actorValue; + + this.choices[choiceKey] = value; + this.render(); + } + + static async #finish() { + const resourceUpdate = Object.keys(this.choices).reduce((acc, resourceKey) => { + const value = this.actor.system.resources[resourceKey].value - this.choices[resourceKey]; + acc[resourceKey] = { value }; + return acc; + }, {}); + + await this.actor.update({ + 'system.resources': resourceUpdate + }); + + this.close(); + } +} diff --git a/module/applications/hud/tokenHUD.mjs b/module/applications/hud/tokenHUD.mjs index 87c3e88e..77caaaff 100644 --- a/module/applications/hud/tokenHUD.mjs +++ b/module/applications/hud/tokenHUD.mjs @@ -5,7 +5,8 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { classes: ['daggerheart'], actions: { combat: DHTokenHUD.#onToggleCombat, - togglePartyTokens: DHTokenHUD.#togglePartyTokens + togglePartyTokens: DHTokenHUD.#togglePartyTokens, + toggleCompanions: DHTokenHUD.#toggleCompanions } }; @@ -26,7 +27,7 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { context.partyOnCanvas = this.actor.type === 'party' && this.actor.system.partyMembers.some(member => member.getActiveTokens().length > 0); - context.icons.toggleParty = 'systems/daggerheart/assets/icons/arrow-dunk.png'; + context.icons.toggleClowncar = 'systems/daggerheart/assets/icons/arrow-dunk.png'; context.actorType = this.actor.type; context.usesEffects = this.actor.type !== 'party'; context.canToggleCombat = DHTokenHUD.#nonCombatTypes.includes(this.actor.type) @@ -56,6 +57,9 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { }, {}) : null; + context.hasCompanion = this.actor.system.companion; + context.companionOnCanvas = context.hasCompanion && this.actor.system.companion.getActiveTokens().length > 0; + return context; } @@ -101,8 +105,24 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { : 'DAGGERHEART.APPLICATIONS.HUD.tokenHUD.depositPartyTokens' ); + await this.toggleClowncar(this.actor.system.partyMembers); + } + + static async #toggleCompanions(_, button) { + const icon = button.querySelector('img'); + icon.classList.toggle('flipped'); + button.dataset.tooltip = game.i18n.localize( + icon.classList.contains('flipped') + ? 'DAGGERHEART.APPLICATIONS.HUD.tokenHUD.retrieveCompanionTokens' + : 'DAGGERHEART.APPLICATIONS.HUD.tokenHUD.depositCompanionTokens' + ); + + await this.toggleClowncar([this.actor.system.companion]); + } + + async toggleClowncar(actors) { const animationDuration = 500; - const activeTokens = this.actor.system.partyMembers.flatMap(member => member.getActiveTokens()); + const activeTokens = actors.flatMap(member => member.getActiveTokens()); const { x: actorX, y: actorY } = this.document; if (activeTokens.length > 0) { for (let token of activeTokens) { @@ -114,14 +134,15 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD { } } else { const activeScene = game.scenes.find(x => x.id === game.user.viewedScene); - const partyTokenData = []; - for (let member of this.actor.system.partyMembers) { + const tokenData = []; + for (let member of actors) { const data = await member.getTokenDocument(); - partyTokenData.push(data.toObject()); + tokenData.push(data.toObject()); } + const newTokens = await activeScene.createEmbeddedDocuments( 'Token', - partyTokenData.map(tokenData => ({ + tokenData.map(tokenData => ({ ...tokenData, alpha: 0, x: actorX, diff --git a/module/applications/levelup/companionLevelup.mjs b/module/applications/levelup/companionLevelup.mjs index 4b8f9b47..7f11ccff 100644 --- a/module/applications/levelup/companionLevelup.mjs +++ b/module/applications/levelup/companionLevelup.mjs @@ -1,6 +1,6 @@ import BaseLevelUp from './levelup.mjs'; import { defaultCompanionTier, LevelOptionType } from '../../data/levelTier.mjs'; -import { DhLevelup } from '../../data/levelup.mjs'; +import { DhCompanionLevelup as DhLevelup } from '../../data/companionLevelup.mjs'; import { diceTypes, range } from '../../config/generalConfig.mjs'; export default class DhCompanionLevelUp extends BaseLevelUp { @@ -9,7 +9,9 @@ export default class DhCompanionLevelUp extends BaseLevelUp { this.levelTiers = this.addBonusChoices(defaultCompanionTier); const playerLevelupData = actor.system.levelData; - this.levelup = new DhLevelup(DhLevelup.initializeData(this.levelTiers, playerLevelupData)); + this.levelup = new DhLevelup( + DhLevelup.initializeData(this.levelTiers, playerLevelupData, actor.system.levelupChoicesLeft) + ); } async _preparePartContext(partId, context) { diff --git a/module/applications/levelup/levelupViewMode.mjs b/module/applications/levelup/levelupViewMode.mjs index b3d7c30f..afd7dbc4 100644 --- a/module/applications/levelup/levelupViewMode.mjs +++ b/module/applications/levelup/levelupViewMode.mjs @@ -70,7 +70,10 @@ export default class DhlevelUpViewMode extends HandlebarsApplicationMixin(Applic return checkbox; }); - let label = game.i18n.localize(option.label); + let label = + optionKey === 'domainCard' + ? game.i18n.format(option.label, { maxLevel: tier.levels.end }) + : game.i18n.localize(option.label); return { label: label, checkboxGroups: chunkify(checkboxes, option.minCost, chunkedBoxes => { diff --git a/module/applications/sheets-configs/action-base-config.mjs b/module/applications/sheets-configs/action-base-config.mjs index 7051ad2b..42252362 100644 --- a/module/applications/sheets-configs/action-base-config.mjs +++ b/module/applications/sheets-configs/action-base-config.mjs @@ -125,6 +125,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2) async _prepareContext(_options) { const context = await super._prepareContext(_options, 'action'); context.source = this.action.toObject(true); + context.action = this.action; context.summons = []; for (const summon of context.source.summon ?? []) { diff --git a/module/applications/sheets/_module.mjs b/module/applications/sheets/_module.mjs index c503e054..390267d5 100644 --- a/module/applications/sheets/_module.mjs +++ b/module/applications/sheets/_module.mjs @@ -1,3 +1,4 @@ export * as actors from './actors/_module.mjs'; export * as api from './api/_modules.mjs'; export * as items from './items/_module.mjs'; +export * as rollTables from './rollTables/_module.mjs'; diff --git a/module/applications/sheets/actors/character.mjs b/module/applications/sheets/actors/character.mjs index e11fee05..4ecaeb06 100644 --- a/module/applications/sheets/actors/character.mjs +++ b/module/applications/sheets/actors/character.mjs @@ -1,5 +1,5 @@ import DHBaseActorSheet from '../api/base-actor.mjs'; -import DhpDeathMove from '../../dialogs/deathMove.mjs'; +import DhDeathMove from '../../dialogs/deathMove.mjs'; import { abilities } from '../../../config/actorConfig.mjs'; import { CharacterLevelup, LevelupViewMode } from '../../levelup/_module.mjs'; import DhCharacterCreation from '../../characterCreation/characterCreation.mjs'; @@ -27,6 +27,7 @@ export default class CharacterSheet extends DHBaseActorSheet { makeDeathMove: CharacterSheet.#makeDeathMove, levelManagement: CharacterSheet.#levelManagement, viewLevelups: CharacterSheet.#viewLevelups, + resetCharacter: CharacterSheet.#resetCharacter, toggleEquipItem: CharacterSheet.#toggleEquipItem, toggleResourceDice: CharacterSheet.#toggleResourceDice, handleResourceDice: CharacterSheet.#handleResourceDice, @@ -42,6 +43,11 @@ export default class CharacterSheet extends DHBaseActorSheet { icon: 'fa-solid fa-angles-up', label: 'DAGGERHEART.ACTORS.Character.viewLevelups', action: 'viewLevelups' + }, + { + icon: 'fa-solid fa-arrow-rotate-left', + label: 'DAGGERHEART.ACTORS.Character.resetCharacter', + action: 'resetCharacter' } ] }, @@ -220,13 +226,6 @@ export default class CharacterSheet extends DHBaseActorSheet { async _preparePartContext(partId, context, options) { context = await super._preparePartContext(partId, context, options); switch (partId) { - case 'header': - const { playerCanEditSheet, levelupAuto } = game.settings.get( - CONFIG.DH.id, - CONFIG.DH.SETTINGS.gameSettings.Automation - ); - context.showSettings = game.user.isGM || !levelupAuto || (levelupAuto && playerCanEditSheet); - break; case 'loadout': await this._prepareLoadoutContext(context, options); break; @@ -666,12 +665,19 @@ export default class CharacterSheet extends DHBaseActorSheet { new LevelupViewMode(this.document).render({ force: true }); } + /** + * Resets the character data and removes all embedded documents. + */ + static async #resetCharacter() { + new game.system.api.applications.dialogs.CharacterResetDialog(this.document).render({ force: true }); + } + /** * Opens the Death Move interface for the character. * @type {ApplicationClickAction} */ static async #makeDeathMove() { - await new DhpDeathMove(this.document).render({ force: true }); + await new DhDeathMove(this.document).render({ force: true }); } /** @@ -728,9 +734,9 @@ export default class CharacterSheet extends DHBaseActorSheet { if (!result) return; /* This could be avoided by baking config.costs into config.resourceUpdates. Didn't feel like messing with it at the time */ - const costResources = result.costs - .filter(x => x.enabled) - .map(cost => ({ ...cost, value: -cost.value, total: -cost.total })); + const costResources = + result.costs?.filter(x => x.enabled).map(cost => ({ ...cost, value: -cost.value, total: -cost.total })) || + {}; config.resourceUpdates.addResources(costResources); await config.resourceUpdates.updateResources(); } @@ -956,6 +962,18 @@ export default class CharacterSheet extends DHBaseActorSheet { } async _onDropItem(event, item) { + const setupCriticalItemTypes = ['class', 'subclass', 'ancestry', 'community']; + if (this.document.system.needsCharacterSetup && setupCriticalItemTypes.includes(item.type)) { + const confirmed = await foundry.applications.api.DialogV2.confirm({ + window: { + title: game.i18n.localize('DAGGERHEART.APPLICATIONS.CharacterCreation.setupSkipTitle') + }, + content: game.i18n.localize('DAGGERHEART.APPLICATIONS.CharacterCreation.setupSkipContent') + }); + + if (!confirmed) return; + } + if (this.document.uuid === item.parent?.uuid) { return super._onDropItem(event, item); } diff --git a/module/applications/sheets/actors/companion.mjs b/module/applications/sheets/actors/companion.mjs index 9b85f622..b30b9c07 100644 --- a/module/applications/sheets/actors/companion.mjs +++ b/module/applications/sheets/actors/companion.mjs @@ -38,15 +38,6 @@ export default class DhCompanionSheet extends DHBaseActorSheet { } }; - /** @inheritDoc */ - async _onRender(context, options) { - await super._onRender(context, options); - - this.element - .querySelector('.level-value') - ?.addEventListener('change', event => this.document.updateLevel(Number(event.currentTarget.value))); - } - /* -------------------------------------------- */ /* Application Clicks Actions */ /* -------------------------------------------- */ @@ -71,10 +62,10 @@ export default class DhCompanionSheet extends DHBaseActorSheet { title: `${game.i18n.localize('DAGGERHEART.GENERAL.Roll.action')}: ${this.actor.name}`, headerTitle: `Companion ${game.i18n.localize('DAGGERHEART.GENERAL.Roll.action')}`, roll: { - trait: partner.system.spellcastModifierTrait?.key + trait: partner.system.spellcastModifierTrait?.key, + companionRoll: true }, - hasRoll: true, - data: partner.getRollData() + hasRoll: true }; const result = await partner.diceRoll(config); diff --git a/module/applications/sheets/api/application-mixin.mjs b/module/applications/sheets/api/application-mixin.mjs index b590de86..3c0444eb 100644 --- a/module/applications/sheets/api/application-mixin.mjs +++ b/module/applications/sheets/api/application-mixin.mjs @@ -600,7 +600,7 @@ export default function DHApplicationMixin(Base) { { relativeTo: isAction ? doc.parent : doc, rollData: doc.getRollData?.(), - secrets: isAction ? doc.parent.isOwner : doc.isOwner + secrets: isAction ? doc.parent.parent.isOwner : doc.isOwner } ); } diff --git a/module/applications/sheets/rollTables/_module.mjs b/module/applications/sheets/rollTables/_module.mjs new file mode 100644 index 00000000..73067b64 --- /dev/null +++ b/module/applications/sheets/rollTables/_module.mjs @@ -0,0 +1 @@ +export { default as RollTableSheet } from './rollTable.mjs'; diff --git a/module/applications/sheets/rollTables/rollTable.mjs b/module/applications/sheets/rollTables/rollTable.mjs new file mode 100644 index 00000000..9ead6814 --- /dev/null +++ b/module/applications/sheets/rollTables/rollTable.mjs @@ -0,0 +1,191 @@ +export default class DhRollTableSheet extends foundry.applications.sheets.RollTableSheet { + static DEFAULT_OPTIONS = { + ...super.DEFAULT_OPTIONS, + actions: { + changeMode: DhRollTableSheet.#onChangeMode, + drawResult: DhRollTableSheet.#onDrawResult, + resetResults: DhRollTableSheet.#onResetResults, + addFormula: DhRollTableSheet.#addFormula, + removeFormula: DhRollTableSheet.#removeFormula + } + }; + + static buildParts() { + const { footer, header, sheet, results, ...parts } = super.PARTS; + return { + sheet: { + ...sheet, + template: 'systems/daggerheart/templates/sheets/rollTable/sheet.hbs' + }, + header: { template: 'systems/daggerheart/templates/sheets/rollTable/header.hbs' }, + ...parts, + results: { + template: 'systems/daggerheart/templates/sheets/rollTable/results.hbs', + templates: ['templates/sheets/roll-table/result-details.hbs'], + scrollable: ['table[data-results] tbody'] + }, + summary: { template: 'systems/daggerheart/templates/sheets/rollTable/summary.hbs' }, + footer + }; + } + + static PARTS = DhRollTableSheet.buildParts(); + + async _preRender(context, options) { + await super._preRender(context, options); + + if (!options.internalRefresh) + this.daggerheartFlag = new game.system.api.data.DhRollTable(this.document.flags.daggerheart); + } + + /* root PART has a blank element on _attachPartListeners, so it cannot be used to set the eventListeners for the view mode */ + async _onRender(context, options) { + super._onRender(context, options); + + for (const element of this.element.querySelectorAll('.system-update-field')) + element.addEventListener('change', this.updateSystemField.bind(this)); + } + + async _preparePartContext(partId, context, options) { + context = await super._preparePartContext(partId, context, options); + + switch (partId) { + case 'sheet': + context.altFormula = this.daggerheartFlag.altFormula; + context.usesAltFormula = Object.keys(this.daggerheartFlag.altFormula).length > 0; + context.altFormulaOptions = { + '': { name: this.daggerheartFlag.formulaName }, + ...this.daggerheartFlag.altFormula + }; + context.activeAltFormula = this.daggerheartFlag.activeAltFormula; + context.selectedFormula = this.daggerheartFlag.getActiveFormula(this.document.formula); + context.results = this.getExtendedResults(context.results); + break; + case 'header': + context.altFormula = this.daggerheartFlag.altFormula; + context.usesAltFormula = Object.keys(this.daggerheartFlag.altFormula).length > 0; + context.altFormulaOptions = { + '': { name: this.daggerheartFlag.formulaName }, + ...this.daggerheartFlag.altFormula + }; + context.activeAltFormula = this.daggerheartFlag.activeAltFormula; + break; + case 'summary': + context.systemFields = this.daggerheartFlag.schema.fields; + context.altFormula = this.daggerheartFlag.altFormula; + context.formulaName = this.daggerheartFlag.formulaName; + break; + case 'results': + context.results = this.getExtendedResults(context.results); + break; + } + + return context; + } + + getExtendedResults(results) { + const bodyDarkMode = document.body.classList.contains('theme-dark'); + const elementLightMode = this.element.classList.contains('theme-light'); + const elementDarkMode = this.element.classList.contains('theme-dark'); + const isDarkMode = elementDarkMode || (!elementLightMode && bodyDarkMode); + + return results.map(x => ({ + ...x, + displayImg: isDarkMode && x.img === 'icons/svg/d20-black.svg' ? 'icons/svg/d20.svg' : x.img + })); + } + + /* -------------------------------------------- */ + /* Flag SystemData update methods */ + /* -------------------------------------------- */ + + async updateSystemField(event) { + const { dataset, value } = event.target; + await this.daggerheartFlag.updateSource({ [dataset.path]: value }); + this.render({ internalRefresh: true }); + } + + getSystemFlagUpdate() { + const deleteUpdate = Object.keys(this.document._source.flags.daggerheart?.altFormula ?? {}).reduce( + (acc, formulaKey) => { + if (!this.daggerheartFlag.altFormula[formulaKey]) acc.altFormula[`-=${formulaKey}`] = null; + + return acc; + }, + { altFormula: {} } + ); + + return { ['flags.daggerheart']: foundry.utils.mergeObject(this.daggerheartFlag.toObject(), deleteUpdate) }; + } + + static async #addFormula() { + await this.daggerheartFlag.updateSource({ + [`altFormula.${foundry.utils.randomID()}`]: game.system.api.data.DhRollTable.getDefaultFormula() + }); + this.render({ internalRefresh: true }); + } + + static async #removeFormula(_event, target) { + await this.daggerheartFlag.updateSource({ + [`altFormula.-=${target.dataset.key}`]: null + }); + this.render({ internalRefresh: true }); + } + + /* -------------------------------------------- */ + /* Extended RollTable methods */ + /* -------------------------------------------- */ + + /** + * Alternate between view and edit modes. + * @this {RollTableSheet} + * @type {ApplicationClickAction} + */ + static async #onChangeMode() { + this.mode = this.isEditMode ? 'view' : 'edit'; + await this.document.update(this.getSystemFlagUpdate()); + await this.render({ internalRefresh: true }); + } + + /** @inheritdoc */ + async _processSubmitData(event, form, submitData, options) { + /* RollTable sends an empty dummy event when swapping from view/edit first time */ + if (Object.keys(submitData).length) { + if (!submitData.flags) submitData.flags = { daggerheart: {} }; + submitData.flags.daggerheart = this.getSystemFlagUpdate(); + } + + super._processSubmitData(event, form, submitData, options); + } + + /** @inheritdoc */ + static async #onResetResults() { + await this.document.update(this.getSystemFlagUpdate()); + await this.document.resetResults(); + } + + /** + * Roll and draw a TableResult. + * @this {RollTableSheet} + * @type {ApplicationClickAction} + */ + static async #onDrawResult(_event, button) { + if (this.form) await this.submit({ operation: { render: false } }); + button.disabled = true; + const table = this.document; + + await this.document.update(this.getSystemFlagUpdate()); + + /* Sending in the currently selectd activeFormula to table.roll to use as the formula */ + const selectedFormula = this.daggerheartFlag.getActiveFormula(this.document.formula); + const tableRoll = await table.roll({ selectedFormula }); + const draws = table.getResultsForRoll(tableRoll.roll.total); + if (draws.length > 0) { + if (game.settings.get('core', 'animateRollTable')) await this._animateRoll(draws); + await table.draw(tableRoll); + } + + // Reenable the button if drawing with replacement since the draw won't trigger a sheet re-render + if (table.replacement) button.disabled = false; + } +} diff --git a/module/applications/ui/chatLog.mjs b/module/applications/ui/chatLog.mjs index c4a313fa..2b489f58 100644 --- a/module/applications/ui/chatLog.mjs +++ b/module/applications/ui/chatLog.mjs @@ -81,6 +81,9 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo html.querySelectorAll('.group-roll-header-expand-section').forEach(element => element.addEventListener('click', this.groupRollExpandSection) ); + html.querySelectorAll('.risk-it-all-button').forEach(element => + element.addEventListener('click', event => this.riskItAllClearStressAndHitPoints(event, data)) + ); }; setupHooks() { @@ -94,15 +97,17 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo /** Ensure the chat theme inherits the interface theme */ _replaceHTML(result, content, options) { - const themedElement = result.log?.querySelector(".chat-log"); - themedElement?.classList.remove("themed", "theme-light", "theme-dark"); + const themedElement = result.log?.querySelector('.chat-log'); + themedElement?.classList.remove('themed', 'theme-light', 'theme-dark'); super._replaceHTML(result, content, options); } /** Remove chat log theme from notifications area */ async _onFirstRender(result, content) { await super._onFirstRender(result, content); - document.querySelector("#chat-notifications .chat-log")?.classList.remove("themed", "theme-light", "theme-dark") + document + .querySelector('#chat-notifications .chat-log') + ?.classList.remove('themed', 'theme-light', 'theme-dark'); } async onRollSimple(event, message) { @@ -383,4 +388,10 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo }); event.target.closest('.group-roll-section').querySelector('.group-roll-content').classList.toggle('closed'); } + + async riskItAllClearStressAndHitPoints(event, data) { + const resourceValue = event.target.dataset.resourceValue; + const actor = game.actors.get(event.target.dataset.actorId); + new game.system.api.applications.dialogs.RiskItAllDialog(actor, resourceValue).render({ force: true }); + } } diff --git a/module/config/generalConfig.mjs b/module/config/generalConfig.mjs index 37894644..be1dfce1 100644 --- a/module/config/generalConfig.mjs +++ b/module/config/generalConfig.mjs @@ -171,7 +171,7 @@ export const defeatedConditions = () => { acc[key] = { ...choice, img: defeated[`${choice.id}Icon`], - description: `DAGGERHEART.CONFIG.Condition.${choice.id}.description` + description: game.i18n.localize(`DAGGERHEART.CONFIG.Condition.${choice.id}.description`) }; return acc; @@ -179,6 +179,10 @@ export const defeatedConditions = () => { }; export const defeatedConditionChoices = { + deathMove: { + id: 'deathMove', + name: 'DAGGERHEART.CONFIG.Condition.deathMove.name' + }, defeated: { id: 'defeated', name: 'DAGGERHEART.CONFIG.Condition.defeated.name' diff --git a/module/data/_module.mjs b/module/data/_module.mjs index 7ad20808..f7e25a4e 100644 --- a/module/data/_module.mjs +++ b/module/data/_module.mjs @@ -1,6 +1,7 @@ export { default as DhCombat } from './combat.mjs'; export { default as DhCombatant } from './combatant.mjs'; export { default as DhTagTeamRoll } from './tagTeamRoll.mjs'; +export { default as DhRollTable } from './rollTable.mjs'; export { default as RegisteredTriggers } from './registeredTriggers.mjs'; export * as countdowns from './countdowns.mjs'; diff --git a/module/data/action/baseAction.mjs b/module/data/action/baseAction.mjs index c403d4a9..115e6463 100644 --- a/module/data/action/baseAction.mjs +++ b/module/data/action/baseAction.mjs @@ -377,14 +377,14 @@ export class ResourceUpdateMap extends Map { if (!resource.key) continue; const existing = this.get(resource.key); - if (existing) { + if (!existing || resource.clear) { + this.set(resource.key, resource); + } else if (!existing?.clear) { this.set(resource.key, { ...existing, value: existing.value + (resource.value ?? 0), total: existing.total + (resource.total ?? 0) }); - } else { - this.set(resource.key, resource); } } } diff --git a/module/data/actor/base.mjs b/module/data/actor/base.mjs index b90361e2..08308eab 100644 --- a/module/data/actor/base.mjs +++ b/module/data/actor/base.mjs @@ -27,7 +27,7 @@ const resistanceField = (resistanceLabel, immunityLabel, reductionLabel) => }); /* Common rules applying to Characters and Adversaries */ -export const commonActorRules = (extendedData = { damageReduction: {} }) => ({ +export const commonActorRules = (extendedData = { damageReduction: {}, attack: { damage: {} } }) => ({ conditionImmunities: new fields.SchemaField({ hidden: new fields.BooleanField({ initial: false }), restrained: new fields.BooleanField({ initial: false }), @@ -41,7 +41,23 @@ export const commonActorRules = (extendedData = { damageReduction: {} }) => ({ magical: new fields.NumberField({ initial: 0, min: 0 }), physical: new fields.NumberField({ initial: 0, min: 0 }) }), - ...extendedData.damageReduction + ...(extendedData.damageReduction ?? {}) + }), + attack: new fields.SchemaField({ + ...extendedData.attack, + damage: new fields.SchemaField({ + hpDamageMultiplier: new fields.NumberField({ + required: true, + nullable: false, + initial: 1 + }), + hpDamageTakenMultiplier: new fields.NumberField({ + required: true, + nullable: false, + initial: 1 + }), + ...(extendedData.attack?.damage ?? {}) + }) }) }); diff --git a/module/data/actor/character.mjs b/module/data/actor/character.mjs index f6ab7e3a..e8da2e10 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -35,7 +35,14 @@ export default class DhCharacter extends BaseDataActor { 'DAGGERHEART.ACTORS.Character.maxHPBonus' ), stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true), - hope: resourceField(6, 2, 'DAGGERHEART.GENERAL.hope') + hope: new fields.SchemaField({ + value: new fields.NumberField({ + initial: 2, + min: 0, + integer: true, + label: 'DAGGERHEART.GENERAL.hope' + }) + }) }), traits: new fields.SchemaField({ agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'), @@ -78,12 +85,7 @@ export default class DhCharacter extends BaseDataActor { bags: new fields.NumberField({ initial: 0, integer: true }), chests: new fields.NumberField({ initial: 0, integer: true }) }), - scars: new fields.TypedObjectField( - new fields.SchemaField({ - name: new fields.StringField({}), - description: new fields.StringField() - }) - ), + scars: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.scars' }), biography: new fields.SchemaField({ background: new fields.HTMLField(), connections: new fields.HTMLField(), @@ -251,35 +253,35 @@ export default class DhCharacter extends BaseDataActor { hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.hint' }), disabledArmor: new fields.BooleanField({ intial: false }) + }, + attack: { + damage: { + diceIndex: new fields.NumberField({ + integer: true, + min: 0, + max: 5, + initial: 0, + label: 'DAGGERHEART.GENERAL.Rules.attack.damage.dice.label', + hint: 'DAGGERHEART.GENERAL.Rules.attack.damage.dice.hint' + }), + bonus: new fields.NumberField({ + required: true, + initial: 0, + min: 0, + label: 'DAGGERHEART.GENERAL.Rules.attack.damage.bonus.label' + }) + }, + roll: new fields.SchemaField({ + trait: new fields.StringField({ + required: true, + choices: CONFIG.DH.ACTOR.abilities, + nullable: true, + initial: null, + label: 'DAGGERHEART.GENERAL.Rules.attack.roll.trait.label' + }) + }) } }), - attack: new fields.SchemaField({ - damage: new fields.SchemaField({ - diceIndex: new fields.NumberField({ - integer: true, - min: 0, - max: 5, - initial: 0, - label: 'DAGGERHEART.GENERAL.Rules.attack.damage.dice.label', - hint: 'DAGGERHEART.GENERAL.Rules.attack.damage.dice.hint' - }), - bonus: new fields.NumberField({ - required: true, - initial: 0, - min: 0, - label: 'DAGGERHEART.GENERAL.Rules.attack.damage.bonus.label' - }) - }), - roll: new fields.SchemaField({ - trait: new fields.StringField({ - required: true, - choices: CONFIG.DH.ACTOR.abilities, - nullable: true, - initial: null, - label: 'DAGGERHEART.GENERAL.Rules.attack.roll.trait.label' - }) - }) - }), dualityRoll: new fields.SchemaField({ defaultHopeDice: new fields.NumberField({ nullable: false, @@ -301,6 +303,9 @@ export default class DhCharacter extends BaseDataActor { runeWard: new fields.BooleanField({ initial: false }), burden: new fields.SchemaField({ ignore: new fields.BooleanField() + }), + roll: new fields.SchemaField({ + guaranteedCritical: new fields.BooleanField() }) }) }; @@ -363,7 +368,7 @@ export default class DhCharacter extends BaseDataActor { const modifiers = subClasses ?.map(sc => ({ ...this.traits[sc.system.spellcastingTrait], key: sc.system.spellcastingTrait })) .filter(x => x); - return modifiers.sort((a, b) => a.value - b.value)[0]; + return modifiers.sort((a, b) => (b.value ?? 0) - (a.value ?? 0))[0]; } get spellcastModifier() { @@ -544,7 +549,18 @@ export default class DhCharacter extends BaseDataActor { } get deathMoveViable() { - return this.resources.hitPoints.max > 0 && this.resources.hitPoints.value >= this.resources.hitPoints.max; + const { characterDefault } = game.settings.get( + CONFIG.DH.id, + CONFIG.DH.SETTINGS.gameSettings.Automation + ).defeated; + const deathMoveOutcomeStatuses = Object.keys(CONFIG.DH.GENERAL.defeatedConditionChoices).filter( + key => key !== characterDefault + ); + const deathMoveNotResolved = this.parent.statuses.every(status => !deathMoveOutcomeStatuses.includes(status)); + + const allHitPointsMarked = + this.resources.hitPoints.max > 0 && this.resources.hitPoints.value >= this.resources.hitPoints.max; + return deathMoveNotResolved && allHitPointsMarked; } get armorApplicableDamageTypes() { @@ -642,8 +658,15 @@ export default class DhCharacter extends BaseDataActor { ? armor.system.baseThresholds.severe + this.levelData.level.current : this.levelData.level.current * 2 }; - this.resources.hope.max -= Object.keys(this.scars).length; + + const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope; + this.resources.hope.max = globalHopeMax - this.scars; this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0; + + /* Companion Related Data */ + this.companionData = { + levelupChoices: this.levelData.level.current - 1 + }; } prepareDerivedData() { @@ -659,6 +682,8 @@ export default class DhCharacter extends BaseDataActor { } } } + + this.companion.system.attack.roll.bonus = this.traits.instinct.value; } this.resources.hope.value = Math.min(baseHope, this.resources.hope.max); @@ -699,6 +724,30 @@ export default class DhCharacter extends BaseDataActor { changes.system.experiences[experience].core = true; } } + + /* Scars can alter the amount of current hope */ + if (changes.system?.scars) { + const diff = this.system.scars - changes.system.scars; + const newHopeMax = this.system.resources.hope.max + diff; + const newHopeValue = Math.min(newHopeMax, this.system.resources.hope.value); + if (newHopeValue != this.system.resources.hope.value) { + if (!changes.system.resources) changes.system.resources = { hope: { value: 0 } }; + changes.system.resources.hope = { + ...changes.system.resources.hope, + value: changes.system.resources.hope.value + newHopeValue + }; + } + } + + /* Force companion data prep */ + if (this.companion) { + if ( + changes.system?.levelData?.level?.current !== undefined && + changes.system.levelData.level.current !== this._source.levelData.level.current + ) { + this.companion.update(this.companion.toObject(), { diff: false, recursive: false }); + } + } } async _preDelete() { @@ -714,4 +763,11 @@ export default class DhCharacter extends BaseDataActor { t => !!t ); } + + static migrateData(source) { + if (typeof source.scars === 'object') source.scars = 0; + if (source.resources?.hope?.max) source.scars = Math.max(6 - source.resources.hope.max, 0); + + return super.migrateData(source); + } } diff --git a/module/data/actor/companion.mjs b/module/data/actor/companion.mjs index fa1965bd..a2cb5db3 100644 --- a/module/data/actor/companion.mjs +++ b/module/data/actor/companion.mjs @@ -108,7 +108,11 @@ export default class DhCompanion extends BaseDataActor { get proficiency() { return this.partner?.system?.proficiency ?? 1; } - + + get canLevelUp() { + return this.levelupChoicesLeft > 0; + } + isItemValid() { return false; } @@ -147,6 +151,17 @@ export default class DhCompanion extends BaseDataActor { } } + prepareDerivedData() { + /* Partner Related Setup */ + if (this.partner) { + this.levelData.level.changed = this.partner.system.levelData.level.current; + this.levelupChoicesLeft = Object.values(this.levelData.levelups).reduce((acc, curr) => { + acc = Math.max(acc - curr.selections.length, 0); + return acc; + }, this.partner.system.companionData.levelupChoices); + } + } + async _preUpdate(changes, options, userId) { const allowed = await super._preUpdate(changes, options, userId); if (allowed === false) return; @@ -162,6 +177,16 @@ export default class DhCompanion extends BaseDataActor { changes.system.experiences[experience].core = true; } } + + /* Force partner data prep */ + if (this.partner) { + if ( + changes.system?.levelData?.level?.current !== undefined && + changes.system.levelData.level.current !== this._source.levelData.level.current + ) { + this.partner.update(this.partner.toObject(), { diff: false, recursive: false }); + } + } } async _preDelete() { diff --git a/module/data/chat-message/_modules.mjs b/module/data/chat-message/_modules.mjs index ec095aac..c671de31 100644 --- a/module/data/chat-message/_modules.mjs +++ b/module/data/chat-message/_modules.mjs @@ -8,6 +8,7 @@ export const config = { adversaryRoll: DHActorRoll, damageRoll: DHActorRoll, dualityRoll: DHActorRoll, + fateRoll: DHActorRoll, groupRoll: DHGroupRoll, systemMessage: DHSystemMessage }; diff --git a/module/data/companionLevelup.mjs b/module/data/companionLevelup.mjs new file mode 100644 index 00000000..7ab61210 --- /dev/null +++ b/module/data/companionLevelup.mjs @@ -0,0 +1,370 @@ +import { abilities } from '../config/actorConfig.mjs'; +import { chunkify } from '../helpers/utils.mjs'; +import { LevelOptionType } from './levelTier.mjs'; + +export class DhCompanionLevelup extends foundry.abstract.DataModel { + static initializeData(levelTierData, pcLevelData, origChoicesLeft) { + let choicesLeft = origChoicesLeft; + + const { current, changed } = pcLevelData.level; + const bonusChoicesOnly = current === changed; + const startLevel = bonusChoicesOnly ? current : current + 1; + const endLevel = bonusChoicesOnly ? startLevel : changed; + + const tiers = {}; + const levels = {}; + const tierKeys = Object.keys(levelTierData.tiers); + tierKeys.forEach(key => { + const tier = levelTierData.tiers[key]; + const belongingLevels = []; + for (var i = tier.levels.start; i <= tier.levels.end; i++) { + if (i <= endLevel) { + const initialAchievements = i === tier.levels.start ? tier.initialAchievements : {}; + const experiences = initialAchievements.experience + ? [...Array(initialAchievements.experience.nr).keys()].reduce((acc, _) => { + acc[foundry.utils.randomID()] = { + name: '', + modifier: initialAchievements.experience.modifier + }; + return acc; + }, {}) + : {}; + + const currentChoices = pcLevelData.levelups[i]?.selections?.length; + const maxSelections = + i === endLevel + ? choicesLeft + (currentChoices ?? 0) + : (currentChoices ?? tier.maxSelections[i]); + if (!pcLevelData.levelups[i]) choicesLeft -= maxSelections; + + levels[i] = DhLevelupLevel.initializeData(pcLevelData.levelups[i], maxSelections, { + ...initialAchievements, + experiences, + domainCards: {} + }); + } + + belongingLevels.push(i); + } + + /* Improve. Temporary handling for Companion new experiences */ + Object.keys(tier.extraAchievements ?? {}).forEach(key => { + const level = Number(key); + if (level >= startLevel && level <= endLevel) { + const levelExtras = tier.extraAchievements[level]; + if (levelExtras.experience) { + levels[level].achievements.experiences[foundry.utils.randomID()] = { + name: '', + modifier: levelExtras.experience.modifier + }; + } + } + }); + + tiers[key] = { + name: tier.name, + belongingLevels: belongingLevels, + options: Object.keys(tier.options).reduce((acc, key) => { + acc[key] = tier.options[key].toObject?.() ?? tier.options[key]; + return acc; + }, {}) + }; + }); + + return { + tiers, + levels, + startLevel, + currentLevel: startLevel, + endLevel + }; + } + + static defineSchema() { + const fields = foundry.data.fields; + + return { + tiers: new fields.TypedObjectField( + new fields.SchemaField({ + name: new fields.StringField({ required: true }), + belongingLevels: new fields.ArrayField(new fields.NumberField({ required: true, integer: true })), + options: new fields.TypedObjectField( + new fields.SchemaField({ + label: new fields.StringField({ required: true }), + checkboxSelections: new fields.NumberField({ required: true, integer: true }), + minCost: new fields.NumberField({ required: true, integer: true }), + type: new fields.StringField({ required: true, choices: LevelOptionType }), + value: new fields.NumberField({ integer: true }), + amount: new fields.NumberField({ integer: true }) + }) + ) + }) + ), + levels: new fields.TypedObjectField(new fields.EmbeddedDataField(DhLevelupLevel)), + startLevel: new fields.NumberField({ required: true, integer: true }), + currentLevel: new fields.NumberField({ required: true, integer: true }), + endLevel: new fields.NumberField({ required: true, integer: true }) + }; + } + + #levelFinished(levelKey) { + const allSelectionsMade = this.levels[levelKey].nrSelections.available === 0; + const allChoicesMade = Object.keys(this.levels[levelKey].choices).every(choiceKey => { + const choice = this.levels[levelKey].choices[choiceKey]; + return Object.values(choice).every(checkbox => { + switch (choiceKey) { + case 'trait': + case 'experience': + case 'domainCard': + case 'subclass': + case 'vicious': + return checkbox.data.length === (checkbox.amount ?? 1); + case 'multiclass': + const classSelected = checkbox.data.length === 1; + const domainSelected = checkbox.secondaryData.domain; + const subclassSelected = checkbox.secondaryData.subclass; + return classSelected && domainSelected && subclassSelected; + default: + return true; + } + }); + }); + const experiencesSelected = !this.levels[levelKey].achievements.experiences + ? true + : Object.values(this.levels[levelKey].achievements.experiences).every(exp => exp.name); + const domainCardsSelected = Object.values(this.levels[levelKey].achievements.domainCards) + .filter(x => x.level <= this.endLevel) + .every(card => card.uuid); + const allAchievementsSelected = experiencesSelected && domainCardsSelected; + + return allSelectionsMade && allChoicesMade && allAchievementsSelected; + } + + get currentLevelFinished() { + return this.#levelFinished(this.currentLevel); + } + + get allLevelsFinished() { + return Object.keys(this.levels) + .filter(level => Number(level) >= this.startLevel) + .every(this.#levelFinished.bind(this)); + } + + get unmarkedTraits() { + const possibleLevels = Object.values(this.tiers).reduce((acc, tier) => { + if (tier.belongingLevels.includes(this.currentLevel)) acc = tier.belongingLevels; + return acc; + }, []); + + return Object.keys(this.levels) + .filter(key => possibleLevels.some(x => x === Number(key))) + .reduce( + (acc, levelKey) => { + const level = this.levels[levelKey]; + Object.values(level.choices).forEach(choice => + Object.values(choice).forEach(checkbox => { + if ( + checkbox.type === 'trait' && + checkbox.data.length > 0 && + Number(levelKey) !== this.currentLevel + ) { + checkbox.data.forEach(data => delete acc[data]); + } + }) + ); + + return acc; + }, + { ...abilities } + ); + } + + get classUpgradeChoices() { + let subclasses = []; + let multiclass = null; + Object.keys(this.levels).forEach(levelKey => { + const level = this.levels[levelKey]; + Object.values(level.choices).forEach(choice => { + Object.values(choice).forEach(checkbox => { + if (checkbox.type === 'multiclass') { + multiclass = { + class: checkbox.data.length > 0 ? checkbox.data[0] : null, + domain: checkbox.secondaryData.domain ?? null, + subclass: checkbox.secondaryData.subclass ?? null, + tier: checkbox.tier, + level: levelKey + }; + } + if (checkbox.type === 'subclass') { + subclasses.push({ + tier: checkbox.tier, + level: levelKey + }); + } + }); + }); + }); + return { subclasses, multiclass }; + } + + get tiersForRendering() { + const tierKeys = Object.keys(this.tiers); + const selections = Object.keys(this.levels).reduce( + (acc, key) => { + const level = this.levels[key]; + Object.keys(level.choices).forEach(optionKey => { + const choice = level.choices[optionKey]; + Object.keys(choice).forEach(checkboxNr => { + const checkbox = choice[checkboxNr]; + if (!acc[checkbox.tier][optionKey]) acc[checkbox.tier][optionKey] = {}; + Object.keys(choice).forEach(checkboxNr => { + acc[checkbox.tier][optionKey][checkboxNr] = { ...checkbox, level: Number(key) }; + }); + }); + }); + + return acc; + }, + tierKeys.reduce((acc, key) => { + acc[key] = {}; + return acc; + }, {}) + ); + + const { multiclass, subclasses } = this.classUpgradeChoices; + return tierKeys.map((tierKey, tierIndex) => { + const tier = this.tiers[tierKey]; + const multiclassInTier = multiclass?.tier === Number(tierKey); + const subclassInTier = subclasses.some(x => x.tier === Number(tierKey)); + + return { + name: game.i18n.localize(tier.name), + active: this.currentLevel >= Math.min(...tier.belongingLevels), + groups: Object.keys(tier.options).map(optionKey => { + const option = tier.options[optionKey]; + + const checkboxes = [...Array(option.checkboxSelections).keys()].flatMap(index => { + const checkboxNr = index + 1; + const checkboxData = selections[tierKey]?.[optionKey]?.[checkboxNr]; + const checkbox = { ...option, checkboxNr, tier: tierKey }; + + if (checkboxData) { + checkbox.level = checkboxData.level; + checkbox.selected = true; + checkbox.disabled = checkbox.level !== this.currentLevel; + } + + if (optionKey === 'multiclass') { + if ((multiclass && !multiclassInTier) || subclassInTier) { + checkbox.disabled = true; + } + } + + if (optionKey === 'subclass' && multiclassInTier) { + checkbox.disabled = true; + } + + return checkbox; + }); + + let label = game.i18n.localize(option.label); + if (optionKey === 'domainCard') { + const maxLevel = tier.belongingLevels[tier.belongingLevels.length - 1]; + label = game.i18n.format(option.label, { maxLevel }); + } + + return { + label: label, + checkboxGroups: chunkify(checkboxes, option.minCost, chunkedBoxes => { + const anySelected = chunkedBoxes.some(x => x.selected); + const anyDisabled = chunkedBoxes.some(x => x.disabled); + return { + multi: option.minCost > 1, + checkboxes: chunkedBoxes.map(x => ({ + ...x, + selected: anySelected, + disabled: anyDisabled + })) + }; + }) + }; + }) + }; + }); + } +} + +export class DhLevelupLevel extends foundry.abstract.DataModel { + static initializeData(levelData = { selections: [] }, maxSelections, achievements) { + return { + maxSelections: maxSelections, + achievements: { + experiences: levelData.achievements?.experiences ?? achievements.experiences ?? {}, + domainCards: levelData.achievements?.domainCards + ? levelData.achievements.domainCards.reduce((acc, card, index) => { + acc[index] = { ...card }; + return acc; + }, {}) + : (achievements.domainCards ?? {}), + proficiency: levelData.achievements?.proficiency ?? achievements.proficiency ?? null + }, + choices: levelData.selections.reduce((acc, data) => { + if (!acc[data.optionKey]) acc[data.optionKey] = {}; + acc[data.optionKey][data.checkboxNr] = { ...data }; + + return acc; + }, {}) + }; + } + + static defineSchema() { + const fields = foundry.data.fields; + + return { + maxSelections: new fields.NumberField({ required: true, integer: true }), + achievements: new fields.SchemaField({ + experiences: new fields.TypedObjectField( + new fields.SchemaField({ + name: new fields.StringField({ required: true }), + modifier: new fields.NumberField({ required: true, integer: true }) + }) + ), + domainCards: new fields.TypedObjectField( + new fields.SchemaField({ + uuid: new fields.StringField({ required: true, nullable: true, initial: null }), + itemUuid: new fields.StringField({ required: true }), + level: new fields.NumberField({ required: true, integer: true }) + }) + ), + proficiency: new fields.NumberField({ integer: true }) + }), + choices: new fields.TypedObjectField( + new fields.TypedObjectField( + new fields.SchemaField({ + tier: new fields.NumberField({ required: true, integer: true }), + minCost: new fields.NumberField({ required: true, integer: true }), + amount: new fields.NumberField({ integer: true }), + value: new fields.StringField(), + data: new fields.ArrayField(new fields.StringField()), + secondaryData: new fields.TypedObjectField(new fields.StringField()), + type: new fields.StringField({ required: true }) + }) + ) + ) + }; + } + + get nrSelections() { + const selections = Object.keys(this.choices).reduce((acc, choiceKey) => { + const choice = this.choices[choiceKey]; + acc += Object.values(choice).reduce((acc, x) => acc + x.minCost, 0); + + return acc; + }, 0); + + return { + selections: selections, + available: this.maxSelections - selections + }; + } +} diff --git a/module/data/fields/action/damageField.mjs b/module/data/fields/action/damageField.mjs index bb81c702..ef91c64e 100644 --- a/module/data/fields/action/damageField.mjs +++ b/module/data/fields/action/damageField.mjs @@ -105,12 +105,22 @@ export default class DamageField extends fields.SchemaField { damagePromises.push( actor.takeHealing(config.damage).then(updates => targetDamage.push({ token, updates })) ); - else + else { + const configDamage = foundry.utils.deepClone(config.damage); + const hpDamageMultiplier = config.actionActor?.system.rules.attack.damage.hpDamageMultiplier ?? 1; + const hpDamageTakenMultiplier = actor.system.rules.attack.damage.hpDamageTakenMultiplier; + if (configDamage.hitPoints) { + for (const part of configDamage.hitPoints.parts) { + part.total = Math.ceil(part.total * hpDamageMultiplier * hpDamageTakenMultiplier); + } + } + damagePromises.push( actor - .takeDamage(config.damage, config.isDirect) + .takeDamage(configDamage, config.isDirect) .then(updates => targetDamage.push({ token, updates })) ); + } } Promise.all(damagePromises).then(async _ => { diff --git a/module/data/item/base.mjs b/module/data/item/base.mjs index 2399b7db..84f39103 100644 --- a/module/data/item/base.mjs +++ b/module/data/item/base.mjs @@ -147,7 +147,7 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel { return await foundry.applications.ux.TextEditor.implementation.enrichHTML(fullDescription, { relativeTo: this, rollData: this.getRollData(), - secrets: this.isOwner + secrets: this.parent.isOwner }); } diff --git a/module/data/registeredTriggers.mjs b/module/data/registeredTriggers.mjs index 8a100585..ee4f3b49 100644 --- a/module/data/registeredTriggers.mjs +++ b/module/data/registeredTriggers.mjs @@ -20,6 +20,7 @@ export default class RegisteredTriggers extends Map { } registerItemTriggers(item, registerOverride) { + if (!item.actor || !item._stats.createdTime) return; for (const action of item.system.actions ?? []) { if (!action.actor) continue; @@ -71,10 +72,21 @@ export default class RegisteredTriggers extends Map { } } + unregisterSceneEnvironmentTriggers(flagSystemData) { + const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData); + for (const environment of sceneData.sceneEnvironments) { + if (environment.pack) continue; + this.unregisterItemTriggers(environment.system.features); + } + } + unregisterSceneTriggers(scene) { + this.unregisterSceneEnvironmentTriggers(scene.flags.daggerheart); + for (const triggerKey of Object.keys(CONFIG.DH.TRIGGER.triggers)) { const existingTrigger = this.get(triggerKey); if (!existingTrigger) continue; + const filtered = new Map(); for (const [uuid, data] of existingTrigger.entries()) { if (!uuid.startsWith(scene.uuid)) filtered.set(uuid, data); @@ -83,14 +95,17 @@ export default class RegisteredTriggers extends Map { } } + registerSceneEnvironmentTriggers(flagSystemData) { + const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData); + for (const environment of sceneData.sceneEnvironments) { + for (const feature of environment.system.features) { + if (feature) this.registerItemTriggers(feature, true); + } + } + } + registerSceneTriggers(scene) { - /* TODO: Finish sceneEnvironment registration and unreg */ - // const systemData = new game.system.api.data.scenes.DHScene(scene.flags.daggerheart); - // for (const environment of systemData.sceneEnvironments) { - // for (const feature of environment.system.features) { - // if(feature) this.registerItemTriggers(feature, true); - // } - // } + this.registerSceneEnvironmentTriggers(scene.flags.daggerheart); for (const actor of scene.tokens.filter(x => x.actor).map(x => x.actor)) { if (actor.prototypeToken.actorLink) continue; @@ -107,13 +122,11 @@ export default class RegisteredTriggers extends Map { if (!triggerSettings.enabled) return updates; const dualityTrigger = this.get(trigger); - if (dualityTrigger) { - const tokenBoundActors = ['adversary', 'environment']; - const triggerActors = ['character', ...tokenBoundActors]; + if (dualityTrigger?.size) { + const triggerActors = ['character', 'adversary', 'environment']; for (let [itemUuid, { actor: actorUuid, triggeringActorType, commands }] of dualityTrigger.entries()) { const actor = await foundry.utils.fromUuid(actorUuid); if (!actor || !triggerActors.includes(actor.type)) continue; - if (tokenBoundActors.includes(actor.type) && !actor.getActiveTokens().length) continue; const triggerData = CONFIG.DH.TRIGGER.triggers[trigger]; if (triggerData.usesActor && triggeringActorType !== 'any') { diff --git a/module/data/rollTable.mjs b/module/data/rollTable.mjs new file mode 100644 index 00000000..78f7e6dd --- /dev/null +++ b/module/data/rollTable.mjs @@ -0,0 +1,38 @@ +import FormulaField from './fields/formulaField.mjs'; + +//Extra definitions for RollTable +export default class DhRollTable extends foundry.abstract.TypeDataModel { + static defineSchema() { + const fields = foundry.data.fields; + + return { + formulaName: new fields.StringField({ + required: true, + nullable: false, + initial: 'Roll Formula', + label: 'DAGGERHEART.ROLLTABLES.FIELDS.formulaName.label' + }), + altFormula: new fields.TypedObjectField( + new fields.SchemaField({ + name: new fields.StringField({ + required: true, + nullable: false, + initial: 'Roll Formula', + label: 'DAGGERHEART.ROLLTABLES.FIELDS.formulaName.label' + }), + formula: new FormulaField({ label: 'Formula Roll', initial: '1d20' }) + }) + ), + activeAltFormula: new fields.StringField({ nullable: true, initial: null }) + }; + } + + getActiveFormula(baseFormula) { + return this.activeAltFormula ? (this.altFormula[this.activeAltFormula]?.formula ?? baseFormula) : baseFormula; + } + + static getDefaultFormula = () => ({ + name: game.i18n.localize('Roll Formula'), + formula: '1d20' + }); +} diff --git a/module/data/settings/Automation.mjs b/module/data/settings/Automation.mjs index 3376b153..436f0eb7 100644 --- a/module/data/settings/Automation.mjs +++ b/module/data/settings/Automation.mjs @@ -55,15 +55,10 @@ export default class DhAutomation extends foundry.abstract.DataModel { initial: true, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.resourceScrollTexts.label' }), - playerCanEditSheet: new fields.BooleanField({ - required: true, - initial: false, - label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.playerCanEditSheet.label' - }), defeated: new fields.SchemaField({ enabled: new fields.BooleanField({ required: true, - initial: false, + initial: true, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.enabled.label' }), overlay: new fields.BooleanField({ @@ -74,7 +69,7 @@ export default class DhAutomation extends foundry.abstract.DataModel { characterDefault: new fields.StringField({ required: true, choices: CONFIG.DH.GENERAL.defeatedConditionChoices, - initial: CONFIG.DH.GENERAL.defeatedConditionChoices.unconscious.id, + initial: CONFIG.DH.GENERAL.defeatedConditionChoices.deathMove.id, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.characterDefault.label' }), adversaryDefault: new fields.StringField({ @@ -89,23 +84,29 @@ export default class DhAutomation extends foundry.abstract.DataModel { initial: CONFIG.DH.GENERAL.defeatedConditionChoices.defeated.id, label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.companionDefault.label' }), + deathMoveIcon: new fields.FilePathField({ + initial: 'icons/magic/life/heart-cross-purple-orange.webp', + categories: ['IMAGE'], + base64: false, + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.deathMove.label' + }), deadIcon: new fields.FilePathField({ initial: 'icons/magic/death/grave-tombstone-glow-teal.webp', categories: ['IMAGE'], base64: false, - label: 'Dead' + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.dead.label' }), defeatedIcon: new fields.FilePathField({ initial: 'icons/magic/control/fear-fright-mask-orange.webp', categories: ['IMAGE'], base64: false, - label: 'Defeated' + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.defeated.label' }), unconsciousIcon: new fields.FilePathField({ initial: 'icons/magic/control/sleep-bubble-purple.webp', categories: ['IMAGE'], base64: false, - label: 'Unconcious' + label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.defeated.unconscious.label' }) }), roll: new fields.SchemaField({ diff --git a/module/data/settings/Homebrew.mjs b/module/data/settings/Homebrew.mjs index 7572c9ea..0138713c 100644 --- a/module/data/settings/Homebrew.mjs +++ b/module/data/settings/Homebrew.mjs @@ -23,6 +23,13 @@ export default class DhHomebrew extends foundry.abstract.DataModel { initial: 12, label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxFear.label' }), + maxHope: new fields.NumberField({ + required: true, + integer: true, + min: 0, + initial: 6, + label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label' + }), maxLoadout: new fields.NumberField({ required: true, integer: true, diff --git a/module/dice/_module.mjs b/module/dice/_module.mjs index e6755a74..b9339d87 100644 --- a/module/dice/_module.mjs +++ b/module/dice/_module.mjs @@ -3,3 +3,4 @@ export { default as D20Roll } from './d20Roll.mjs'; export { default as DamageRoll } from './damageRoll.mjs'; export { default as DHRoll } from './dhRoll.mjs'; export { default as DualityRoll } from './dualityRoll.mjs'; +export { default as FateRoll } from './fateRoll.mjs'; diff --git a/module/dice/d20Roll.mjs b/module/dice/d20Roll.mjs index 3ddd8027..f117ff65 100644 --- a/module/dice/d20Roll.mjs +++ b/module/dice/d20Roll.mjs @@ -99,11 +99,14 @@ export default class D20Roll extends DHRoll { this.options.roll.modifiers = this.applyBaseBonus(); + const actorExperiences = this.options.roll.companionRoll + ? (this.options.data?.companion?.system.experiences ?? {}) + : (this.options.data.system?.experiences ?? {}); this.options.experiences?.forEach(m => { - if (this.options.data.system?.experiences?.[m]) + if (actorExperiences[m]) this.options.roll.modifiers.push({ - label: this.options.data.system.experiences[m].name, - value: this.options.data.system.experiences[m].value + label: actorExperiences[m].name, + value: actorExperiences[m].value }); }); diff --git a/module/dice/dualityRoll.mjs b/module/dice/dualityRoll.mjs index aaca7400..e65d0ff5 100644 --- a/module/dice/dualityRoll.mjs +++ b/module/dice/dualityRoll.mjs @@ -12,6 +12,7 @@ export default class DualityRoll extends D20Roll { constructor(formula, data = {}, options = {}) { super(formula, data, options); this.rallyChoices = this.setRallyChoices(); + this.guaranteedCritical = options.guaranteedCritical; } static messageType = 'dualityRoll'; @@ -25,29 +26,23 @@ export default class DualityRoll extends D20Roll { } get dHope() { - // if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return; if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); return this.dice[0]; - // return this.#hopeDice; } set dHope(faces) { if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); - this.terms[0].faces = this.getFaces(faces); - // this.#hopeDice = `d${face}`; + this.dice[0].faces = this.getFaces(faces); } get dFear() { - // if ( !(this.terms[1] instanceof foundry.dice.terms.Die) ) return; if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice(); return this.dice[1]; - // return this.#fearDice; } set dFear(faces) { if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice(); this.dice[1].faces = this.getFaces(faces); - // this.#fearDice = `d${face}`; } get dAdvantage() { @@ -90,26 +85,29 @@ export default class DualityRoll extends D20Roll { } get isCritical() { + if (this.guaranteedCritical) return true; if (!this.dHope._evaluated || !this.dFear._evaluated) return; return this.dHope.total === this.dFear.total; } get withHope() { - if (!this._evaluated) return; + if (!this._evaluated || this.guaranteedCritical) return; return this.dHope.total > this.dFear.total; } get withFear() { - if (!this._evaluated) return; + if (!this._evaluated || this.guaranteedCritical) return; return this.dHope.total < this.dFear.total; } get totalLabel() { - const label = this.withHope - ? 'DAGGERHEART.GENERAL.hope' - : this.withFear - ? 'DAGGERHEART.GENERAL.fear' - : 'DAGGERHEART.GENERAL.criticalSuccess'; + const label = this.guaranteedCritical + ? 'DAGGERHEART.GENERAL.guaranteedCriticalSuccess' + : this.isCritical + ? 'DAGGERHEART.GENERAL.criticalSuccess' + : this.withHope + ? 'DAGGERHEART.GENERAL.hope' + : 'DAGGERHEART.GENERAL.fear'; return game.i18n.localize(label); } @@ -178,6 +176,21 @@ export default class DualityRoll extends D20Roll { return modifiers; } + static async buildConfigure(config = {}, message = {}) { + config.dialog ??= {}; + config.guaranteedCritical = config.data?.parent?.appliedEffects.reduce((a, c) => { + const change = c.changes.find(ch => ch.key === 'system.rules.roll.guaranteedCritical'); + if (change) a = true; + return a; + }, false); + + if (config.guaranteedCritical) { + config.dialog.configure = false; + } + + return super.buildConfigure(config, message); + } + getActionChangeKeys() { const changeKeys = new Set([`system.bonuses.roll.${this.options.actionType}`]); @@ -223,7 +236,7 @@ export default class DualityRoll extends D20Roll { data.hope = { dice: roll.dHope.denomination, - value: roll.dHope.total, + value: this.guaranteedCritical ? 0 : roll.dHope.total, rerolled: { any: roll.dHope.results.some(x => x.rerolled), rerolls: roll.dHope.results.filter(x => x.rerolled) @@ -231,7 +244,7 @@ export default class DualityRoll extends D20Roll { }; data.fear = { dice: roll.dFear.denomination, - value: roll.dFear.total, + value: this.guaranteedCritical ? 0 : roll.dFear.total, rerolled: { any: roll.dFear.results.some(x => x.rerolled), rerolls: roll.dFear.results.filter(x => x.rerolled) @@ -243,7 +256,7 @@ export default class DualityRoll extends D20Roll { }; data.result = { duality: roll.withHope ? 1 : roll.withFear ? -1 : 0, - total: roll.dHope.total + roll.dFear.total, + total: this.guaranteedCritical ? 0 : roll.dHope.total + roll.dFear.total, label: roll.totalLabel }; @@ -261,7 +274,7 @@ export default class DualityRoll extends D20Roll { } static async handleTriggers(roll, config) { - if (!config.source?.actor) return; + if (!config.source?.actor || config.skips?.triggers) return; const updates = []; const dualityUpdates = await game.system.registeredTriggers.runTrigger( diff --git a/module/dice/fateRoll.mjs b/module/dice/fateRoll.mjs new file mode 100644 index 00000000..418c8465 --- /dev/null +++ b/module/dice/fateRoll.mjs @@ -0,0 +1,85 @@ +import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; +import D20Roll from './d20Roll.mjs'; +import { setDiceSoNiceForHopeFateRoll, setDiceSoNiceForFearFateRoll } from '../helpers/utils.mjs'; + +export default class FateRoll extends D20Roll { + constructor(formula, data = {}, options = {}) { + super(formula, data, options); + } + + static messageType = 'fateRoll'; + + static DefaultDialog = D20RollDialog; + + get title() { + return game.i18n.localize(`DAGGERHEART.GENERAL.fateRoll`); + } + + get dHope() { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + return this.dice[0]; + } + + set dHope(faces) { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + this.dice[0].faces = this.getFaces(faces); + } + + get dFear() { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + return this.dice[0]; + } + + set dFear(faces) { + if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice(); + this.dice[0].faces = this.getFaces(faces); + } + + get isCritical() { + return false; + } + + get fateDie() { + return this.data.fateType; + } + + static getHooks(hooks) { + return [...(hooks ?? []), 'Fate']; + } + + /** @inheritDoc */ + static fromData(data) { + data.terms[0].class = foundry.dice.terms.Die.name; + return super.fromData(data); + } + + createBaseDice() { + if (this.dice[0] instanceof foundry.dice.terms.Die) { + this.terms = [this.terms[0]]; + return; + } + this.terms[0] = new foundry.dice.terms.Die({ faces: 12 }); + } + + static async buildEvaluate(roll, config = {}, message = {}) { + await super.buildEvaluate(roll, config, message); + + if (roll.fateDie === 'Hope') { + await setDiceSoNiceForHopeFateRoll(roll, config.roll.fate.dice); + } else { + await setDiceSoNiceForFearFateRoll(roll, config.roll.fate.dice); + } + } + + static postEvaluate(roll, config = {}) { + const data = super.postEvaluate(roll, config); + + data.fate = { + dice: roll.fateDie === 'Hope' ? roll.dHope.denomination : roll.dFear.denomination, + value: roll.fateDie === 'Hope' ? roll.dHope.total : roll.dFear.total, + fateDie: roll.fateDie + }; + + return data; + } +} diff --git a/module/documents/_module.mjs b/module/documents/_module.mjs index 8073cfe1..b9cfd3f2 100644 --- a/module/documents/_module.mjs +++ b/module/documents/_module.mjs @@ -4,6 +4,7 @@ export { default as DhpCombat } from './combat.mjs'; export { default as DHCombatant } from './combatant.mjs'; export { default as DhActiveEffect } from './activeEffect.mjs'; export { default as DhChatMessage } from './chatMessage.mjs'; +export { default as DhRollTable } from './rollTable.mjs'; export { default as DhScene } from './scene.mjs'; export { default as DhToken } from './token.mjs'; export { default as DhTooltipManager } from './tooltipManager.mjs'; diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs index 6d943c54..e8bea0bf 100644 --- a/module/documents/actor.mjs +++ b/module/documents/actor.mjs @@ -241,6 +241,11 @@ export default class DhpActor extends Actor { } } }); + + if (this.system.companion) { + this.system.companion.updateLevel(usedLevel); + } + this.sheet.render(); } } @@ -764,16 +769,24 @@ export default class DhpActor extends Actor { }; } } else { + const valueFunc = (base, resource, baseMax) => { + if (resource.clear) return baseMax && base.inverted ? baseMax : 0; + + return (base.value ?? base) + resource.value; + }; switch (r.key) { case 'fear': ui.resources.updateFear( - game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear) + r.value + valueFunc( + game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear), + r + ) ); break; case 'armor': if (this.system.armor?.system?.marks) { updates.armor.resources['system.marks.value'] = Math.max( - Math.min(this.system.armor.system.marks.value + r.value, this.system.armorScore), + Math.min(valueFunc(this.system.armor.system.marks, r), this.system.armorScore), 0 ); } @@ -782,7 +795,7 @@ export default class DhpActor extends Actor { if (this.system.resources?.[r.key]) { updates.actor.resources[`system.resources.${r.key}.value`] = Math.max( Math.min( - this.system.resources[r.key].value + r.value, + valueFunc(this.system.resources[r.key], r, this.system.resources[r.key].max), this.system.resources[r.key].max ), 0 @@ -841,8 +854,8 @@ export default class DhpActor extends Actor { async toggleDefeated(defeatedState) { const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).defeated; - const { unconscious, defeated, dead } = CONFIG.DH.GENERAL.conditions(); - const defeatedConditions = new Set([unconscious.id, defeated.id, dead.id]); + const { deathMove, unconscious, defeated, dead } = CONFIG.DH.GENERAL.conditions(); + const defeatedConditions = new Set([deathMove.id, unconscious.id, defeated.id, dead.id]); if (!defeatedState) { for (let defeatedId of defeatedConditions) { await this.toggleStatusEffect(defeatedId, { overlay: settings.overlay, active: defeatedState }); @@ -856,6 +869,18 @@ export default class DhpActor extends Actor { } } + async setDeathMoveDefeated(defeatedIconId) { + const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).defeated; + const actorDefault = settings[`${this.type}Default`]; + if (!settings.enabled || !settings.enabled || !actorDefault || actorDefault === defeatedIconId) return; + + for (let defeatedId of Object.keys(CONFIG.DH.GENERAL.defeatedConditionChoices)) { + await this.toggleStatusEffect(defeatedId, { overlay: settings.overlay, active: false }); + } + + if (defeatedIconId) await this.toggleStatusEffect(defeatedIconId, { overlay: settings.overlay, active: true }); + } + queueScrollText(scrollingTextData) { this.#scrollTextQueue.push(...scrollingTextData.map(data => () => createScrollText(this, data))); if (!this.#scrollTextInterval) { diff --git a/module/documents/chatMessage.mjs b/module/documents/chatMessage.mjs index 4c6b0b93..1d2c6c41 100644 --- a/module/documents/chatMessage.mjs +++ b/module/documents/chatMessage.mjs @@ -87,6 +87,15 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage { break; } } + if (this.type === 'fateRoll') { + html.classList.add('fate'); + if (this.system.roll?.fate.fateDie == 'Hope') { + html.classList.add('hope'); + } + if (this.system.roll?.fate.fateDie == 'Fear') { + html.classList.add('fear'); + } + } const autoExpandRoll = game.settings.get( CONFIG.DH.id, diff --git a/module/documents/collections/_module.mjs b/module/documents/collections/_module.mjs new file mode 100644 index 00000000..a24c1d85 --- /dev/null +++ b/module/documents/collections/_module.mjs @@ -0,0 +1 @@ +export { default as DhActorCollection } from './actorCollection.mjs'; diff --git a/module/documents/collections/actorCollection.mjs b/module/documents/collections/actorCollection.mjs new file mode 100644 index 00000000..a3714b30 --- /dev/null +++ b/module/documents/collections/actorCollection.mjs @@ -0,0 +1,14 @@ +export default class DhActorCollection extends foundry.documents.collections.Actors { + /** Ensure companions are initialized after all other subtypes. */ + _initialize() { + super._initialize(); + const companions = []; + for (const actor of this.values()) { + if (actor.type === 'companion') companions.push(actor); + } + for (const actor of companions) { + this.delete(actor.id); + this.set(actor.id, actor); + } + } +} diff --git a/module/documents/rollTable.mjs b/module/documents/rollTable.mjs new file mode 100644 index 00000000..50b8fe63 --- /dev/null +++ b/module/documents/rollTable.mjs @@ -0,0 +1,122 @@ +export default class DhRollTable extends foundry.documents.RollTable { + async roll({ selectedFormula, roll, recursive = true, _depth = 0 } = {}) { + // Prevent excessive recursion + if (_depth > 5) { + throw new Error(`Maximum recursion depth exceeded when attempting to draw from RollTable ${this.id}`); + } + + const formula = selectedFormula ?? this.formula; + + // If there is no formula, automatically calculate an even distribution + if (!this.formula) { + await this.normalize(); + } + + // Reference the provided roll formula + roll = roll instanceof Roll ? roll : Roll.create(formula); + let results = []; + + // Ensure that at least one non-drawn result remains + const available = this.results.filter(r => !r.drawn); + if (!available.length) { + ui.notifications.warn(game.i18n.localize('TABLE.NoAvailableResults')); + return { roll, results }; + } + + // Ensure that results are available within the minimum/maximum range + const minRoll = (await roll.reroll({ minimize: true })).total; + const maxRoll = (await roll.reroll({ maximize: true })).total; + const availableRange = available.reduce( + (range, result) => { + const r = result.range; + if (!range[0] || r[0] < range[0]) range[0] = r[0]; + if (!range[1] || r[1] > range[1]) range[1] = r[1]; + return range; + }, + [null, null] + ); + if (availableRange[0] > maxRoll || availableRange[1] < minRoll) { + ui.notifications.warn('No results can possibly be drawn from this table and formula.'); + return { roll, results }; + } + + // Continue rolling until one or more results are recovered + let iter = 0; + while (!results.length) { + if (iter >= 10000) { + ui.notifications.error( + `Failed to draw an available entry from Table ${this.name}, maximum iteration reached` + ); + break; + } + roll = await roll.reroll(); + results = this.getResultsForRoll(roll.total); + iter++; + } + + // Draw results recursively from any inner Roll Tables + if (recursive) { + const inner = []; + for (const result of results) { + const { type, documentUuid } = result; + const documentName = foundry.utils.parseUuid(documentUuid)?.type; + if (type === 'document' && documentName === 'RollTable') { + const innerTable = await fromUuid(documentUuid); + if (innerTable) { + const innerRoll = await innerTable.roll({ _depth: _depth + 1 }); + inner.push(...innerRoll.results); + } + } else inner.push(result); + } + results = inner; + } + + // Return the Roll and the results + return { roll, results }; + } + + async toMessage(results, { roll, messageData = {}, messageOptions = {} } = {}) { + messageOptions.rollMode ??= game.settings.get('core', 'rollMode'); + + // Construct chat data + messageData = foundry.utils.mergeObject( + { + author: game.user.id, + speaker: foundry.documents.ChatMessage.implementation.getSpeaker(), + rolls: [], + sound: roll ? CONFIG.sounds.dice : null, + flags: { 'core.RollTable': this.id } + }, + messageData + ); + if (roll) messageData.rolls.push(roll); + + // Render the chat card which combines the dice roll with the drawn results + const detailsPromises = await Promise.allSettled(results.map(r => r.getHTML())); + const flavorKey = `TABLE.DrawFlavor${results.length > 1 ? 'Plural' : ''}`; + const flavor = game.i18n.format(flavorKey, { + number: results.length, + name: foundry.utils.escapeHTML(this.name) + }); + messageData.content = await foundry.applications.handlebars.renderTemplate(CONFIG.RollTable.resultTemplate, { + description: await TextEditor.implementation.enrichHTML(this.description, { + documents: true, + secrets: this.isOwner + }), + flavor: flavor, + results: results.map((result, i) => { + const r = result.toObject(false); + r.details = detailsPromises[i].value ?? ''; + const useTableIcon = + result.icon === CONFIG.RollTable.resultIcon && this.img !== this.constructor.DEFAULT_ICON; + r.icon = useTableIcon ? this.img : result.icon; + return r; + }), + rollHTML: this.displayRoll && roll ? await roll.render() : null, + table: this + }); + + // Create the chat message + return foundry.documents.ChatMessage.implementation.create(messageData, messageOptions); + } +} diff --git a/module/documents/scene.mjs b/module/documents/scene.mjs index 7f880b1d..9e2a3f5b 100644 --- a/module/documents/scene.mjs +++ b/module/documents/scene.mjs @@ -51,6 +51,27 @@ export default class DhScene extends Scene { } } + async _preUpdate(changes, options, user) { + const allowed = await super._preUpdate(changes, options, user); + if (allowed === false) return false; + + if (changes.flags?.daggerheart) { + if (this._source.flags.daggerheart) { + const unregisterTriggerData = this._source.flags.daggerheart.sceneEnvironments.reduce( + (acc, env) => { + if (!changes.flags.daggerheart.sceneEnvironments.includes(env)) acc.sceneEnvironments.push(env); + + return acc; + }, + { ...this._source.flags.daggerheart, sceneEnvironments: [] } + ); + game.system.registeredTriggers.unregisterSceneEnvironmentTriggers(unregisterTriggerData); + } + + game.system.registeredTriggers.registerSceneEnvironmentTriggers(changes.flags.daggerheart); + } + } + _onDelete(options, userId) { super._onDelete(options, userId); diff --git a/module/enrichers/DualityRollEnricher.mjs b/module/enrichers/DualityRollEnricher.mjs index 536847f7..f6de8107 100644 --- a/module/enrichers/DualityRollEnricher.mjs +++ b/module/enrichers/DualityRollEnricher.mjs @@ -2,7 +2,7 @@ import { abilities } from '../config/actorConfig.mjs'; import { getCommandTarget, rollCommandToJSON } from '../helpers/utils.mjs'; export default function DhDualityRollEnricher(match, _options) { - const roll = rollCommandToJSON(match[1], match[0]); + const roll = rollCommandToJSON(match[0]); if (!roll) return match[0]; return getDualityMessage(roll.result, roll.flavor); @@ -47,6 +47,7 @@ function getDualityMessage(roll, flavor) { ${roll?.trait && abilities[roll.trait] ? `data-trait="${roll.trait}"` : ''} ${roll?.advantage ? 'data-advantage="true"' : ''} ${roll?.disadvantage ? 'data-disadvantage="true"' : ''} + ${roll?.grantResources ? 'data-grant-resources="true"' : ''} > ${roll?.reaction ? '' : ''} ${label} @@ -63,7 +64,8 @@ export const renderDualityButton = async event => { traitValue = button.dataset.trait?.toLowerCase(), target = getCommandTarget({ allowNull: true }), difficulty = button.dataset.difficulty, - advantage = button.dataset.advantage ? Number(button.dataset.advantage) : undefined; + advantage = button.dataset.advantage ? Number(button.dataset.advantage) : undefined, + grantResources = Boolean(button.dataset?.grantResources); await enrichedDualityRoll( { @@ -73,14 +75,15 @@ export const renderDualityButton = async event => { difficulty, title: button.dataset.title, label: button.dataset.label, - advantage + advantage, + grantResources }, event ); }; export const enrichedDualityRoll = async ( - { reaction, traitValue, target, difficulty, title, label, advantage }, + { reaction, traitValue, target, difficulty, title, label, advantage, grantResources, customConfig }, event ) => { const config = { @@ -93,16 +96,24 @@ export const enrichedDualityRoll = async ( advantage, type: reaction ? 'reaction' : null }, + skips: { + resources: !grantResources, + triggers: !grantResources + }, type: 'trait', - hasRoll: true + hasRoll: true, + ...(customConfig ?? {}) }; if (target) { - await target.diceRoll(config); + const result = await target.diceRoll(config); + if (!result) return; + result.resourceUpdates.updateResources(); } else { // For no target, call DualityRoll directly with basic data config.data = { experiences: {}, traits: {}, rules: {} }; config.source = { actor: null }; await CONFIG.Dice.daggerheart.DualityRoll.build(config); } + return config; }; diff --git a/module/enrichers/FateRollEnricher.mjs b/module/enrichers/FateRollEnricher.mjs new file mode 100644 index 00000000..c82bbcb2 --- /dev/null +++ b/module/enrichers/FateRollEnricher.mjs @@ -0,0 +1,80 @@ +import { getCommandTarget, rollCommandToJSON } from '../helpers/utils.mjs'; + +export default function DhFateRollEnricher(match, _options) { + const roll = rollCommandToJSON(match[0]); + if (!roll) return match[0]; + + return getFateMessage(roll.result, roll?.flavor); +} + +export function getFateTypeData(fateTypeValue) { + const value = fateTypeValue ? fateTypeValue.capitalize() : 'Hope'; + const lowercased = fateTypeValue?.toLowerCase?.() ?? 'hope'; + switch (lowercased) { + case 'hope': + case 'fear': + return { value, label: game.i18n.localize(`DAGGERHEART.GENERAL.${lowercased}`) }; + default: + return null; + } +} + +function getFateMessage(roll, flavor) { + const fateTypeData = getFateTypeData(roll?.type); + + if (!fateTypeData) + return ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing')); + + const { value: fateType, label: fateTypeLabel } = fateTypeData; + const title = flavor ?? game.i18n.localize('DAGGERHEART.GENERAL.fateRoll'); + + const fateElement = document.createElement('span'); + fateElement.innerHTML = ` + + `; + + return fateElement; +} + +export const renderFateButton = async event => { + const button = event.currentTarget, + target = getCommandTarget({ allowNull: true }); + + const fateTypeData = getFateTypeData(button.dataset?.fatetype); + + if (!fateTypeData) ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing')); + const { value: fateType, label: fateTypeLabel } = fateTypeData; + + await enrichedFateRoll( + { + target, + title: button.dataset.title, + label: button.dataset.label, + fateType: fateType + }, + event + ); +}; + +export const enrichedFateRoll = async ({ target, title, label, fateType }, event) => { + const config = { + event: event ?? {}, + title: title, + headerTitle: label, + roll: {}, + hasRoll: true, + fateType: fateType, + skips: { reaction: true } + }; + + config.data = { experiences: {}, traits: {}, fateType: fateType }; + config.source = { actor: target?.uuid }; + await CONFIG.Dice.daggerheart.FateRoll.build(config); + return config; +}; diff --git a/module/enrichers/_module.mjs b/module/enrichers/_module.mjs index 0dcd870e..b80f166c 100644 --- a/module/enrichers/_module.mjs +++ b/module/enrichers/_module.mjs @@ -1,10 +1,11 @@ import { default as DhDamageEnricher, renderDamageButton } from './DamageEnricher.mjs'; import { default as DhDualityRollEnricher, renderDualityButton } from './DualityRollEnricher.mjs'; +import { default as DhFateRollEnricher, renderFateButton } from './FateRollEnricher.mjs'; import { default as DhEffectEnricher } from './EffectEnricher.mjs'; import { default as DhTemplateEnricher, renderMeasuredTemplate } from './TemplateEnricher.mjs'; import { default as DhLookupEnricher } from './LookupEnricher.mjs'; -export { DhDamageEnricher, DhDualityRollEnricher, DhEffectEnricher, DhTemplateEnricher }; +export { DhDamageEnricher, DhDualityRollEnricher, DhEffectEnricher, DhTemplateEnricher, DhFateRollEnricher }; export const enricherConfig = [ { @@ -15,6 +16,10 @@ export const enricherConfig = [ pattern: /\[\[\/dr\s?(.*?)\]\]({[^}]*})?/g, enricher: DhDualityRollEnricher }, + { + pattern: /\[\[\/fr\s?(.*?)\]\]({[^}]*})?/g, + enricher: DhFateRollEnricher + }, { pattern: /@Effect\[([^\[\]]*)\]({[^}]*})?/g, enricher: DhEffectEnricher @@ -38,6 +43,10 @@ export const enricherRenderSetup = element => { .querySelectorAll('.duality-roll-button') .forEach(element => element.addEventListener('click', renderDualityButton)); + element + .querySelectorAll('.fate-roll-button') + .forEach(element => element.addEventListener('click', renderFateButton)); + element .querySelectorAll('.measured-template-button') .forEach(element => element.addEventListener('click', renderMeasuredTemplate)); diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs index a28725b1..1cce581a 100644 --- a/module/helpers/utils.mjs +++ b/module/helpers/utils.mjs @@ -1,14 +1,14 @@ -import { diceTypes, getDiceSoNicePresets, range } from '../config/generalConfig.mjs'; +import { diceTypes, getDiceSoNicePresets, getDiceSoNicePreset, range } from '../config/generalConfig.mjs'; import Tagify from '@yaireo/tagify'; export const capitalize = string => { return string.charAt(0).toUpperCase() + string.slice(1); }; -export function rollCommandToJSON(text, raw) { +export function rollCommandToJSON(text) { if (!text) return {}; - const flavorMatch = raw?.match(/{(.*)}$/); + const flavorMatch = text?.match(/{(.*)}$/); const flavor = flavorMatch ? flavorMatch[1] : null; // Match key="quoted string" OR key=unquotedValue @@ -31,7 +31,7 @@ export function rollCommandToJSON(text, raw) { } result[key] = value; } - return Object.keys(result).length > 0 ? { result, flavor } : null; + return { result, flavor }; } export const getCommandTarget = (options = {}) => { @@ -69,6 +69,20 @@ export const setDiceSoNiceForDualityRoll = async (rollResult, advantageState, ho } }; +export const setDiceSoNiceForHopeFateRoll = async (rollResult, hopeFaces) => { + if (!game.modules.get('dice-so-nice')?.active) return; + const { diceSoNice } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance); + const diceSoNicePresets = await getDiceSoNicePreset(diceSoNice.hope, hopeFaces); + rollResult.dice[0].options = diceSoNicePresets; +}; + +export const setDiceSoNiceForFearFateRoll = async (rollResult, fearFaces) => { + if (!game.modules.get('dice-so-nice')?.active) return; + const { diceSoNice } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance); + const diceSoNicePresets = await getDiceSoNicePreset(diceSoNice.fear, fearFaces); + rollResult.dice[0].options = diceSoNicePresets; +}; + export const chunkify = (array, chunkSize, mappingFunc) => { var chunkifiedArray = []; for (let i = 0; i < array.length; i += chunkSize) { diff --git a/module/systemRegistration/migrations.mjs b/module/systemRegistration/migrations.mjs index 086647bf..743d42a4 100644 --- a/module/systemRegistration/migrations.mjs +++ b/module/systemRegistration/migrations.mjs @@ -212,6 +212,7 @@ export async function runMigrations() { } if (foundry.utils.isNewerVersion('1.5.5', lastMigrationVersion)) { + /* Clear out Environments that were added directly from compendium */ for (const scene of game.scenes) { if (!scene.flags.daggerheart) continue; const systemData = new game.system.api.data.scenes.DHScene(scene.flags.daggerheart); @@ -226,6 +227,25 @@ export async function runMigrations() { lastMigrationVersion = '1.5.5'; } + + if (foundry.utils.isNewerVersion('1.6.0', lastMigrationVersion)) { + /* Delevel any companions that are higher level than their partner character */ + for (const companion of game.actors.filter(x => x.type === 'companion')) { + if (companion.system.levelData.level.current <= 1) continue; + + if (!companion.system.partner) { + await companion.updateLevel(1); + } else { + const endLevel = companion.system.partner.system.levelData.level.current; + if (endLevel < companion.system.levelData.level.current) { + companion.system.levelData.level.changed = companion.system.levelData.level.current; + await companion.updateLevel(endLevel); + } + } + } + + lastMigrationVersion = '1.6.0'; + } //#endregion await game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LastMigrationVersion, lastMigrationVersion); diff --git a/src/packs/adversaries/adversary_Demon_of_Despair_kE4dfhqmIQpNd44e.json b/src/packs/adversaries/adversary_Demon_of_Despair_kE4dfhqmIQpNd44e.json index 188b2687..830848c3 100644 --- a/src/packs/adversaries/adversary_Demon_of_Despair_kE4dfhqmIQpNd44e.json +++ b/src/packs/adversaries/adversary_Demon_of_Despair_kE4dfhqmIQpNd44e.json @@ -235,7 +235,51 @@ }, "_id": "2ESeh4tPhr6DI5ty", "img": "icons/magic/death/skull-horned-worn-fire-blue.webp", - "effects": [], + "effects": [ + { + "name": "Depths Of Despair", + "type": "base", + "system": { + "rangeDependence": { + "enabled": false, + "type": "withinRange", + "target": "hostile", + "range": "melee" + } + }, + "_id": "nofxm1vGZ2TmceA2", + "img": "icons/magic/death/skull-horned-worn-fire-blue.webp", + "changes": [ + { + "key": "system.rules.attack.damage.hpDamageMultiplier", + "mode": 5, + "value": "2", + "priority": null + } + ], + "disabled": true, + "duration": { + "startTime": null, + "combat": null, + "seconds": null, + "rounds": null, + "turns": null, + "startRound": null, + "startTurn": null + }, + "description": "
The Demon of Despair deals double damage to PCs with 0 Hope.
", + "origin": null, + "tint": "#ffffff", + "transfer": true, + "statuses": [], + "sort": 0, + "flags": {}, + "_stats": { + "compendiumSource": null + }, + "_key": "!actors.items.effects!kE4dfhqmIQpNd44e.2ESeh4tPhr6DI5ty.nofxm1vGZ2TmceA2" + } + ], "folder": null, "sort": 0, "ownership": { diff --git a/src/packs/adversaries/adversary_Failed_Experiment_ChwwVqowFw8hJQwT.json b/src/packs/adversaries/adversary_Failed_Experiment_ChwwVqowFw8hJQwT.json index 39800002..408d5102 100644 --- a/src/packs/adversaries/adversary_Failed_Experiment_ChwwVqowFw8hJQwT.json +++ b/src/packs/adversaries/adversary_Failed_Experiment_ChwwVqowFw8hJQwT.json @@ -304,7 +304,51 @@ }, "_id": "1fE6xo8yIOmZkGNE", "img": "icons/skills/melee/strike-slashes-orange.webp", - "effects": [], + "effects": [ + { + "name": "Overwhelm", + "type": "base", + "system": { + "rangeDependence": { + "enabled": false, + "type": "withinRange", + "target": "hostile", + "range": "melee" + } + }, + "_id": "eGB9G0ljYCcdGbOx", + "img": "icons/skills/melee/strike-slashes-orange.webp", + "changes": [ + { + "key": "system.rules.attack.damage.hpDamageMultiplier", + "mode": 5, + "value": "2", + "priority": null + } + ], + "disabled": true, + "duration": { + "startTime": null, + "combat": null, + "seconds": null, + "rounds": null, + "turns": null, + "startRound": null, + "startTurn": null + }, + "description": "When a target the Failed Experiment attacks has other adversaries within Very Close range, the Failed Experiment deals double damage.
", + "origin": null, + "tint": "#ffffff", + "transfer": true, + "statuses": [], + "sort": 0, + "flags": {}, + "_stats": { + "compendiumSource": null + }, + "_key": "!actors.items.effects!ChwwVqowFw8hJQwT.1fE6xo8yIOmZkGNE.eGB9G0ljYCcdGbOx" + } + ], "folder": null, "sort": 0, "ownership": { diff --git a/src/packs/adversaries/adversary_Hallowed_Archer_kabueAo6BALApWqp.json b/src/packs/adversaries/adversary_Hallowed_Archer_kabueAo6BALApWqp.json index 0abf1661..8cce1b94 100644 --- a/src/packs/adversaries/adversary_Hallowed_Archer_kabueAo6BALApWqp.json +++ b/src/packs/adversaries/adversary_Hallowed_Archer_kabueAo6BALApWqp.json @@ -229,7 +229,51 @@ }, "_id": "FGJTAeL38zTVd4fA", "img": "icons/magic/control/buff-flight-wings-runes-red-yellow.webp", - "effects": [], + "effects": [ + { + "name": "Punish the Guilty", + "type": "base", + "system": { + "rangeDependence": { + "enabled": false, + "type": "withinRange", + "target": "hostile", + "range": "melee" + } + }, + "_id": "ID85zoIa5GfhNMti", + "img": "icons/magic/control/buff-flight-wings-runes-red-yellow.webp", + "changes": [ + { + "key": "system.rules.attack.damage.hpDamageMultiplier", + "mode": 5, + "value": "2", + "priority": null + } + ], + "disabled": true, + "duration": { + "startTime": null, + "combat": null, + "seconds": null, + "rounds": null, + "turns": null, + "startRound": null, + "startTurn": null + }, + "description": "The Hallowed Archer deals double damage to targets marked Guilty by a High Seraph.
", + "origin": null, + "tint": "#ffffff", + "transfer": true, + "statuses": [], + "sort": 0, + "flags": {}, + "_stats": { + "compendiumSource": null + }, + "_key": "!actors.items.effects!kabueAo6BALApWqp.FGJTAeL38zTVd4fA.ID85zoIa5GfhNMti" + } + ], "folder": null, "sort": 0, "ownership": { diff --git a/src/packs/adversaries/adversary_Jagged_Knife_Kneebreaker_CBKixLH3yhivZZuL.json b/src/packs/adversaries/adversary_Jagged_Knife_Kneebreaker_CBKixLH3yhivZZuL.json index fc644604..c38260e9 100644 --- a/src/packs/adversaries/adversary_Jagged_Knife_Kneebreaker_CBKixLH3yhivZZuL.json +++ b/src/packs/adversaries/adversary_Jagged_Knife_Kneebreaker_CBKixLH3yhivZZuL.json @@ -336,7 +336,14 @@ "range": "melee" } }, - "changes": [], + "changes": [ + { + "key": "system.rules.attack.damage.hpDamageTakenMultiplier", + "mode": 5, + "value": "2", + "priority": null + } + ], "disabled": false, "duration": { "startTime": null, @@ -350,8 +357,8 @@ "description": "", "tint": "#ffffff", "statuses": [ - "restrained", - "vulnerable" + "vulnerable", + "restrained" ], "sort": 0, "flags": {}, diff --git a/src/packs/adversaries/adversary_Skeleton_Archer_7X5q7a6ueeHs5oA9.json b/src/packs/adversaries/adversary_Skeleton_Archer_7X5q7a6ueeHs5oA9.json index e5381f6f..9d837ac0 100644 --- a/src/packs/adversaries/adversary_Skeleton_Archer_7X5q7a6ueeHs5oA9.json +++ b/src/packs/adversaries/adversary_Skeleton_Archer_7X5q7a6ueeHs5oA9.json @@ -230,7 +230,51 @@ "subType": null, "originId": null }, - "effects": [], + "effects": [ + { + "name": "Opportunist", + "type": "base", + "system": { + "rangeDependence": { + "enabled": false, + "type": "withinRange", + "target": "hostile", + "range": "melee" + } + }, + "_id": "O03vYbyNLO3YPZGo", + "img": "icons/skills/targeting/crosshair-triple-strike-orange.webp", + "changes": [ + { + "key": "system.rules.attack.damage.hpDamageMultiplier", + "mode": 5, + "value": "2", + "priority": null + } + ], + "disabled": true, + "duration": { + "startTime": null, + "combat": null, + "seconds": null, + "rounds": null, + "turns": null, + "startRound": null, + "startTurn": null + }, + "description": "When two or more adversaries are within Very Close range of a creature, all damage the Skeleton Archer deals to that creature is doubled.
", + "origin": null, + "tint": "#ffffff", + "transfer": true, + "statuses": [], + "sort": 0, + "flags": {}, + "_stats": { + "compendiumSource": null + }, + "_key": "!actors.items.effects!7X5q7a6ueeHs5oA9.6mL2FQ9pQdfoDNzG.O03vYbyNLO3YPZGo" + } + ], "folder": null, "sort": 0, "ownership": { diff --git a/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json b/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json index 069fe6ba..41f11a74 100644 --- a/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json +++ b/src/packs/communities/feature_Know_the_Tide_07x6Qe6qMzDw2xN4.json @@ -9,13 +9,47 @@ "resource": { "type": "simple", "value": 0, - "max": "", - "icon": "", - "recovery": null, + "max": "@system.levelData.level.current", + "icon": "fa-solid fa-water", + "recovery": "session", "diceStates": {}, "dieFaces": "d4" }, - "actions": {}, + "actions": { + "tFlus34KotJjHfTe": { + "type": "effect", + "_id": "tFlus34KotJjHfTe", + "systemPath": "actions", + "baseAction": false, + "description": "", + "chatDisplay": true, + "originItem": { + "type": "itemCollection" + }, + "actionType": "action", + "triggers": [ + { + "trigger": "fearRoll", + "triggeringActorType": "self", + "command": "const { max, value } = this.item.system.resource;\nconst maxValue = actor.system.levelData.level.current;\nconst afterUpdate = value+1;\nif (afterUpdate > maxValue) return;\n\nui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.knowTheTide'));\nreturn { updates: [{\n key: 'resource',\n itemId: this.item.id,\n target: this.item,\n value: 1,\n}]};" + } + ], + "cost": [], + "uses": { + "value": null, + "max": "", + "recovery": null, + "consumeOnSuccess": false + }, + "effects": [], + "target": { + "type": "any", + "amount": null + }, + "name": "Know The Tide", + "range": "" + } + }, "originItemType": null, "subType": null, "originId": null, diff --git a/src/packs/rolltables/tables_Consumables_tF04P02yVN1YDVel.json b/src/packs/rolltables/tables_Consumables_tF04P02yVN1YDVel.json index c2413ec3..c3f5ffdc 100644 --- a/src/packs/rolltables/tables_Consumables_tF04P02yVN1YDVel.json +++ b/src/packs/rolltables/tables_Consumables_tF04P02yVN1YDVel.json @@ -1,7 +1,7 @@ { "name": "Consumables", "img": "icons/consumables/potions/bottle-corked-red.webp", - "description": "To generate a random consumable, choose a rarity, roll the designated dice, and match the total to the item in the table:
Common: 1d12 or 2d12
Uncommon: 2d12 or 3d12
Rare: 3d12 or 4d12
Legendary: 4d12 or 5d12
To generate a random item, choose a rarity, roll the designated dice, and match the total to the item in the table:
Common: 1d12 or 2d12
Uncommon: 2d12 or 3d12
Rare: 3d12 or 4d12
Legendary: 4d12 or 5d12
Layering Goals Other than Attrition into Combat
", + "description": "", "results": [ { "type": "text", @@ -311,7 +311,20 @@ "default": 0, "Bgvu4A6AMkRFOTGR": 3 }, - "flags": {}, + "flags": { + "daggerheart": { + "formulaName": "Roll Formula", + "altFormula": {}, + "activeAltFormula": null, + "flags": { + "daggerheart": { + "formulaName": "Roll Formula", + "altFormula": {}, + "activeAltFormula": null + } + } + } + }, "formula": "1d12", "_id": "I5L1dlgxXTNrCCkL", "sort": 400000, diff --git a/src/packs/subclasses/feature_Advanced_Training_uGcs785h94RMtueH.json b/src/packs/subclasses/feature_Advanced_Training_uGcs785h94RMtueH.json index 8f9c63f1..16a5cd79 100644 --- a/src/packs/subclasses/feature_Advanced_Training_uGcs785h94RMtueH.json +++ b/src/packs/subclasses/feature_Advanced_Training_uGcs785h94RMtueH.json @@ -16,7 +16,51 @@ "artist": "" } }, - "effects": [], + "effects": [ + { + "name": "Advanced Training", + "type": "base", + "system": { + "rangeDependence": { + "enabled": false, + "type": "withinRange", + "target": "hostile", + "range": "melee" + } + }, + "_id": "bKOuMxhB2Jth3j2T", + "img": "icons/creatures/mammals/wolf-howl-moon-gray.webp", + "changes": [ + { + "key": "system.companionData.levelupChoices", + "mode": 2, + "value": "2", + "priority": null + } + ], + "disabled": false, + "duration": { + "startTime": null, + "combat": null, + "seconds": null, + "rounds": null, + "turns": null, + "startRound": null, + "startTurn": null + }, + "description": "Choose two additional level-up options for your companion.
", + "origin": null, + "tint": "#ffffff", + "transfer": true, + "statuses": [], + "sort": 0, + "flags": {}, + "_stats": { + "compendiumSource": null + }, + "_key": "!items.effects!uGcs785h94RMtueH.bKOuMxhB2Jth3j2T" + } + ], "sort": 0, "ownership": { "default": 0, diff --git a/src/packs/subclasses/feature_Expert_Training_iCXtOWBKv1FdKdWz.json b/src/packs/subclasses/feature_Expert_Training_iCXtOWBKv1FdKdWz.json index e5850da6..06819dc9 100644 --- a/src/packs/subclasses/feature_Expert_Training_iCXtOWBKv1FdKdWz.json +++ b/src/packs/subclasses/feature_Expert_Training_iCXtOWBKv1FdKdWz.json @@ -16,7 +16,51 @@ "artist": "" } }, - "effects": [], + "effects": [ + { + "name": "Expert Training", + "type": "base", + "system": { + "rangeDependence": { + "enabled": false, + "type": "withinRange", + "target": "hostile", + "range": "melee" + } + }, + "_id": "rknTONvaUDZ2Yz1W", + "img": "icons/creatures/mammals/dog-husky-white-blue.webp", + "changes": [ + { + "key": "system.companionData.levelupChoices", + "mode": 2, + "value": "1", + "priority": null + } + ], + "disabled": false, + "duration": { + "startTime": null, + "combat": null, + "seconds": null, + "rounds": null, + "turns": null, + "startRound": null, + "startTurn": null + }, + "description": "Choose an additional level-up option for your companion.
", + "origin": null, + "tint": "#ffffff", + "transfer": true, + "statuses": [], + "sort": 0, + "flags": {}, + "_stats": { + "compendiumSource": null + }, + "_key": "!items.effects!iCXtOWBKv1FdKdWz.rknTONvaUDZ2Yz1W" + } + ], "sort": 0, "ownership": { "default": 0, diff --git a/styles/less/dialog/character-reset/sheet.less b/styles/less/dialog/character-reset/sheet.less new file mode 100644 index 00000000..44312a3e --- /dev/null +++ b/styles/less/dialog/character-reset/sheet.less @@ -0,0 +1,27 @@ +.daggerheart.dh-style.dialog.views.character-reset { + .character-reset-container { + display: flex; + flex-direction: column; + gap: 8px; + + legend { + padding: 0 4px; + } + + .character-reset-header { + font-size: var(--font-size-18); + text-align: center; + } + + .reset-data-container { + display: grid; + grid-template-columns: 3fr 2fr; + align-items: center; + gap: 4px; + + label { + font-weight: bold; + } + } + } +} diff --git a/styles/less/dialog/death-move/death-move-container.less b/styles/less/dialog/death-move/death-move-container.less index e5803d95..903704e3 100644 --- a/styles/less/dialog/death-move/death-move-container.less +++ b/styles/less/dialog/death-move/death-move-container.less @@ -1,55 +1,56 @@ -@import '../../utils/spacing.less'; -@import '../../utils/colors.less'; -@import '../../utils/fonts.less'; - -.daggerheart.dh-style.dialog.death-move { - .death-move-container { - display: flex; - flex-direction: column; - gap: 5px; - - .moves-list { - .move-item { - display: flex; - align-items: center; - gap: 5px; - - &:hover { - background-color: light-dark(@soft-shadow, @soft-white-shadow); - cursor: pointer; - } - padding: 5px; - border-radius: 5px; - transition: background-color 0.3s ease-in-out; - - .label { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; - flex: 1; - i { - text-align: center; - width: 30px; - } - } - - input[type='radio'] { - margin-left: auto; - } - } - } - } - - footer { - margin-top: 8px; - display: flex; - gap: 8px; - - button { - flex: 1; - height: 40px; - font-weight: 600; - } - } -} +@import '../../utils/spacing.less'; +@import '../../utils/colors.less'; +@import '../../utils/fonts.less'; + +.daggerheart.dh-style.dialog.death-move { + .death-move-container { + display: flex; + flex-direction: column; + gap: 5px; + + .moves-list { + .move-item { + display: flex; + align-items: center; + gap: 5px; + padding: 5px; + border-radius: 5px; + transition: background-color 0.3s ease-in-out; + height: 37px; + + &:hover { + background-color: light-dark(@soft-shadow, @soft-white-shadow); + cursor: pointer; + } + + .label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + flex: 1; + i { + text-align: center; + width: 30px; + } + } + + input[type='radio'] { + margin-left: auto; + } + } + } + } + + footer { + margin-top: 8px; + display: flex; + gap: 8px; + + button { + flex: 1; + height: 40px; + font-weight: 600; + } + } +} diff --git a/styles/less/dialog/index.less b/styles/less/dialog/index.less index b5ed8764..733cdd1c 100644 --- a/styles/less/dialog/index.less +++ b/styles/less/dialog/index.less @@ -38,4 +38,8 @@ @import './item-transfer/sheet.less'; -@import './settings/change-currency-icon.less'; \ No newline at end of file +@import './settings/change-currency-icon.less'; + +@import './risk-it-all/sheet.less'; + +@import './character-reset/sheet.less'; diff --git a/styles/less/dialog/risk-it-all/sheet.less b/styles/less/dialog/risk-it-all/sheet.less new file mode 100644 index 00000000..db34a5c1 --- /dev/null +++ b/styles/less/dialog/risk-it-all/sheet.less @@ -0,0 +1,60 @@ +.daggerheart.dialog.dh-style.views.risk-it-all { + .risk-it-all-container { + display: flex; + align-items: center; + flex-direction: column; + gap: 8px; + text-align: center; + + header { + font-weight: bold; + font-size: var(--font-size-20); + } + + .section-label { + font-size: var(--font-size-18); + text-decoration: underline; + } + + .remaining-section { + display: flex; + flex-direction: column; + gap: 2px; + } + + .resource-section { + width: 100%; + display: flex; + gap: 8px; + } + + .final-section { + width: 100%; + display: flex; + flex-direction: column; + gap: 2px; + + .final-section-values-container { + width: 100%; + display: flex; + align-items: center; + justify-content: space-evenly; + + .final-section-value-container { + display: flex; + flex-direction: column; + gap: 2px; + } + } + } + } + + footer { + width: 100%; + display: flex; + + button { + flex: 1; + } + } +} diff --git a/styles/less/global/chat.less b/styles/less/global/chat.less index dc671e44..b9478ea4 100644 --- a/styles/less/global/chat.less +++ b/styles/less/global/chat.less @@ -15,6 +15,14 @@ .message-header .message-header-main .message-sub-header-container h4 { color: @dark-blue; } + + .message-content { + .table-draw { + .table-description { + color: @dark; + } + } + } } } @@ -83,6 +91,7 @@ .message-content { padding-bottom: 8px; + .flavor-text { font-size: var(--font-size-12); line-height: 20px; @@ -90,6 +99,33 @@ text-align: center; display: block; } + + .table-draw { + .table-flavor { + padding-top: 5px; + padding-bottom: 0.5rem; + font-size: var(--font-size-12); + } + + .table-description { + color: @beige; + font-style: italic; + + &.flavor-spaced { + padding-top: 0; + } + } + + .table-results { + .description { + flex-basis: min-content; + + > p:first-of-type { + margin-top: 0; + } + } + } + } } } } diff --git a/styles/less/global/enrichment.less b/styles/less/global/enrichment.less index 2ad3975a..8256d60a 100644 --- a/styles/less/global/enrichment.less +++ b/styles/less/global/enrichment.less @@ -1,5 +1,6 @@ .measured-template-button, .enriched-damage-button, +.fate-roll-button, .duality-roll-button { display: inline; diff --git a/styles/less/hud/token-hud/token-hud.less b/styles/less/hud/token-hud/token-hud.less index 46003975..ea58f673 100644 --- a/styles/less/hud/token-hud/token-hud.less +++ b/styles/less/hud/token-hud/token-hud.less @@ -24,13 +24,13 @@ font-weight: bold; } } + } - .clown-car img { - transition: 0.5s; + .clown-car img { + transition: 0.5s; - &.flipped { - transform: scaleX(-1); - } + &.flipped { + transform: scaleX(-1); } } diff --git a/styles/less/sheets/actors/character/header.less b/styles/less/sheets/actors/character/header.less index 593f1b73..5e8ef002 100644 --- a/styles/less/sheets/actors/character/header.less +++ b/styles/less/sheets/actors/character/header.less @@ -195,6 +195,11 @@ .hope-value { display: flex; cursor: pointer; + + &.scar { + cursor: initial; + opacity: 0.6; + } } } } diff --git a/styles/less/sheets/actors/companion/header.less b/styles/less/sheets/actors/companion/header.less index 3616a6b3..2a162a25 100644 --- a/styles/less/sheets/actors/companion/header.less +++ b/styles/less/sheets/actors/companion/header.less @@ -207,7 +207,7 @@ .input-section { display: flex; align-items: center; - justify-content: space-between; + gap: 8px; } } diff --git a/styles/less/sheets/index.less b/styles/less/sheets/index.less index 216cda33..1bdb451a 100644 --- a/styles/less/sheets/index.less +++ b/styles/less/sheets/index.less @@ -40,4 +40,5 @@ @import './items/heritage.less'; @import './items/item-sheet-shared.less'; +@import './rollTables/sheet.less'; @import './actions/actions.less'; diff --git a/styles/less/sheets/rollTables/sheet.less b/styles/less/sheets/rollTables/sheet.less new file mode 100644 index 00000000..a7c05455 --- /dev/null +++ b/styles/less/sheets/rollTables/sheet.less @@ -0,0 +1,29 @@ +.application.sheet.roll-table-sheet { + .formulas-section { + legend { + margin-left: auto; + margin-right: auto; + } + + .formulas-container { + display: grid; + grid-template-columns: 1fr 1fr 40px; + gap: 10px; + text-align: center; + + .formula-button { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + } + } + } + + .roll-table-view-formula-container { + width: fit-content; + display: flex; + align-items: center; + gap: 4px; + } +} diff --git a/styles/less/ui/chat/chat.less b/styles/less/ui/chat/chat.less index 57e9fd57..1b1e3c1c 100644 --- a/styles/less/ui/chat/chat.less +++ b/styles/less/ui/chat/chat.less @@ -14,6 +14,7 @@ color: @dark; } + &.fate, &.duality { background-image: url(../assets/parchments/dh-parchment-dark.png); @@ -66,6 +67,7 @@ } } + &.fate, &.critical { --text-color: @chat-purple; --bg-color: @chat-purple-40; @@ -80,7 +82,7 @@ } } - &:not(.duality) { + &:not(.duality .fate) { .font-20 { color: @dark; } @@ -173,6 +175,28 @@ } } + &.fate { + + &.hope { + --text-color: @golden; + --bg-color: @golden-40; + .message-header, + .message-content { + background-color: @golden-bg; + } + } + + &.fear { + --text-color: @chat-blue; + --bg-color: @chat-blue-40; + .message-header, + .message-content { + background-color: @chat-blue-bg; + } + } + + } + &.duality { &.hope { --text-color: @golden; diff --git a/styles/less/ui/chat/deathmoves.less b/styles/less/ui/chat/deathmoves.less new file mode 100644 index 00000000..175b8753 --- /dev/null +++ b/styles/less/ui/chat/deathmoves.less @@ -0,0 +1,152 @@ +@import '../../utils/colors.less'; +@import '../../utils/fonts.less'; +@import '../../utils/spacing.less'; + +#interface.theme-light { + .daggerheart.chat.death-moves { + .death-moves-list .death-move { + &:hover { + background: @dark-blue-10; + } + + .death-label { + border-bottom: 1px solid @dark-blue; + + .header-label .title { + color: @dark-blue; + } + .header-label .label { + color: @dark; + } + } + + .fa-chevron-down { + color: @dark-blue; + } + } + + .description { + color: @dark; + } + + .result { + color: @dark; + } + + .risk-it-all-button { + color: @dark; + } + } +} + +.daggerheart.chat { + &.death-moves { + display: flex; + flex-direction: column; + align-items: center; + + details[open] { + .fa-chevron-down { + transform: rotate(180deg); + transition: all 0.3s ease; + } + } + + .death-moves-list { + display: flex; + flex-direction: column; + gap: 5px; + width: 100%; + + .fa-chevron-down { + transition: all 0.3s ease; + margin-left: auto; + } + + .death-move { + width: 100%; + + .death-label { + display: flex; + align-items: center; + gap: 5px; + border-bottom: 1px solid @golden; + margin: 0 8px; + padding-bottom: 5px; + width: -webkit-fill-available; + + &:hover { + background: light-dark(@dark-blue-10, @golden-10); + cursor: pointer; + transition: all 0.3s ease; + } + + .death-image { + width: 40px; + height: 40px; + border-radius: 3px; + } + + .header-label { + padding: 8px; + .title { + font-size: var(--font-size-16); + color: @golden; + font-weight: 700; + } + .label { + font-size: var(--font-size-12); + color: @beige; + margin: 0; + } + } + } + + .description { + padding: 8px; + } + } + + .action-use-button-parent { + width: 100%; + + .action-use-target { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + width: 100%; + padding: 4px 8px 10px 40px; + font-size: var(--font-size-12); + + label { + font-weight: bold; + } + + select { + flex: 1; + } + } + } + + .action-use-button { + width: -webkit-fill-available; + margin: 0 8px; + font-weight: 600; + height: 40px; + } + } + + .result { + padding: 8px; + font-weight: bold; + } + + .risk-it-all-button { + width: -webkit-fill-available; + margin: 0 8px; + font-weight: 600; + height: 40px; + } + } +} diff --git a/styles/less/ui/index.less b/styles/less/ui/index.less index 25f51d0f..065e43c5 100644 --- a/styles/less/ui/index.less +++ b/styles/less/ui/index.less @@ -6,6 +6,7 @@ @import './chat/effect-summary.less'; @import './chat/group-roll.less'; @import './chat/refresh-message.less'; +@import './chat/deathmoves.less'; @import './chat/sheet.less'; @import './combat-sidebar/combat-sidebar.less'; diff --git a/system.json b/system.json index 6521c6d6..8624bab7 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "daggerheart", "title": "Daggerheart", "description": "An unofficial implementation of the Daggerheart system", - "version": "1.5.5", + "version": "1.6.0", "compatibility": { "minimum": "13.346", "verified": "13.351", @@ -285,6 +285,7 @@ }, "ChatMessage": { "dualityRoll": {}, + "fateRoll": {}, "adversaryRoll": {}, "damageRoll": {}, "abilityUse": {}, diff --git a/templates/dialogs/characterReset.hbs b/templates/dialogs/characterReset.hbs new file mode 100644 index 00000000..298826e5 --- /dev/null +++ b/templates/dialogs/characterReset.hbs @@ -0,0 +1,33 @@ +