diff --git a/daggerheart.mjs b/daggerheart.mjs index 77e9831e..7ae01590 100644 --- a/daggerheart.mjs +++ b/daggerheart.mjs @@ -58,6 +58,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; @@ -105,7 +108,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'], @@ -190,6 +193,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', diff --git a/lang/en.json b/lang/en.json index fbd8f34c..4d6815c3 100755 --- a/lang/en.json +++ b/lang/en.json @@ -488,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": { @@ -2381,6 +2383,12 @@ "secondaryWeapon": "Secondary Weapon" } }, + "ROLLTABLES": { + "FIELDS": { + "formulaName": { "label": "Formula Name" } + }, + "formula": "Formula" + }, "SETTINGS": { "Appearance": { "FIELDS": { diff --git a/module/applications/dialogs/d20RollDialog.mjs b/module/applications/dialogs/d20RollDialog.mjs index 6f320152..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; 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/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/companion.mjs b/module/applications/sheets/actors/companion.mjs index 9b85f622..21f09f02 100644 --- a/module/applications/sheets/actors/companion.mjs +++ b/module/applications/sheets/actors/companion.mjs @@ -71,10 +71,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/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/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 12396384..594f078c 100644 --- a/module/data/actor/character.mjs +++ b/module/data/actor/character.mjs @@ -253,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, @@ -368,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() { @@ -677,6 +677,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); 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/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/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/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/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/enrichers/DualityRollEnricher.mjs b/module/enrichers/DualityRollEnricher.mjs index 91149fd8..f6de8107 100644 --- a/module/enrichers/DualityRollEnricher.mjs +++ b/module/enrichers/DualityRollEnricher.mjs @@ -107,6 +107,7 @@ export const enrichedDualityRoll = async ( if (target) { const result = await target.diceRoll(config); + if (!result) return; result.resourceUpdates.updateResources(); } else { // For no target, call DualityRoll directly with basic data 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/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/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/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/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/templates/hud/tokenHUD.hbs b/templates/hud/tokenHUD.hbs index f079e5d9..1ba29621 100644 --- a/templates/hud/tokenHUD.hbs +++ b/templates/hud/tokenHUD.hbs @@ -11,6 +11,11 @@ + {{#if hasCompanion}} + + {{/if}} {{#if canConfigure}}