Merged with main

This commit is contained in:
WBHarry 2026-06-05 16:00:09 +02:00
commit d78927d0c2
275 changed files with 5076 additions and 4132 deletions

View file

@ -439,10 +439,13 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
'system.domain': { key: 'system.domain', value: this.setup.class?.system.domains ?? null }
};
if (type === 'subclasses')
if (type === 'subclasses') {
const classItem = this.setup.class;
const uuid = classItem?._stats.compendiumSource ?? classItem?.uuid;
presets.filter = {
'system.linkedClass.uuid': { key: 'system.linkedClass.uuid', value: this.setup.class?.uuid }
'system.linkedClass': { key: 'system.linkedClass', value: uuid }
};
}
if (equipment.includes(type))
presets.filter = {
@ -610,7 +613,8 @@ export default class DhCharacterCreation extends HandlebarsApplicationMixin(Appl
[foundry.utils.randomID()]: {}
};
} else if (item.type === 'subclass' && event.target.closest('.subclass-card')) {
if (this.setup.class.system.subclasses.every(subclass => subclass.uuid !== item.uuid)) {
const classSubclasses = await this.setup.class.system.fetchSubclasses();
if (classSubclasses.every(subclass => subclass.uuid !== item.uuid)) {
ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass'));
return;
}

View file

@ -50,7 +50,7 @@ export default class CompendiumBrowserSettings extends HandlebarsApplicationMixi
const excludedSourceData = this.browserSettings.excludedSources;
const excludedPackData = this.browserSettings.excludedPacks;
context.typePackCollections = game.packs.reduce((acc, pack) => {
const { type, label, packageType, packageName: basePackageName, id } = pack.metadata;
const { type, label, packageType, packageName: basePackageName, name, id } = pack.metadata;
if (!CompendiumBrowserSettings.#browserPackTypes.includes(type)) return acc;
const isWorldPack = packageType === 'world';
@ -68,13 +68,15 @@ export default class CompendiumBrowserSettings extends HandlebarsApplicationMixi
if (!acc[type].sources[packageName])
acc[type].sources[packageName] = { label: sourceLabel, checked: sourceChecked, packs: [] };
const checked = !excludedPackData[id] || !excludedPackData[id].excludedDocumentTypes.includes(type);
const included =
!excludedPackData[packageName] ||
!excludedPackData[packageName][name]?.excludedDocumentTypes.includes(type);
acc[type].sources[packageName].packs.push({
pack: id,
name,
type,
label: id === game.system.id ? game.system.title : game.i18n.localize(label),
checked: checked
checked: included
});
return acc;
@ -106,16 +108,16 @@ export default class CompendiumBrowserSettings extends HandlebarsApplicationMixi
toggleTypedPack(event) {
event.stopPropagation();
const { type, pack } = event.target.dataset;
const currentlyExcluded = this.browserSettings.excludedPacks[pack]
? this.browserSettings.excludedPacks[pack].excludedDocumentTypes.includes(type)
const { type, source, packName } = event.target.dataset;
const currentlyExcluded = this.browserSettings.excludedPacks[source]?.[packName]
? this.browserSettings.excludedPacks[source][packName].excludedDocumentTypes.includes(type)
: false;
if (!this.browserSettings.excludedPacks[pack])
this.browserSettings.excludedPacks[pack] = { excludedDocumentTypes: [] };
this.browserSettings.excludedPacks[pack].excludedDocumentTypes = currentlyExcluded
? this.browserSettings.excludedPacks[pack].excludedDocumentTypes.filter(x => x !== type)
: [...(this.browserSettings.excludedPacks[pack]?.excludedDocumentTypes ?? []), type];
this.browserSettings.excludedPacks[source] ??= {};
this.browserSettings.excludedPacks[source][packName] ??= { excludedDocumentTypes: [] };
this.browserSettings.excludedPacks[source][packName].excludedDocumentTypes = currentlyExcluded
? this.browserSettings.excludedPacks[source][packName].excludedDocumentTypes.filter(x => x !== type)
: [...(this.browserSettings.excludedPacks[source][packName]?.excludedDocumentTypes ?? []), type];
this.render();
}

View file

@ -10,7 +10,6 @@ export { default as ImageSelectDialog } from './imageSelectDialog.mjs';
export { default as ItemTransferDialog } from './itemTransfer.mjs';
export { default as MulticlassChoiceDialog } from './multiclassChoiceDialog.mjs';
export { default as OwnershipSelection } from './ownershipSelection.mjs';
export { default as RerollDamageDialog } from './rerollDamageDialog.mjs';
export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs';
export { default as ActionSelectionDialog } from './actionSelectionDialog.mjs';
export { default as TagTeamDialog } from './tagTeamDialog.mjs';

View file

@ -72,8 +72,8 @@ export default class ActionSelectionDialog extends HandlebarsApplicationMixin(Ap
static async #onChooseAction(event, button) {
const { actionId } = button.dataset;
this.action = this.item.system.actionsList.find(a => a._id === actionId);
Object.defineProperty(this.event, 'shiftKey', {
this.#action = this.item.system.actionsList.find(a => a._id === actionId);
Object.defineProperty(this.#event, 'shiftKey', {
get() {
return event.shiftKey;
}

View file

@ -175,14 +175,14 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio
this.disadvantage = advantage === -1;
this.config.roll.advantage = this.config.roll.advantage === advantage ? 0 : advantage;
if (this.config.roll.advantage === 0) return this.render();
if (this.config.roll.advantage === 1 && this.config.data.rules.roll.advantageFaces) {
const faces = Number.parseInt(this.config.data.rules.roll.advantageFaces);
this.roll.advantageFaces = Number.isNaN(faces) ? this.roll.advantageFaces : faces;
} else if (this.config.roll.advantage === -1 && this.config.data.rules.roll.disadvantageFaces) {
const faces = Number.parseInt(this.config.data.rules.roll.disadvantageFaces);
this.roll.advantageFaces = Number.isNaN(faces) ? this.roll.advantageFaces : faces;
}
const defaultFaces =
this.config.roll.advantage === 1
? this.config.data.rules.roll.defaultAdvantageDice
: this.config.data.rules.roll.defaultDisadvantageDice;
const faces = Number.parseInt(defaultFaces);
this.roll.advantageFaces = Number.isNaN(faces) ? this.roll.advantageFaces : faces;
this.render();
}

View file

@ -22,9 +22,10 @@ export default class DamageReductionDialog extends HandlebarsApplicationMixin(Ap
);
const orderedArmorSources = getArmorSources(actor).filter(s => !s.disabled);
const armor = orderedArmorSources.reduce((acc, { document }) => {
const armor = orderedArmorSources.reduce((acc, { name, document }) => {
const { current, max } = document.type === 'armor' ? document.system.armor : document.system.armorData;
acc.push({
name,
effect: document,
marks: [...Array(max).keys()].reduce((acc, _, index) => {
const spent = index < current;
@ -152,14 +153,8 @@ export default class DamageReductionDialog extends HandlebarsApplicationMixin(Ap
const armorSources = [];
for (const source of this.marks.armor) {
const parent = source.effect.origin
? await foundry.utils.fromUuid(source.effect.origin)
: source.effect.parent;
const useEffectName = parent.type === 'armor' || parent instanceof Actor;
const label = useEffectName ? source.effect.name : parent.name;
armorSources.push({
label: label,
label: source.name,
uuid: source.effect.uuid,
marks: source.marks
});

View file

@ -57,6 +57,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
let returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar');
if (config.roll.fate.value <= this.actor.system.levelData.level.current) {
const maxHope = this.actor.system.resources.hope.max + this.actor.system.scars;
const newScarAmount = this.actor.system.scars + 1;
await this.actor.update({
system: {
@ -64,7 +65,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
}
});
if (newScarAmount >= this.actor.system.resources.hope.max) {
if (newScarAmount >= maxHope) {
await this.actor.setDeathMoveDefeated(CONFIG.DH.GENERAL.defeatedConditionChoices.dead.id);
return game.i18n.format('DAGGERHEART.UI.Chat.deathMove.journeysEnd', { scars: newScarAmount });
}

View file

@ -259,7 +259,9 @@ export default class DhpDowntime extends HandlebarsApplicationMixin(ApplicationV
const resetValue = increasing
? 0
: feature.system.resource.max
? new Roll(Roll.replaceFormulaData(feature.system.resource.max, this.actor)).evaluateSync().total
? new Roll(
Roll.replaceFormulaData(feature.system.resource.max, this.actor.getRollData())
).evaluateSync().total
: 0;
await feature.update({ 'system.resource.value': resetValue });

View file

@ -1,12 +1,12 @@
import { ResourceUpdateMap } from '../../data/action/baseAction.mjs';
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
import Party from '../sheets/actors/party.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export default class GroupRollDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(party) {
super();
super({ id: `GroupRollDialog-${party.id}` });
this.party = party;
this.partyMembers = party.system.partyMembers
@ -35,19 +35,18 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
static DEFAULT_OPTIONS = {
tag: 'form',
id: 'GroupRollDialog',
classes: ['daggerheart', 'views', 'dh-style', 'dialog', 'group-roll-dialog'],
position: { width: 550, height: 'auto' },
position: { width: 390, height: 'auto' },
window: {
icon: 'fa-solid fa-users'
},
actions: {
toggleSelectMember: this.#toggleSelectMember,
startGroupRoll: this.#startGroupRoll,
makeRoll: this.#makeRoll,
removeRoll: this.#removeRoll,
rerollDice: this.#rerollDice,
makeLeaderRoll: this.#makeLeaderRoll,
removeLeaderRoll: this.#removeLeaderRoll,
rerollLeaderDice: this.#rerollLeaderDice,
markSuccessfull: this.#markSuccessfull,
markSuccessful: this.#markSuccessful,
cancelRoll: this.#onCancelRoll,
finishRoll: this.#finishRoll
},
@ -59,17 +58,21 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
id: 'initialization',
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/initialization.hbs'
},
main: {
id: 'main',
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/main.hbs'
},
leader: {
id: 'leader',
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/leader.hbs'
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/parts/member.hbs'
},
groupRoll: {
id: 'groupRoll',
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/groupRoll.hbs'
result: {
id: 'result',
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/parts/result.hbs'
},
footer: {
id: 'footer',
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/footer.hbs'
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/parts/footer.hbs'
}
};
@ -89,51 +92,31 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
}
_configureRenderParts(options) {
const { initialization, leader, groupRoll, footer } = super._configureRenderParts(options);
const augmentedParts = { initialization };
const parts = super._configureRenderParts(options);
for (const memberKey of Object.keys(this.party.system.groupRoll.aidingCharacters)) {
augmentedParts[memberKey] = {
parts[memberKey] = {
id: memberKey,
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/groupRollMember.hbs'
template: 'systems/daggerheart/templates/dialogs/groupRollDialog/parts/member.hbs'
};
}
augmentedParts.leader = leader;
augmentedParts.groupRoll = groupRoll;
augmentedParts.footer = footer;
return augmentedParts;
}
/**@inheritdoc */
async _onRender(context, options) {
await super._onRender(context, options);
if (this.element.querySelector('.team-container')) return;
if (this.tabGroups.application !== this.constructor.PARTS.initialization.id) {
const initializationPart = this.element.querySelector('.initialization-container');
initializationPart.insertAdjacentHTML('afterend', '<div class="team-container"></div>');
initializationPart.insertAdjacentHTML(
'afterend',
`<div class="section-title">${game.i18n.localize('DAGGERHEART.APPLICATIONS.GroupRollSelect.aidingCharacters')}</div>`
);
const teamContainer = this.element.querySelector('.team-container');
for (const memberContainer of this.element.querySelectorAll('.team-member-container'))
teamContainer.appendChild(memberContainer);
}
return parts;
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.isGM = game.user.isGM;
context.isEditable = this.getIsEditable();
context.isEditable =
game.user.isGM ||
this.party.system.partyMembers.some(actor => {
const selected = Boolean(this.party.system.groupRoll.participants[actor.id]);
return selected && actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER);
});
context.fields = this.party.system.schema.fields.groupRoll.fields;
context.data = this.party.system.groupRoll;
context.traitOptions = CONFIG.DH.ACTOR.abilities;
context.members = {};
context.aidKeys = Object.keys(this.party.system.groupRoll.aidingCharacters);
context.allHaveRolled = Object.keys(context.data.participants).every(key => {
const data = context.data.participants[key];
return Boolean(data.rollData);
@ -145,6 +128,7 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
async _preparePartContext(partId, context, options) {
const partContext = await super._preparePartContext(partId, context, options);
partContext.partId = partId;
partContext.leader = this.getRollCharacterData(this.party.system.groupRoll.leader);
switch (partId) {
case 'initialization':
@ -162,19 +146,14 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
partContext.canStartGroupRoll = selectedMembers.length > 1 && this.leader?.memberId;
partContext.openForAllPlayers = this.openForAllPlayers;
break;
case 'leader':
partContext.leader = this.getRollCharacterData(this.party.system.groupRoll.leader);
break;
case 'groupRoll':
case 'result':
const leader = this.party.system.groupRoll.leader;
partContext.hasRolled =
leader?.rollData ||
Object.values(this.party.system.groupRoll?.aidingCharacters ?? {}).some(
x => x.successfull !== null
);
Object.values(this.party.system.groupRoll?.aidingCharacters ?? {}).some(x => x.successful !== null);
const { modifierTotal, modifiers } = Object.values(this.party.system.groupRoll.aidingCharacters).reduce(
(acc, curr) => {
const modifier = curr.successfull === true ? 1 : curr.successfull === false ? -1 : null;
const modifier = curr.successful === true ? 1 : curr.successful === false ? -1 : null;
if (modifier) {
acc.modifierTotal += modifier;
acc.modifiers.push(modifier);
@ -200,7 +179,7 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
case 'footer':
partContext.canFinishRoll =
Boolean(this.party.system.groupRoll.leader?.rollData) &&
Object.values(this.party.system.groupRoll.aidingCharacters).every(x => x.successfull !== null);
Object.values(this.party.system.groupRoll.aidingCharacters).every(x => x.successful !== null);
break;
}
@ -216,20 +195,42 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
if (!data) return {};
const actor = game.actors.get(data.id);
const isLeader = data === this.party.system.groupRoll.leader;
const roll = data.roll;
const withTypeSuffix = !roll ? null : roll.isCritical ? 'criticalShort' : roll.withHope ? 'hope' : 'fear';
const thing = withTypeSuffix ? _loc(`DAGGERHEART.GENERAL.${withTypeSuffix}`) : null;
return {
...data,
type: isLeader ? 'leader' : 'aid',
basePath: isLeader ? 'system.groupRoll.leader' : `system.groupRoll.aidingCharacters.${data.id}`,
rollChoiceLabel: _loc(CONFIG.DH.ACTOR.abilities[data.rollChoice]?.label),
roll: data.roll,
isEditable: actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER),
isEditable: actor?.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER),
key: partId,
readyToRoll: Boolean(data.rollChoice),
hasRolled: Boolean(data.rollData)
hasRolled: Boolean(data.rollData),
modifier: data.successful ? 1 : data.successful === false ? -1 : 0,
withLabelShort: thing ? _loc('DAGGERHEART.GENERAL.withThing', { thing }) : null
};
}
#getCharacterDataById(id) {
if (!id) return null;
const groupRoll = this.party.system.groupRoll;
if (id === 'leader' || id === groupRoll.leader?.id) {
return { data: groupRoll.leader, basePath: 'system.groupRoll.leader' };
} else if (id in groupRoll.aidingCharacters) {
return { data: groupRoll.aidingCharacters[id], basePath: `system.groupRoll.aidingCharacters.${id}` };
}
return null;
}
static async updateData(event, _, formData) {
const partyData = foundry.utils.expandObject(formData.object);
this.updatePartyData(partyData, this.getUpdatingParts(event.target));
}
@ -246,7 +247,7 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
});
};
await emitAsGM(
await emitGMUpdate(
GMUpdateEvent.UpdateDocument,
gmUpdate,
update,
@ -256,26 +257,19 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
}
getUpdatingParts(target) {
const { initialization, leader, groupRoll, footer } = this.constructor.PARTS;
const { initialization, leader, result, footer } = this.constructor.PARTS;
const isInitialization = this.tabGroups.application === initialization.id;
const updatingMember = target.closest('.team-member-container')?.dataset?.memberKey;
const updatingLeader = target.closest('.main-character-outer-container');
const updatingMember = target.closest('.member-roll-container.aid')?.dataset?.memberKey;
const updatingLeader = target.closest('.member-roll-container.leader');
return [
...(isInitialization ? [initialization.id] : []),
...(updatingMember ? [updatingMember] : []),
...(updatingLeader ? [leader.id] : []),
...(!isInitialization ? [groupRoll.id, footer.id] : [])
...(!isInitialization ? [result.id, footer.id] : [])
];
}
getIsEditable() {
return this.party.system.partyMembers.some(actor => {
const selected = Boolean(this.party.system.groupRoll.participants[actor.id]);
return selected && actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER);
});
}
groupRollRefresh = ({ refreshType, action, parts }) => {
if (refreshType !== RefreshType.GroupRoll) return;
@ -304,6 +298,9 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
static #toggleSelectMember(_, button) {
const member = this.partyMembers.find(x => x.id === button.dataset.id);
member.selected = !member.selected;
if (this.leader?.memberId === member.id) {
this.leader = null;
}
this.render();
}
@ -343,11 +340,14 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
}
//#endregion
async makeRoll(button, characterData, path) {
const actor = game.actors.find(x => x.id === characterData.id);
/** @this GroupRollDialog */
static async #makeRoll(_event, button) {
const member = button.closest('[data-member-key]').dataset.memberKey;
const { data, basePath } = this.#getCharacterDataById(member);
const actor = game.actors.find(x => x.id === data.id);
if (!actor) return;
const result = await actor.rollTrait(characterData.rollChoice, {
const result = await actor.rollTrait(data.rollChoice, {
skips: {
createMessage: true,
resources: true,
@ -356,53 +356,38 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
});
if (!result) return;
if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
const rollData = result.messageRoll.toJSON();
delete rollData.options.messageRoll;
this.updatePartyData(
{
[path]: rollData
[basePath]: { rollData, successful: null }
},
this.getUpdatingParts(button)
);
}
static async #makeRoll(_event, button) {
const { member } = button.dataset;
const character = this.party.system.groupRoll.aidingCharacters[member];
this.makeRoll(button, character, `system.groupRoll.aidingCharacters.${member}.rollData`);
}
static async #makeLeaderRoll(_event, button) {
const character = this.party.system.groupRoll.leader;
this.makeRoll(button, character, 'system.groupRoll.leader.rollData');
}
async removeRoll(button, path) {
/** @this GroupRollDialog */
static async #removeRoll(_event, button) {
const member = button.closest('[data-member-key]').dataset.memberKey;
const { basePath } = this.#getCharacterDataById(member);
this.updatePartyData(
{
[path]: {
[basePath]: {
rollData: null,
rollChoice: null,
selected: false,
successfull: null
successful: null
}
},
this.getUpdatingParts(button)
);
}
static async #removeRoll(_event, button) {
this.removeRoll(button, `system.groupRoll.aidingCharacters.${button.dataset.member}`);
}
static async #removeLeaderRoll(_event, button) {
this.removeRoll(button, 'system.groupRoll.leader');
}
async rerollDice(button, data, path) {
/** @this GroupRollDialog */
static async #rerollDice(_, button) {
const { diceType } = button.dataset;
const { data, basePath } = this.#getCharacterDataById(button.dataset.member);
const dieIndex = diceType === 'hope' ? 0 : diceType === 'fear' ? 1 : 2;
const newRoll = game.system.api.dice.DualityRoll.fromData(data.rollData);
@ -416,31 +401,19 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
const rollData = newRoll.toJSON();
this.updatePartyData(
{
[path]: rollData
[`${basePath}.rollData`]: rollData
},
this.getUpdatingParts(button)
);
}
static async #rerollDice(_, button) {
const { member } = button.dataset;
this.rerollDice(
button,
this.party.system.groupRoll.aidingCharacters[member],
`system.groupRoll.aidingCharacters.${member}.rollData`
);
}
static async #rerollLeaderDice(_, button) {
this.rerollDice(button, this.party.system.groupRoll.leader, `system.groupRoll.leader.rollData`);
}
static #markSuccessfull(_event, button) {
const previousValue = this.party.system.groupRoll.aidingCharacters[button.dataset.member].successfull;
const newValue = Boolean(button.dataset.successfull === 'true');
static #markSuccessful(_event, button) {
const memberKey = button.closest('[data-member-key]').dataset.memberKey;
const previousValue = this.party.system.groupRoll.aidingCharacters[memberKey].successful;
const newValue = Boolean(button.dataset.success === 'true');
this.updatePartyData(
{
[`system.groupRoll.aidingCharacters.${button.dataset.member}.successfull`]:
[`system.groupRoll.aidingCharacters.${memberKey}.successful`]:
previousValue === newValue ? null : newValue
},
this.getUpdatingParts(button)
@ -484,7 +457,7 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
static async #finishRoll() {
const totalRoll = this.party.system.groupRoll.leader.roll;
for (const character of Object.values(this.party.system.groupRoll.aidingCharacters)) {
totalRoll.terms.push(new foundry.dice.terms.OperatorTerm({ operator: character.successfull ? '+' : '-' }));
totalRoll.terms.push(new foundry.dice.terms.OperatorTerm({ operator: character.successful ? '+' : '-' }));
totalRoll.terms.push(new foundry.dice.terms.NumericTerm({ number: 1 }));
}

View file

@ -1,280 +0,0 @@
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export default class RerollDamageDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(message, options = {}) {
super(options);
this.message = message;
this.damage = Object.keys(message.system.damage).reduce((acc, typeKey) => {
const type = message.system.damage[typeKey];
acc[typeKey] = Object.keys(type.parts).reduce((acc, partKey) => {
const part = type.parts[partKey];
acc[partKey] = Object.keys(part.dice).reduce((acc, diceKey) => {
const dice = part.dice[diceKey];
const activeResults = dice.results.filter(x => x.active);
acc[diceKey] = {
dice: dice.dice,
selectedResults: activeResults.length,
maxSelected: activeResults.length,
results: activeResults.map(x => ({ ...x, selected: true }))
};
return acc;
}, {});
return acc;
}, {});
return acc;
}, {});
}
static DEFAULT_OPTIONS = {
id: 'reroll-dialog',
classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'reroll-dialog'],
window: {
icon: 'fa-solid fa-dice'
},
actions: {
toggleResult: RerollDamageDialog.#toggleResult,
selectRoll: RerollDamageDialog.#selectRoll,
doReroll: RerollDamageDialog.#doReroll,
save: RerollDamageDialog.#save
}
};
/** @override */
static PARTS = {
main: {
id: 'main',
template: 'systems/daggerheart/templates/dialogs/rerollDialog/damage/main.hbs'
},
footer: {
id: 'footer',
template: 'systems/daggerheart/templates/dialogs/rerollDialog/footer.hbs'
}
};
get title() {
return game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.damageTitle');
}
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
htmlElement.querySelectorAll('.to-reroll-input').forEach(element => {
element.addEventListener('change', this.toggleDice.bind(this));
});
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.damage = this.damage;
context.disabledReroll = !this.getRerollDice().length;
context.saveDisabled = !this.isSelectionDone();
return context;
}
static async #save() {
const update = {
'system.damage': Object.keys(this.damage).reduce((acc, typeKey) => {
const type = this.damage[typeKey];
let typeTotal = 0;
const messageType = this.message.system.damage[typeKey];
const parts = Object.keys(type).map(partKey => {
const part = type[partKey];
const messagePart = messageType.parts[partKey];
let partTotal = messagePart.modifierTotal;
const dice = Object.keys(part).map(diceKey => {
const dice = part[diceKey];
const total = dice.results.reduce((acc, result) => {
if (result.active) acc += result.result;
return acc;
}, 0);
partTotal += total;
const messageDice = messagePart.dice[diceKey];
return {
...messageDice,
total: total,
results: dice.results.map(x => ({
...x,
hasRerolls: dice.results.length > 1
}))
};
});
typeTotal += partTotal;
return {
...messagePart,
total: partTotal,
dice: dice
};
});
acc[typeKey] = {
...messageType,
total: typeTotal,
parts: parts
};
return acc;
}, {})
};
await this.message.update(update);
await this.close();
}
getRerollDice() {
const rerollDice = [];
Object.keys(this.damage).forEach(typeKey => {
const type = this.damage[typeKey];
Object.keys(type).forEach(partKey => {
const part = type[partKey];
Object.keys(part).forEach(diceKey => {
const dice = part[diceKey];
Object.keys(dice.results).forEach(resultKey => {
const result = dice.results[resultKey];
if (result.toReroll) {
rerollDice.push({
...result,
dice: dice.dice,
type: typeKey,
part: partKey,
dice: diceKey,
result: resultKey
});
}
});
});
});
});
return rerollDice;
}
isSelectionDone() {
const diceFinishedData = [];
Object.keys(this.damage).forEach(typeKey => {
const type = this.damage[typeKey];
Object.keys(type).forEach(partKey => {
const part = type[partKey];
Object.keys(part).forEach(diceKey => {
const dice = part[diceKey];
const selected = dice.results.reduce((acc, result) => acc + (result.active ? 1 : 0), 0);
diceFinishedData.push(selected === dice.maxSelected);
});
});
});
return diceFinishedData.every(x => x);
}
toggleDice(event) {
const target = event.target;
const { type, part, dice } = target.dataset;
const toggleDice = this.damage[type][part][dice];
const existingDiceRerolls = this.getRerollDice().filter(
x => x.type === type && x.part === part && x.dice === dice
);
const allRerolled = existingDiceRerolls.length === toggleDice.results.filter(x => x.active).length;
toggleDice.toReroll = !allRerolled;
toggleDice.results.forEach(result => {
if (result.active) {
result.toReroll = !allRerolled;
}
});
this.render();
}
static #toggleResult(event) {
event.stopPropagation();
const target = event.target.closest('.to-reroll-result');
const { type, part, dice, result } = target.dataset;
const toggleDice = this.damage[type][part][dice];
const toggleResult = toggleDice.results[result];
toggleResult.toReroll = !toggleResult.toReroll;
const existingDiceRerolls = this.getRerollDice().filter(
x => x.type === type && x.part === part && x.dice === dice
);
const allToReroll = existingDiceRerolls.length === toggleDice.results.filter(x => x.active).length;
toggleDice.toReroll = allToReroll;
this.render();
}
static async #selectRoll(_, button) {
const { type, part, dice, result } = button.dataset;
const diceVal = this.damage[type][part][dice];
const diceResult = diceVal.results[result];
if (!diceResult.active && diceVal.results.filter(x => x.active).length === diceVal.maxSelected) {
return ui.notifications.warn(
game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.deselectDiceNotification')
);
}
if (diceResult.active) {
diceVal.toReroll = false;
diceResult.toReroll = false;
}
diceVal.selectedResults += diceResult.active ? -1 : 1;
diceResult.active = !diceResult.active;
this.render();
}
static async #doReroll() {
const toReroll = this.getRerollDice().map(x => {
const { type, part, dice, result } = x;
const diceData = this.damage[type][part][dice].results[result];
return {
...diceData,
dice: this.damage[type][part][dice].dice,
typeKey: type,
partKey: part,
diceKey: dice,
resultsIndex: result
};
});
const roll = await new Roll(toReroll.map(x => `1${x.dice}`).join(' + ')).evaluate();
if (game.modules.get('dice-so-nice')?.active) {
const diceSoNiceRoll = {
_evaluated: true,
dice: roll.dice,
options: { appearance: {} }
};
await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true);
}
toReroll.forEach((data, index) => {
const { typeKey, partKey, diceKey, resultsIndex } = data;
const rerolledDice = roll.dice[index];
const dice = this.damage[typeKey][partKey][diceKey];
dice.toReroll = false;
dice.results[resultsIndex].active = false;
dice.results[resultsIndex].discarded = true;
dice.results[resultsIndex].toReroll = false;
dice.results.splice(dice.results.length, 0, {
...rerolledDice.results[0],
toReroll: false,
selected: true
});
});
this.render();
}
}

View file

@ -1,279 +0,0 @@
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export default class RerollDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(message, options = {}) {
super(options);
this.message = message;
this.damage = Object.keys(message.system.damage).reduce((acc, typeKey) => {
const type = message.system.damage[typeKey];
acc[typeKey] = Object.keys(type.parts).reduce((acc, partKey) => {
const part = type.parts[partKey];
acc[partKey] = Object.keys(part.dice).reduce((acc, diceKey) => {
const dice = part.dice[diceKey];
const activeResults = dice.results.filter(x => x.active);
acc[diceKey] = {
dice: dice.dice,
selectedResults: activeResults.length,
maxSelected: activeResults.length,
results: activeResults.map(x => ({ ...x, selected: true }))
};
return acc;
}, {});
return acc;
}, {});
return acc;
}, {});
}
static DEFAULT_OPTIONS = {
id: 'reroll-dialog',
classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'reroll-dialog'],
window: {
icon: 'fa-solid fa-dice'
},
actions: {
toggleResult: RerollDialog.#toggleResult,
selectRoll: RerollDialog.#selectRoll,
doReroll: RerollDialog.#doReroll,
save: RerollDialog.#save
}
};
/** @override */
static PARTS = {
main: {
id: 'main',
template: 'systems/daggerheart/templates/dialogs/rerollDialog/main.hbs'
},
footer: {
id: 'footer',
template: 'systems/daggerheart/templates/dialogs/rerollDialog/footer.hbs'
}
};
get title() {
return game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.title');
}
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
htmlElement.querySelectorAll('.to-reroll-input').forEach(element => {
element.addEventListener('change', this.toggleDice.bind(this));
});
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.damage = this.damage;
context.disabledReroll = !this.getRerollDice().length;
context.saveDisabled = !this.isSelectionDone();
return context;
}
static async #save() {
const update = {
'system.damage': Object.keys(this.damage).reduce((acc, typeKey) => {
const type = this.damage[typeKey];
let typeTotal = 0;
const messageType = this.message.system.damage[typeKey];
const parts = Object.keys(type).map(partKey => {
const part = type[partKey];
const messagePart = messageType.parts[partKey];
let partTotal = messagePart.modifierTotal;
const dice = Object.keys(part).map(diceKey => {
const dice = part[diceKey];
const total = dice.results.reduce((acc, result) => {
if (result.active) acc += result.result;
return acc;
}, 0);
partTotal += total;
const messageDice = messagePart.dice[diceKey];
return {
...messageDice,
total: total,
results: dice.results.map(x => ({
...x,
hasRerolls: dice.results.length > 1
}))
};
});
typeTotal += partTotal;
return {
...messagePart,
total: partTotal,
dice: dice
};
});
acc[typeKey] = {
...messageType,
total: typeTotal,
parts: parts
};
return acc;
}, {})
};
await this.message.update(update);
await this.close();
}
getRerollDice() {
const rerollDice = [];
Object.keys(this.damage).forEach(typeKey => {
const type = this.damage[typeKey];
Object.keys(type).forEach(partKey => {
const part = type[partKey];
Object.keys(part).forEach(diceKey => {
const dice = part[diceKey];
Object.keys(dice.results).forEach(resultKey => {
const result = dice.results[resultKey];
if (result.toReroll) {
rerollDice.push({
...result,
dice: dice.dice,
type: typeKey,
part: partKey,
dice: diceKey,
result: resultKey
});
}
});
});
});
});
return rerollDice;
}
isSelectionDone() {
const diceFinishedData = [];
Object.keys(this.damage).forEach(typeKey => {
const type = this.damage[typeKey];
Object.keys(type).forEach(partKey => {
const part = type[partKey];
Object.keys(part).forEach(diceKey => {
const dice = part[diceKey];
const selected = dice.results.reduce((acc, result) => acc + (result.active ? 1 : 0), 0);
diceFinishedData.push(selected === dice.maxSelected);
});
});
});
return diceFinishedData.every(x => x);
}
toggleDice(event) {
const target = event.target;
const { type, part, dice } = target.dataset;
const toggleDice = this.damage[type][part][dice];
const existingDiceRerolls = this.getRerollDice().filter(
x => x.type === type && x.part === part && x.dice === dice
);
const allRerolled = existingDiceRerolls.length === toggleDice.results.filter(x => x.active).length;
toggleDice.toReroll = !allRerolled;
toggleDice.results.forEach(result => {
if (result.active) {
result.toReroll = !allRerolled;
}
});
this.render();
}
static #toggleResult(event) {
event.stopPropagation();
const target = event.target.closest('.to-reroll-result');
const { type, part, dice, result } = target.dataset;
const toggleDice = this.damage[type][part][dice];
const toggleResult = toggleDice.results[result];
toggleResult.toReroll = !toggleResult.toReroll;
const existingDiceRerolls = this.getRerollDice().filter(
x => x.type === type && x.part === part && x.dice === dice
);
const allToReroll = existingDiceRerolls.length === toggleDice.results.length;
toggleDice.toReroll = allToReroll;
this.render();
}
static async #selectRoll(_, button) {
const { type, part, dice, result } = button.dataset;
const diceVal = this.damage[type][part][dice];
const diceResult = diceVal.results[result];
if (!diceResult.active && diceVal.results.filter(x => x.active).length === diceVal.maxSelected) {
return ui.notifications.warn(
game.i18n.localize('DAGGERHEART.APPLICATIONS.RerollDialog.deselectDiceNotification')
);
}
if (diceResult.active) {
diceVal.toReroll = false;
diceResult.toReroll = false;
}
diceVal.selectedResults += diceResult.active ? -1 : 1;
diceResult.active = !diceResult.active;
this.render();
}
static async #doReroll() {
const toReroll = this.getRerollDice().map(x => {
const { type, part, dice, result } = x;
const diceData = this.damage[type][part][dice].results[result];
return {
...diceData,
dice: this.damage[type][part][dice].dice,
typeKey: type,
partKey: part,
diceKey: dice,
resultsIndex: result
};
});
const roll = await new Roll(toReroll.map(x => `1${x.dice}`).join(' + ')).evaluate();
if (game.modules.get('dice-so-nice')?.active) {
const diceSoNiceRoll = {
_evaluated: true,
dice: roll.dice,
options: { appearance: {} }
};
await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true);
}
toReroll.forEach((data, index) => {
const { typeKey, partKey, diceKey, resultsIndex } = data;
const rerolledDice = roll.dice[index];
const dice = this.damage[typeKey][partKey][diceKey];
dice.toReroll = false;
dice.results[resultsIndex].active = false;
dice.results[resultsIndex].discarded = true;
dice.results[resultsIndex].toReroll = false;
dice.results.splice(dice.results.length, 0, {
...rerolledDice.results[0],
toReroll: false,
selected: true
});
});
this.render();
}
}

View file

@ -1,4 +1,4 @@
import { itemAbleRollParse } from '../../helpers/utils.mjs';
import { itemAbleRollParse, triggerChatRollFx } from '../../helpers/utils.mjs';
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
@ -69,7 +69,7 @@ export default class ResourceDiceDialog extends HandlebarsApplicationMixin(Appli
const max = itemAbleRollParse(this.item.system.resource.max, this.actor, this.item);
const diceFormula = `${max}${this.item.system.resource.dieFaces}`;
const roll = await new Roll(diceFormula).evaluate();
if (game.modules.get('dice-so-nice')?.active) await game.dice3d.showForRoll(roll, game.user, true);
await triggerChatRollFx([roll]);
this.rollValues = roll.terms[0].results.map(x => ({ value: x.result, used: false }));
this.resetUsed = true;

View file

@ -1,13 +1,13 @@
import { MemberData } from '../../data/tagTeamData.mjs';
import { getCritDamageBonus } from '../../helpers/utils.mjs';
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
import Party from '../sheets/actors/party.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export default class TagTeamDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(party) {
super();
super({ id: `TagTeamDialog-${party.id}` });
this.party = party;
this.partyMembers = party.system.partyMembers
@ -36,9 +36,11 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
static DEFAULT_OPTIONS = {
tag: 'form',
id: 'TagTeamDialog',
classes: ['daggerheart', 'views', 'dh-style', 'dialog', 'tag-team-dialog'],
position: { width: 550, height: 'auto' },
window: {
icon: 'fa-solid fa-user-group'
},
actions: {
toggleSelectMember: TagTeamDialog.#toggleSelectMember,
startTagTeamRoll: TagTeamDialog.#startTagTeamRoll,
@ -60,13 +62,17 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
id: 'initialization',
template: 'systems/daggerheart/templates/dialogs/tagTeamDialog/initialization.hbs'
},
tagTeamRoll: {
id: 'tagTeamRoll',
template: 'systems/daggerheart/templates/dialogs/tagTeamDialog/tagTeamRoll.hbs'
},
rollSelection: {
id: 'rollSelection',
template: 'systems/daggerheart/templates/dialogs/tagTeamDialog/rollSelection.hbs'
},
tagTeamRoll: {
id: 'tagTeamRoll',
template: 'systems/daggerheart/templates/dialogs/tagTeamDialog/tagTeamRoll.hbs'
result: {
id: 'result',
template: 'systems/daggerheart/templates/dialogs/tagTeamDialog/result.hbs'
}
};
@ -97,41 +103,25 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
}
_configureRenderParts(options) {
const { initialization, rollSelection, tagTeamRoll } = super._configureRenderParts(options);
const augmentedParts = { initialization };
const parts = super._configureRenderParts(options);
for (const memberKey of Object.keys(this.party.system.tagTeam.members)) {
augmentedParts[memberKey] = {
parts[memberKey] = {
id: memberKey,
template: 'systems/daggerheart/templates/dialogs/tagTeamDialog/tagTeamMember.hbs'
};
}
augmentedParts.rollSelection = rollSelection;
augmentedParts.tagTeamRoll = tagTeamRoll;
return augmentedParts;
}
/**@inheritdoc */
async _onRender(context, options) {
await super._onRender(context, options);
// if (this.element.querySelector('.roll-selection')) {
// for (const element of this.element.querySelectorAll('.team-member-container')) {
// element.classList.add('select-padding');
// }
// }
if (this.element.querySelector('.team-container')) return;
const initializationPart = this.element.querySelector('.initialization-container');
initializationPart.insertAdjacentHTML('afterend', '<div class="team-container"></div>');
const teamContainer = this.element.querySelector('.team-container');
for (const memberContainer of this.element.querySelectorAll('.team-member-container'))
teamContainer.appendChild(memberContainer);
return parts;
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.isEditable = this.getIsEditable();
context.isEditable =
game.user.isGM ||
this.party.system.partyMembers.some(actor => {
const selected = Boolean(this.party.system.tagTeam.members[actor.id]);
return selected && actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER);
});
context.fields = this.party.system.schema.fields.tagTeam.fields;
context.data = this.party.system.tagTeam;
context.rollTypes = CONFIG.DH.GENERAL.tagTeamRollTypes;
@ -167,6 +157,9 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
partContext.initiatorDisabled = !selectedMembers.length;
partContext.openForAllPlayers = this.openForAllPlayers;
break;
case 'tagTeamRoll':
partContext.memberKeys = Object.keys(this.party.system.tagTeam.members);
break;
case 'rollSelection':
partContext.members = Object.keys(this.party.system.tagTeam.members).reduce((acc, key) => {
@ -175,7 +168,7 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
return acc;
}, {});
break;
case 'tagTeamRoll':
case 'result':
const selectedRoll = Object.values(this.party.system.tagTeam.members).find(member => member.selected);
const critSelected = !selectedRoll
? undefined
@ -191,59 +184,58 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
}
if (Object.keys(this.party.system.tagTeam.members).includes(partId)) {
const data = this.party.system.tagTeam.members[partId];
const actor = game.actors.get(partId);
const rollOptions = [];
const damageRollOptions = [];
for (const item of actor.items) {
if (item.system.metadata.hasActions) {
const actions = [...item.system.actions, ...(item.system.attack ? [item.system.attack] : [])];
for (const action of actions) {
if (action.hasRoll) {
const actionItem = {
value: action.uuid,
label: action.name,
group: item.name,
baseAction: action.baseAction
};
if (action.hasDamage) damageRollOptions.push(actionItem);
else rollOptions.push(actionItem);
}
}
}
}
const selectedRoll = Object.values(this.party.system.tagTeam.members).find(member => member.selected);
const critSelected = !selectedRoll
? undefined
: (selectedRoll?.rollData?.options?.roll?.isCritical ?? false);
const damage = data.rollData?.options?.damage;
partContext.hasDamage |= Boolean(damage);
const critHitPointsDamage = await this.getCriticalDamage(damage);
partContext.members[partId] = {
...data,
roll: data.roll,
isEditable: actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER),
key: partId,
readyToRoll: Boolean(data.rollChoice),
hasRolled: Boolean(data.rollData),
rollOptions,
damageRollOptions,
damage: damage,
critDamage: critHitPointsDamage,
useCritDamage: critSelected || (critSelected === undefined && data.rollData?.options?.roll?.isCritical)
};
const data = await this.#prepareMemberContext(partId);
partContext.hasDamage |= Boolean(data?.damage);
partContext.members[partId] = data;
}
return partContext;
}
async #prepareMemberContext(partId) {
const data = this.party.system.tagTeam.members[partId] ?? {};
const actor = game.actors.get(partId);
if (!actor) console.error(`Failed to get actor ${partId}`);
const rollOptions = [];
const damageRollOptions = [];
for (const item of actor?.items ?? []) {
if (!item.system.metadata.hasActions) continue;
const actions = [...item.system.actions, ...(item.system.attack ? [item.system.attack] : [])];
for (const action of actions) {
if (action.hasRoll) {
const collection = action.hasDamage ? damageRollOptions : rollOptions;
collection.push({
value: action.uuid,
label: action.name,
group: item.name,
baseAction: action.baseAction
});
}
}
}
const selectedRoll = Object.values(this.party.system.tagTeam.members).find(member => member.selected);
const critSelected = !selectedRoll ? undefined : (selectedRoll?.rollData?.options?.roll?.isCritical ?? false);
const damage = data.rollData?.options?.damage;
return {
...data,
roll: data.roll,
isEditable: actor?.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER),
key: partId,
readyToRoll: Boolean(data.rollChoice),
hasRolled: Boolean(data.rollData),
rollOptions,
damageRollOptions,
damage: damage,
critDamage: await this.getCriticalDamage(damage),
useCritDamage: critSelected || (critSelected === undefined && data.rollData?.options?.roll?.isCritical)
};
}
getUpdatingParts(target) {
const { initialization, rollSelection, tagTeamRoll } = this.constructor.PARTS;
const { initialization, rollSelection, result } = this.constructor.PARTS;
const isInitialization = this.tabGroups.application === initialization.id;
const updatingMember = target.closest('.team-member-container')?.dataset?.memberKey;
@ -251,7 +243,7 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
...(isInitialization ? [initialization.id] : []),
...(updatingMember ? [updatingMember] : []),
...(!isInitialization ? [rollSelection.id] : []),
...(!isInitialization ? [tagTeamRoll.id] : [])
...(!isInitialization ? [result.id] : [])
];
}
@ -274,7 +266,7 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
});
};
await emitAsGM(
await emitGMUpdate(
GMUpdateEvent.UpdateDocument,
gmUpdate,
update,
@ -285,13 +277,6 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
);
}
getIsEditable() {
return this.party.system.partyMembers.some(actor => {
const selected = Boolean(this.party.system.tagTeam.members[actor.id]);
return selected && actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER);
});
}
tagTeamRefresh = ({ refreshType, action, parts }) => {
if (refreshType !== RefreshType.TagTeamRoll) return;
@ -446,8 +431,6 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
if (!result) return;
if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
const rollData = result.messageRoll.toJSON();
delete rollData.options.messageRoll;
this.updatePartyData(
@ -663,42 +646,50 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
}
async getJoinedRoll({ overrideIsCritical, displayVersion } = {}) {
const memberValues = Object.values(this.party.system.tagTeam.members);
const selectedRoll = memberValues.find(x => x.selected);
let baseMainRoll = selectedRoll ?? memberValues[0];
let baseSecondaryRoll = selectedRoll
? memberValues.find(x => !x.selected)
: memberValues.length > 1
? memberValues[1]
: null;
try {
const memberValues = Object.values(this.party.system.tagTeam.members);
const selectedRoll = memberValues.find(x => x.selected);
const baseMainRoll = selectedRoll ?? memberValues[0];
const baseSecondaryRoll = selectedRoll
? memberValues.find(x => !x.selected)
: memberValues.length > 1
? memberValues[1]
: null;
if (!baseMainRoll?.rollData || !baseSecondaryRoll) return null;
if (!baseMainRoll?.rollData || !baseSecondaryRoll) return null;
const mainRoll = new MemberData(baseMainRoll.toObject());
const secondaryRollData = new MemberData(baseSecondaryRoll.toObject()).rollData;
const systemData = mainRoll.rollData.options;
const isCritical = overrideIsCritical ?? systemData.roll.isCritical;
if (isCritical) systemData.damage = await this.getCriticalDamage(systemData.damage);
const mainRoll = new MemberData(baseMainRoll.toObject());
const secondaryRollData = new MemberData(baseSecondaryRoll.toObject()).rollData;
const systemData = mainRoll.rollData.options;
const isCritical = overrideIsCritical ?? systemData.roll.isCritical;
if (isCritical) systemData.damage = await this.getCriticalDamage(systemData.damage);
if (secondaryRollData?.options.hasDamage) {
const secondaryDamage = (displayVersion ? overrideIsCritical : isCritical)
? await this.getCriticalDamage(secondaryRollData.options.damage)
: secondaryRollData.options.damage;
if (systemData.damage) {
for (const key in secondaryDamage) {
const damage = secondaryDamage[key];
systemData.damage[key].formula = [systemData.damage[key].formula, damage.formula]
.filter(x => x)
.join(' + ');
systemData.damage[key].total += damage.total;
systemData.damage[key].parts.push(...damage.parts);
if (secondaryRollData?.options.hasDamage) {
const secondaryDamage = (displayVersion ? overrideIsCritical : isCritical)
? await this.getCriticalDamage(secondaryRollData.options.damage)
: secondaryRollData.options.damage;
if (systemData.damage) {
for (const [key, damage] of Object.entries(secondaryDamage ?? {})) {
if (key in systemData.damage) {
systemData.damage[key].formula = [systemData.damage[key]?.formula, damage.formula]
.filter(x => x)
.join(' + ');
systemData.damage[key].total += damage.total;
systemData.damage[key].parts.push(...damage.parts);
} else {
systemData.damage[key] = damage;
}
}
} else {
systemData.damage = secondaryDamage;
}
} else {
systemData.damage = secondaryDamage;
}
}
return mainRoll;
return mainRoll;
} catch (err) {
console.error(err);
return null;
}
}
static async #onCancelRoll(_event, _button, options = { confirm: true }) {

View file

@ -124,7 +124,9 @@ export default class DHTokenHUD extends foundry.applications.hud.TokenHUD {
const animationDuration = 500;
const scene = game.scenes.get(game.user.viewedScene);
/* getDependentTokens returns already removed tokens with id = null. Need to filter that until it's potentially fixed from Foundry */
const activeTokens = actors.flatMap(member => member.getDependentTokens({ scenes: scene }).filter(x => x._id));
const activeTokens = actors.flatMap(member =>
member.getDependentTokens({ scenes: scene }).filter(x => x._id && !x._destroyed)
);
const { x: actorX, y: actorY } = this.document;
if (activeTokens.length > 0) {
for (let token of activeTokens) {

View file

@ -154,6 +154,7 @@ export default class DhCharacterLevelUp extends LevelUpBase {
if (multiclasses?.[0]) {
const data = multiclasses[0];
const multiclass = data.data.length > 0 ? await foundry.utils.fromUuid(data.data[0]) : {};
const subclasses = (await multiclass?.system?.fetchSubclasses()) ?? [];
context.multiclass = {
...data,
@ -173,13 +174,12 @@ export default class DhCharacterLevelUp extends LevelUpBase {
alreadySelected
};
}) ?? [],
subclasses:
multiclass?.system?.subclasses.map(subclass => ({
...subclass,
uuid: subclass.uuid,
selected: data.secondaryData.subclass === subclass.uuid,
disabled: data.secondaryData.subclass && data.secondaryData.subclass !== subclass.uuid
})) ?? [],
subclasses: subclasses.map(subclass => ({
...subclass,
uuid: subclass.uuid,
selected: data.secondaryData.subclass === subclass.uuid,
disabled: data.secondaryData.subclass && data.secondaryData.subclass !== subclass.uuid
})),
compendium: 'classes',
limit: 1
};

View file

@ -358,14 +358,14 @@ export default class DhlevelUp extends HandlebarsApplicationMixin(ApplicationV2)
const experienceIncreaseTagify = htmlElement.querySelector('.levelup-experience-increases');
if (experienceIncreaseTagify) {
const allExperiences = {
...this.actor.system.experiences,
...Object.values(this.levelup.levels).reduce((acc, level) => {
for (const key of Object.keys(level.achievements.experiences)) {
acc[key] = level.achievements.experiences[key];
}
return acc;
}, {})
}, {}),
...this.actor.system.experiences
};
tagifyElement(
experienceIncreaseTagify,

View file

@ -120,12 +120,6 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S
foundry.utils.fromUuidSync(x)
);
for (const key of Object.keys(this.document._source.flags.daggerheart?.sceneEnvironments ?? {})) {
if (!submitData.flags.daggerheart.sceneEnvironments[key]) {
submitData.flags.daggerheart.sceneEnvironments[key] = _del;
}
}
super._processSubmitData(event, form, submitData, options);
}
}

View file

@ -1,6 +1,5 @@
import { DhHomebrew } from '../../data/settings/_module.mjs';
import { Resource } from '../../data/settings/Homebrew.mjs';
import { slugify } from '../../helpers/utils.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -112,7 +111,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
switch (partId) {
case 'domains':
const selectedDomain = this.selected.domain ? this.settings.domains[this.selected.domain] : null;
const selectedDomain = this.settings.domains[this.selected.domain] ?? null;
const enrichedDescription = selectedDomain
? await foundry.applications.ux.TextEditor.implementation.enrichHTML(selectedDomain.description)
: null;
@ -403,12 +402,12 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
const domainName = button.form.elements.domainName.value;
if (!domainName) return;
const newSlug = slugify(domainName);
const newSlug = domainName.slugify();
const existingDomains = [
...Object.values(this.settings.domains),
...Object.values(CONFIG.DH.DOMAIN.domains)
];
if (existingDomains.find(x => slugify(game.i18n.localize(x.label)) === newSlug)) {
if (existingDomains.find(x => x.id === newSlug)) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.domains.duplicateDomain'));
return;
}
@ -529,7 +528,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
const identifier = button.form.elements.identifier.value;
if (!identifier) return;
const sluggedIdentifier = slugify(identifier);
const sluggedIdentifier = identifier.slugify();
await this.settings.updateSource({
[`resources.${actorType}.resources.${sluggedIdentifier}`]: Resource.getDefaultResourceData(identifier)

View file

@ -2,6 +2,7 @@ export { default as ActionConfig } from './action-config.mjs';
export { default as ActionSettingsConfig } from './action-settings-config.mjs';
export { default as CharacterSettings } from './character-settings.mjs';
export { default as AdversarySettings } from './adversary-settings.mjs';
export { default as NPCSettings } from './npc-settings.mjs';
export { default as CompanionSettings } from './companion-settings.mjs';
export { default as SettingFeatureConfig } from './setting-feature-config.mjs';
export { default as EnvironmentSettings } from './environment-settings.mjs';

View file

@ -156,7 +156,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2)
context.openSection = this.openSection;
context.tabs = this._getTabs(this.constructor.TABS);
context.config = CONFIG.DH;
if (this.action.hasDamage) {
if (this.action.damage) {
context.allDamageTypesUsed = !getUnusedDamageTypes(this.action.damage.parts).length;
if (this.action.damage.hasOwnProperty('includeBase') && this.action.type === 'attack')
@ -204,7 +204,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2)
};
}
if (this.action.parent.metadata?.isQuantifiable) {
if (this.action.parent.metadata?.isInventoryItem) {
options.quantity = {
label: 'DAGGERHEART.GENERAL.itemQuantity',
group: 'Global'
@ -302,7 +302,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2)
static addDamage(_event) {
if (!this.action.damage.parts) return;
const choices = getUnusedDamageTypes(this.action.damage.parts);
const choices = getUnusedDamageTypes(this.action._source.damage.parts);
const content = new foundry.data.fields.StringField({
label: game.i18n.localize('Damage Type'),
choices,

View file

@ -41,7 +41,7 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
* @returns {ChangeChoice { value: string, label: string, hint: string, group: string }[]}
*/
static getChangeChoices() {
const ignoredActorKeys = ['config', 'DhEnvironment', 'DhParty'];
const ignoredActorKeys = ['config', 'DhEnvironment', 'DhParty', 'DhNPC'];
const getAllLeaves = (root, group, parentPath = '') => {
const leaves = [];
@ -175,6 +175,7 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
const partContext = await super._preparePartContext(partId, context);
switch (partId) {
case 'details':
partContext.isItemEffect = partContext.isItemEffect || this.options.isSetting;
const useGeneric = game.settings.get(
CONFIG.DH.id,
CONFIG.DH.SETTINGS.gameSettings.appearance

View file

@ -110,6 +110,7 @@ export default class DHAdversarySettings extends DHBaseActorSettings {
}
async _onDrop(event) {
event.stopPropagation();
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
const item = await fromUuid(data.uuid);

View file

@ -121,6 +121,7 @@ export default class DHEnvironmentSettings extends DHBaseActorSettings {
}
async _onDrop(event) {
event.stopPropagation();
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
const item = await fromUuid(data.uuid);
if (data.fromInternal && item?.parent?.uuid === this.actor.uuid) return;
@ -138,8 +139,4 @@ export default class DHEnvironmentSettings extends DHBaseActorSettings {
this.render();
}
}
async _onDropItem(event, item) {
console.log(item);
}
}

View file

@ -0,0 +1,85 @@
import DHBaseActorSettings from '../sheets/api/actor-setting.mjs';
/**@typedef {import('@client/applications/_types.mjs').ApplicationClickAction} ApplicationClickAction */
export default class DHNPCSettings extends DHBaseActorSettings {
/**@inheritdoc */
static DEFAULT_OPTIONS = {
classes: ['npc-settings'],
position: { width: 455, height: 'auto' },
actions: {},
dragDrop: [
{ dragSelector: null, dropSelector: '.tab.features' },
{ dragSelector: '.feature-item', dropSelector: null }
]
};
/**@override */
static PARTS = {
header: {
id: 'header',
template: 'systems/daggerheart/templates/sheets-settings/npc-settings/header.hbs'
},
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
details: {
id: 'details',
template: 'systems/daggerheart/templates/sheets-settings/npc-settings/details.hbs'
},
features: {
id: 'features',
template: 'systems/daggerheart/templates/sheets-settings/npc-settings/features.hbs'
}
};
/** @override */
static TABS = {
primary: {
tabs: [{ id: 'details' }, { id: 'features' }],
initial: 'details',
labelPrefix: 'DAGGERHEART.GENERAL.Tabs'
}
};
async _prepareContext(options) {
const context = await super._prepareContext(options);
const featureForms = ['passive', 'action', 'reaction'];
context.features = context.document.system.features.sort((a, b) =>
a.system.featureForm !== b.system.featureForm
? featureForms.indexOf(a.system.featureForm) - featureForms.indexOf(b.system.featureForm)
: a.sort - b.sort
);
return context;
}
/* -------------------------------------------- */
async _onDragStart(event) {
const featureItem = event.currentTarget.closest('.feature-item');
if (featureItem) {
const feature = this.actor.items.get(featureItem.id);
const featureData = { type: 'Item', uuid: feature.uuid, fromInternal: true };
event.dataTransfer.setData('text/plain', JSON.stringify(featureData));
event.dataTransfer.setDragImage(featureItem.querySelector('img'), 60, 0);
}
}
async _onDrop(event) {
event.stopPropagation();
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event);
const item = await fromUuid(data.uuid);
if (item?.type === 'feature') {
if (data.fromInternal && item.parent?.uuid === this.actor.uuid) {
return;
}
const itemData = item.toObject();
delete itemData._id;
await this.actor.createEmbeddedDocuments('Item', [itemData]);
}
}
}

View file

@ -188,8 +188,9 @@ export default class SettingFeatureConfig extends HandlebarsApplicationMixin(App
if (type === 'effect') {
const move = foundry.utils.getProperty(this.settings, this.movePath);
for (const action of move.actions) {
const remainingEffects = action.effects.filter(x => x._id !== id);
if (action.effects.length !== remainingEffects.length) {
const actionEffects = action.effects ?? [];
const remainingEffects = actionEffects.filter(x => x._id !== id);
if (actionEffects.length !== remainingEffects.length) {
await action.update({
effects: remainingEffects.map(x => {
const { _id, ...rest } = x;

View file

@ -2,4 +2,5 @@ export { default as Adversary } from './adversary.mjs';
export { default as Character } from './character.mjs';
export { default as Companion } from './companion.mjs';
export { default as Environment } from './environment.mjs';
export { default as NPC } from './npc.mjs';
export { default as Party } from './party.mjs';

View file

@ -31,6 +31,16 @@ export default class AdversarySheet extends DHBaseActorSheet {
dragSelector: '[data-item-id][draggable="true"], [data-item-id] [draggable="true"]',
dropSelector: null
}
],
contextMenus: [
{
handler: DHBaseActorSheet.getBaseAttackContextOptions,
selector: '[data-item-uuid][data-type="attack"]',
options: {
parentClassHooks: false,
fixed: true
}
}
]
};

View file

@ -3,7 +3,7 @@ import DhDeathMove from '../../dialogs/deathMove.mjs';
import { CharacterLevelup, LevelupViewMode } from '../../levelup/_module.mjs';
import DhCharacterCreation from '../../characterCreation/characterCreation.mjs';
import FilterMenu from '../../ux/filter-menu.mjs';
import { getArmorSources, getDocFromElement, getDocFromElementSync } from '../../../helpers/utils.mjs';
import { getArmorSources, getDocFromElement, getDocFromElementSync, sortBy } from '../../../helpers/utils.mjs';
/**@typedef {import('@client/applications/_types.mjs').ApplicationClickAction} ApplicationClickAction */
@ -57,6 +57,22 @@ export default class CharacterSheet extends DHBaseActorSheet {
}
],
contextMenus: [
{
handler: CharacterSheet.#getCreationMainContextOptions,
selector: '.character-details [data-action="editDoc"]',
options: {
parentClassHooks: false,
fixed: true
}
},
{
handler: DHBaseActorSheet.getBaseAttackContextOptions,
selector: '[data-item-uuid][data-type="attack"]',
options: {
parentClassHooks: false,
fixed: true
}
},
{
handler: CharacterSheet.#getDomainCardContextOptions,
selector: '[data-item-uuid][data-type="domainCard"]',
@ -176,6 +192,9 @@ export default class CharacterSheet extends DHBaseActorSheet {
for (const input of form.querySelectorAll('input:not([type=search]), .editor.prosemirror')) {
input.disabled = disabled;
}
for (const element of form.querySelectorAll('.input[contenteditable]')) {
element.classList.toggle('disabled', disabled);
}
}
/** @inheritDoc */
@ -209,8 +228,9 @@ export default class CharacterSheet extends DHBaseActorSheet {
context.attributes = Object.keys(this.document.system.traits).reduce((acc, key) => {
acc[key] = {
...this.document.system.traits[key],
name: game.i18n.localize(CONFIG.DH.ACTOR.abilities[key].name),
verbs: CONFIG.DH.ACTOR.abilities[key].verbs.map(x => game.i18n.localize(x))
label: _loc(CONFIG.DH.ACTOR.abilities[key].label),
verbs: CONFIG.DH.ACTOR.abilities[key].verbs.map(x => game.i18n.localize(x)),
isSpellcasting: this.document.system.spellcastModifierTrait?.key === key
};
return acc;
@ -226,6 +246,11 @@ export default class CharacterSheet extends DHBaseActorSheet {
context.resources.stress.emptyPips =
context.resources.stress.max < maxResource ? maxResource - context.resources.stress.max : 0;
context.equippedItems = sortBy(
this.document.items.filter(i => i.system.equipped && (i.type === 'weapon' || i.usable)),
i => (i.type === 'weapon' ? (i.system.secondary ? 1 : 0) : 2)
);
context.beastformActive = this.document.effects.find(x => x.type === 'beastform');
return context;
@ -313,6 +338,56 @@ export default class CharacterSheet extends DHBaseActorSheet {
/* Context Menu */
/* -------------------------------------------- */
static #getCreationMainContextOptions() {
/** Returns true if the item is managed by the level up wizard. Such items shouldn't allow things like manual removal */
function isItemWizardManaged(item) {
const actor = item?.actor;
if (!actor) return false;
// If levelup automation is off in general or for this character, all items are unmanaged
// This is disabled until we have proper granted feature removal, for now this feature is to correct errors
// const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;
// if (!levelupAuto) return false;
// Core items aren't part of levelup data. TODO: add some way to flag a specific character as no auto leveling
const classPair = actor.system.class;
const coreItems = [actor.system.ancestry, actor.system.community, classPair?.value, classPair?.subclass];
if (coreItems.includes(item)) return true;
const levelups = Object.values(actor.system.levelData?.levelups) ?? [];
const uuid = item.uuid;
const sourceUuid = item._stats.compendiumSource; // on older characters this may be missing
return levelups.some(data => {
if (item.type === 'subclass') {
const selectedSubclasses = data.selections.map(s => s.secondaryData?.subclass).filter(s => !!s);
return sourceUuid
? selectedSubclasses.includes(sourceUuid)
: selectedSubclasses.length && item.system.isMulticlass;
}
const matchesCard = data.achievements.domainCards.some(i => i.itemUuid === uuid);
const matchesSelection = data.selections.some(s => s.itemUuid === uuid);
return matchesCard || matchesSelection;
});
}
return [
{
label: 'CONTROLS.CommonDelete',
icon: 'fa-solid fa-trash',
visible: target => {
const doc = getDocFromElementSync(target);
return doc?.isOwner && !isItemWizardManaged(doc);
},
onClick: async (event, target) => {
const doc = await getDocFromElement(target);
if (event.shiftKey) return doc.delete();
else return doc.deleteDialog();
}
}
];
}
/**
* Get the set of ContextMenu options for DomainCards.
* @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} - The Array of context options passed to the ContextMenu instance
@ -329,7 +404,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
const doc = getDocFromElementSync(target);
return doc?.isOwner && doc.system.inVault;
},
callback: async target => {
onClick: async (_, target) => {
const doc = await getDocFromElement(target);
const actorLoadout = doc.actor.system.loadoutSlot;
if (actorLoadout.available) return doc.update({ 'system.inVault': false });
@ -343,7 +418,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
const doc = getDocFromElementSync(target);
return doc?.isOwner && doc.system.inVault;
},
callback: async (target, event) => {
onClick: async (event, target) => {
const doc = await getDocFromElement(target);
const actorLoadout = doc.actor.system.loadoutSlot;
if (!actorLoadout.available) {
@ -382,7 +457,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
const doc = getDocFromElementSync(target);
return doc?.isOwner && !doc.system.inVault;
},
callback: async target => (await getDocFromElement(target)).update({ 'system.inVault': true })
onClick: async (_, target) => (await getDocFromElement(target)).update({ 'system.inVault': true })
}
].map(option => ({
...option,
@ -408,7 +483,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
const doc = getDocFromElementSync(target);
return doc.isOwner && doc && !doc.system.equipped;
},
callback: (target, event) => CharacterSheet.#toggleEquipItem.call(this, event, target)
onClick: (event, target) => CharacterSheet.#toggleEquipItem.call(this, event, target)
},
{
label: 'unequip',
@ -417,7 +492,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
const doc = getDocFromElementSync(target);
return doc.isOwner && doc && doc.system.equipped;
},
callback: (target, event) => CharacterSheet.#toggleEquipItem.call(this, event, target)
onClick: (event, target) => CharacterSheet.#toggleEquipItem.call(this, event, target)
}
].map(option => ({
...option,
@ -712,7 +787,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
? {
'system.linkedClass.uuid': {
key: 'system.linkedClass.uuid',
value: this.document.system.class.value._stats.compendiumSource
value: this.document.system.class.value?._stats.compendiumSource
}
}
: undefined,
@ -978,7 +1053,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
game.tooltip.activate(target, {
html,
locked: true,
cssClass: 'bordered-tooltip',
cssClass: 'bordered-tooltip dh-style',
direction: 'DOWN'
});
@ -1074,7 +1149,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
game.tooltip.activate(target, {
html,
locked: true,
cssClass: 'bordered-tooltip',
cssClass: 'bordered-tooltip dh-style',
direction: 'DOWN',
noOffset: true
});

View file

@ -11,7 +11,17 @@ export default class DhCompanionSheet extends DHBaseActorSheet {
toggleStress: DhCompanionSheet.#toggleStress,
actionRoll: DhCompanionSheet.#actionRoll,
levelManagement: DhCompanionSheet.#levelManagement
}
},
contextMenus: [
{
handler: DHBaseActorSheet.getBaseAttackContextOptions,
selector: '[data-item-uuid][data-type="attack"]',
options: {
parentClassHooks: false,
fixed: true
}
}
]
};
static PARTS = {

View file

@ -0,0 +1,136 @@
import DHBaseActorSheet from '../api/base-actor.mjs';
export default class NPCSheet extends DHBaseActorSheet {
/** @inheritDoc */
static DEFAULT_OPTIONS = {
classes: ['npc'],
position: { width: 660, height: 600 },
window: { resizable: true },
actions: {},
window: {
resizable: true,
controls: [
{
icon: 'fa-solid fa-signature',
label: 'DAGGERHEART.UI.Tooltip.configureAttribution',
action: 'editAttribution'
}
]
},
dragDrop: [
{
dragSelector: '[data-item-id][draggable="true"], [data-item-id] [draggable="true"]',
dropSelector: null
}
]
};
static PARTS = {
header: { template: 'systems/daggerheart/templates/sheets/actors/npc/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/actors/npc/navigation.hbs' },
features: {
template: 'systems/daggerheart/templates/sheets/actors/npc/features.hbs',
scrollable: ['.feature-section']
},
notes: {
template: 'systems/daggerheart/templates/sheets/actors/npc/notes.hbs'
}
};
/** @inheritdoc */
static TABS = {
primary: {
tabs: [{ id: 'notes' }, { id: 'features' }],
initial: 'notes',
labelPrefix: 'DAGGERHEART.GENERAL.Tabs'
}
};
/** @inheritdoc */
_prepareTabs(group) {
const result = super._prepareTabs(group);
if (group === 'primary') {
result.features.empty = this.document.system.features.length === 0;
}
return result;
}
/** @inheritdoc */
async _preparePartContext(partId, context, options) {
context = await super._preparePartContext(partId, context, options);
switch (partId) {
case 'header':
await this._prepareHeaderContext(context, options);
break;
case 'features':
await this._prepareFeaturesContext(context, options);
break;
case 'notes':
await this._prepareNotesContext(context, options);
break;
}
return context;
}
/**
* Prepare render context for the Header part.
* @param {ApplicationRenderContext} context
* @param {ApplicationRenderOptions} options
* @returns {Promise<void>}
* @protected
*/
async _prepareHeaderContext(context, _options) {
const { system } = this.document;
const { TextEditor } = foundry.applications.ux;
context.description = await TextEditor.implementation.enrichHTML(system.description, {
secrets: this.document.isOwner,
relativeTo: this.document
});
}
/**
* Prepare render context for the Features part.
* @param {ApplicationRenderContext} context
* @param {ApplicationRenderOptions} options
* @returns {Promise<void>}
* @protected
*/
async _prepareFeaturesContext(context, _options) {
const featureForms = ['passive', 'action', 'reaction'];
context.features = this.document.system.features.sort((a, b) =>
a.system.featureForm !== b.system.featureForm
? featureForms.indexOf(a.system.featureForm) - featureForms.indexOf(b.system.featureForm)
: a.sort - b.sort
);
}
/**
* Prepare render context for the Biography part.
* @param {ApplicationRenderContext} context
* @param {ApplicationRenderOptions} options
* @returns {Promise<void>}
* @protected
*/
async _prepareNotesContext(context, _options) {
const { system } = this.document;
const { TextEditor } = foundry.applications.ux;
const paths = {
notes: 'notes'
};
for (const [key, path] of Object.entries(paths)) {
const value = foundry.utils.getProperty(system, path);
context[key] = {
field: system.schema.getField(path),
value,
enriched: await TextEditor.implementation.enrichHTML(value, {
secrets: this.document.isOwner,
relativeTo: this.document
})
};
}
}
}

View file

@ -26,7 +26,6 @@ export default class Party extends DHBaseActorSheet {
actions: {
openDocument: Party.#openDocument,
deletePartyMember: Party.#deletePartyMember,
deleteItem: Party.#deleteItem,
toggleHope: Party.#toggleHope,
toggleHitPoints: Party.#toggleHitPoints,
toggleStress: Party.#toggleStress,
@ -44,12 +43,10 @@ export default class Party extends DHBaseActorSheet {
static PARTS = {
header: { template: 'systems/daggerheart/templates/sheets/actors/party/header.hbs' },
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
partyMembers: { template: 'systems/daggerheart/templates/sheets/actors/party/party-members.hbs' },
/* NOT YET IMPLEMENTED */
// projects: {
// template: 'systems/daggerheart/templates/sheets/actors/party/projects.hbs',
// scrollable: ['']
// },
partyMembers: {
template: 'systems/daggerheart/templates/sheets/actors/party/party-members.hbs',
scrollable: ['']
},
inventory: {
template: 'systems/daggerheart/templates/sheets/actors/party/inventory.hbs',
scrollable: ['.tab.inventory .items-section']
@ -60,19 +57,13 @@ export default class Party extends DHBaseActorSheet {
/** @inheritdoc */
static TABS = {
primary: {
tabs: [
{ id: 'partyMembers' },
/* NOT YET IMPLEMENTED */
// { id: 'projects' },
{ id: 'inventory' },
{ id: 'notes' }
],
tabs: [{ id: 'partyMembers' }, { id: 'inventory' }, { id: 'notes' }],
initial: 'partyMembers',
labelPrefix: 'DAGGERHEART.GENERAL.Tabs'
}
};
static ALLOWED_ACTOR_TYPES = ['character', 'companion', 'adversary'];
static ALLOWED_ACTOR_TYPES = ['character', 'companion', 'adversary', 'npc'];
static DICE_ROLL_ACTOR_TYPES = ['character'];
async _onRender(context, options) {
@ -85,6 +76,14 @@ export default class Party extends DHBaseActorSheet {
/* Prepare Context */
/* -------------------------------------------- */
async _prepareContext(options) {
const context = await super._prepareContext(options);
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Metagaming);
context.showStats =
settings.hidePartyStats === 'never' || (settings.hidePartyStats === 'players' && game.user.isGM);
return context;
}
async _preparePartContext(partId, context, options) {
context = await super._preparePartContext(partId, context, options);
switch (partId) {
@ -498,23 +497,4 @@ export default class Party extends DHBaseActorSheet {
const newMembersList = currentMembers.filter(uuid => uuid !== doc.uuid);
await this.document.update({ 'system.partyMembers': newMembersList });
}
static async #deleteItem(event, target) {
const doc = await getDocFromElement(target.closest('.inventory-item'));
if (!event.shiftKey) {
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: {
title: game.i18n.format('DAGGERHEART.APPLICATIONS.DeleteConfirmation.title', {
type: game.i18n.localize('TYPES.Actor.party'),
name: doc.name
})
},
content: game.i18n.format('DAGGERHEART.APPLICATIONS.DeleteConfirmation.text', { name: doc.name })
});
if (!confirmed) return;
}
this.document.deleteEmbeddedDocuments('Item', [doc.id]);
}
}

View file

@ -89,7 +89,7 @@ export default function DHApplicationMixin(Base) {
classes: ['daggerheart', 'sheet', 'dh-style'],
actions: {
triggerContextMenu: DHSheetV2.#triggerContextMenu,
createDoc: DHSheetV2.#createDoc,
createDoc: DHSheetV2.#onCreateDoc,
editDoc: DHSheetV2.#editDoc,
deleteDoc: DHSheetV2.#deleteDoc,
toChat: DHSheetV2.#toChat,
@ -97,8 +97,8 @@ export default function DHApplicationMixin(Base) {
viewItem: DHSheetV2.#viewItem,
toggleEffect: DHSheetV2.#toggleEffect,
toggleExtended: DHSheetV2.#toggleExtended,
addNewItem: DHSheetV2.#addNewItem,
browseItem: DHSheetV2.#browseItem,
addNewItem: DHSheetV2.#onAddNewItem,
browseItem: DHSheetV2.#onBrowseItem,
editAttribution: DHSheetV2.#editAttribution,
configureLevelUpOptions: DHSheetV2.#configureLevelUpOptions
},
@ -439,7 +439,7 @@ export default function DHApplicationMixin(Base) {
const target = element.closest('[data-item-uuid]');
return !target.dataset.disabled && target.dataset.itemType !== 'beastform';
},
callback: async target => (await getDocFromElement(target)).update({ disabled: true })
onClick: async (_, target) => (await getDocFromElement(target)).update({ disabled: true })
},
{
label: 'enableEffect',
@ -448,7 +448,7 @@ export default function DHApplicationMixin(Base) {
const target = element.closest('[data-item-uuid]');
return target.dataset.disabled && target.dataset.itemType !== 'beastform';
},
callback: async target => (await getDocFromElement(target)).update({ disabled: false })
onClick: async (_, target) => (await getDocFromElement(target)).update({ disabled: false })
}
].map(option => ({
...option,
@ -493,7 +493,9 @@ export default function DHApplicationMixin(Base) {
(doc?.isOwner && (!doc?.hasOwnProperty('systemPath') || doc?.inCollection))
);
},
callback: async target => (await getDocFromElement(target)).sheet.render({ force: true })
onClick: async (_, target) => {
return (await getDocFromElement(target)).sheet.render({ force: true });
}
}
];
@ -508,7 +510,7 @@ export default function DHApplicationMixin(Base) {
!foundry.utils.isEmpty(doc?.damage?.parts);
return doc?.isOwner && hasDamage;
},
callback: async (target, event) => {
onClick: async (event, target) => {
const doc = await getDocFromElement(target),
action = doc?.system?.attack ?? doc;
const config = action.prepareConfig(event);
@ -528,7 +530,7 @@ export default function DHApplicationMixin(Base) {
const doc = getDocFromElementSync(target);
return doc?.isOwner && !(doc.type === 'domainCard' && doc.system.inVault);
},
callback: async (target, event) => (await getDocFromElement(target)).use(event)
onClick: async (event, target) => (await getDocFromElement(target)).use(event)
});
}
@ -536,7 +538,7 @@ export default function DHApplicationMixin(Base) {
options.push({
label: 'DAGGERHEART.APPLICATIONS.ContextMenu.sendToChat',
icon: 'fa-solid fa-message',
callback: async target => (await getDocFromElement(target)).toChat(this.document.uuid)
onClick: async (_, target) => (await getDocFromElement(target)).toChat(this.document.uuid)
});
if (deletable)
@ -546,9 +548,9 @@ export default function DHApplicationMixin(Base) {
visible: element => {
const target = element.closest('[data-item-uuid]');
const doc = getDocFromElementSync(target);
return doc?.isOwner && target.dataset.itemType !== 'beastform';
return doc?.isOwner !== false && target.dataset.itemType !== 'beastform';
},
callback: async (target, event) => {
onClick: async (event, target) => {
const doc = await getDocFromElement(target);
if (event.shiftKey) return doc.delete();
else return doc.deleteDialog();
@ -654,7 +656,7 @@ export default function DHApplicationMixin(Base) {
/* Application Clicks Actions */
/* -------------------------------------------- */
static async #addNewItem(event, target) {
static async #onAddNewItem(event, target) {
const createChoice = await foundry.applications.api.DialogV2.wait({
classes: ['dh-style', 'two-big-buttons'],
buttons: [
@ -673,11 +675,11 @@ export default function DHApplicationMixin(Base) {
if (!createChoice) return;
if (createChoice === 'browse') return DHSheetV2.#browseItem.call(this, event, target);
else return DHSheetV2.#createDoc.call(this, event, target);
if (createChoice === 'browse') return DHSheetV2.#onBrowseItem.call(this, event, target);
else return DHSheetV2.#onCreateDoc.call(this, event, target);
}
static async #browseItem(event, target) {
static async #onBrowseItem(_event, target) {
const type = target.dataset.compendium ?? target.dataset.type;
const presets = {
@ -732,7 +734,7 @@ export default function DHApplicationMixin(Base) {
* Create an embedded document.
* @type {ApplicationClickAction}
*/
static async #createDoc(event, target) {
static async #onCreateDoc(event, target) {
const { documentClass, type, inVault, disabled } = target.dataset;
const parentIsItem = this.document.documentName === 'Item';
const featureOnCharacter = this.document.parent?.type === 'character' && type === 'feature';
@ -760,11 +762,15 @@ export default function DHApplicationMixin(Base) {
type,
system: systemData
};
if (inVault) data['system.inVault'] = true;
if (disabled) data.disabled = true;
if (type === 'domainCard' && parent?.system.domains?.length) {
data.system.domain = parent.system.domains[0];
if (type === 'domainCard') {
if (parent?.system.domains?.length) data.system.domain = parent.system.domains[0];
if (inVault) data.system.inVault = true;
} else if (type === 'weapon') {
// Passing an empty system object to weapon causes validation failure due to attack action initialization
// todo: determine why, fix it at its source, then remove this fallback
delete data.system;
}
const doc = await cls.create(data, { parent, renderSheet: !event.shiftKey });

View file

@ -166,6 +166,15 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
}
}
/** Add support for input content editables */
_toggleDisabled(disabled) {
super._toggleDisabled(disabled);
const form = this.form;
for (const element of form.querySelectorAll('.input[contenteditable]')) {
element.classList.toggle('disabled', disabled);
}
}
/* -------------------------------------------- */
/* Context Menu */
/* -------------------------------------------- */
@ -180,6 +189,43 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
return this._getContextMenuCommonOptions.call(this, { usable: true, toChat: true });
}
/**
* Get the set of ContextMenu options for the base attack.
* @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} - The Array of context options passed to the ContextMenu instance
* @this {CharacterSheet}
* @protected
*/
static getBaseAttackContextOptions() {
/**@type {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} */
return [
{
label: 'DAGGERHEART.CONFIG.RollTypes.attack.name',
icon: 'fa-solid fa-burst',
onClick: async (event, target) => (await getDocFromElement(target)).use(event)
},
{
label: 'DAGGERHEART.GENERAL.damage',
icon: 'fa-solid fa-explosion',
onClick: async (event, target) => {
const doc = await getDocFromElement(target),
action = doc?.system?.attack ?? doc;
const config = action.prepareConfig(event);
config.effects = await game.system.api.data.actions.actionsTypes.base.getEffects(
this.document,
doc
);
config.hasRoll = false;
return action && action.workflow.get('damage').execute(config, null, true);
}
},
{
label: 'DAGGERHEART.APPLICATIONS.ContextMenu.sendToChat',
icon: 'fa-solid fa-message',
onClick: async (_, target) => (await getDocFromElement(target)).toChat(this.document.uuid)
}
];
}
/* -------------------------------------------- */
/* Application Listener Actions */
/* -------------------------------------------- */

View file

@ -126,7 +126,7 @@ export default class DHBaseItemSheet extends DHApplicationMixin(ItemSheetV2) {
options.push({
name: 'CONTROLS.CommonDelete',
icon: '<i class="fa-solid fa-trash"></i>',
callback: async target => {
onClick: async (_, target) => {
const feature = await getDocFromElement(target);
if (!feature) return;
const confirmed = await foundry.applications.api.DialogV2.confirm({

View file

@ -104,9 +104,10 @@ export default class ClassSheet extends DHBaseItemSheet {
}
/**@inheritdoc */
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
async _prepareContext(options) {
const context = await super._prepareContext(options);
context.domains = this.document.system.domains;
context.subclasses = await this.document.system.fetchSubclasses();
return context;
}
@ -128,20 +129,8 @@ export default class ClassSheet extends DHBaseItemSheet {
const item = await fromUuid(data.uuid);
const itemType = data.type === 'ActiveEffect' ? data.type : item.type;
const target = event.target.closest('fieldset.drop-section');
if (itemType === 'subclass') {
if (item.system.linkedClass) {
return ui.notifications.warn(
game.i18n.format('DAGGERHEART.UI.Notifications.subclassAlreadyLinked', {
name: item.name,
class: this.document.name
})
);
}
await item.update({ 'system.linkedClass': this.document.uuid });
await this.document.update({
'system.subclasses': [...this.document.system.subclasses.map(x => x.uuid), item.uuid]
});
} else if (['feature', 'ActiveEffect'].includes(itemType)) {
if (['feature', 'ActiveEffect'].includes(itemType)) {
super._onDrop(event);
} else if (this.document.parent?.type !== 'character') {
if (itemType === 'weapon') {
@ -200,12 +189,6 @@ export default class ClassSheet extends DHBaseItemSheet {
static async #removeItemFromCollection(_event, element) {
const { uuid, target } = element.dataset;
const prop = foundry.utils.getProperty(this.document.system, target);
if (target === 'subclasses') {
const subclass = await foundry.utils.fromUuid(uuid);
await subclass?.update({ 'system.linkedClass': null });
}
await this.document.update({ [`system.${target}`]: prop.filter(i => i && i.uuid !== uuid).map(x => x.uuid) });
}

View file

@ -40,4 +40,36 @@ export default class SubclassSheet extends DHBaseItemSheet {
get relatedDocs() {
return this.document.system.features.map(x => x.item);
}
async _prepareContext(options) {
const context = await super._prepareContext(options);
if (this.document.system.linkedClass) {
const classData = await fromUuid(this.document.system.linkedClass);
context.class = classData ?? {
name: _loc('DAGGERHEART.GENERAL.missingX', { x: _loc('TYPES.Item.class') }),
missing: true
};
}
return context;
}
async _onDrop(event) {
event.stopPropagation();
const data = TextEditor.getDragEventData(event);
const item = await fromUuid(data.uuid);
const itemType = data.type === 'ActiveEffect' ? data.type : item.type;
if (itemType === 'class') {
const uuid = item._stats.compendiumSource ?? item.uuid;
if (this.document.system.linkedClass !== uuid) {
await this.document.update({ 'system.linkedClass': uuid });
// Re-render all class sheets for instant feedback
for (const app of foundry.applications.instances.values()) {
if (app.document?.type === 'class') app.render();
}
}
return;
}
return super._onDrop(event);
}
}

View file

@ -103,6 +103,19 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
_getEntryContextOptions() {
return [
...super._getEntryContextOptions(),
{
label: 'DAGGERHEART.UI.ChatLog.rerollActionRoll',
icon: '<i class="fa-solid fa-dice"></i>',
visible: li => {
const message = game.messages.get(li.dataset.messageId);
return message.system.hasRoll && (game.user.isGM || message.isAuthor);
},
callback: async li => {
const message = game.messages.get(li.dataset.messageId);
const reroll = await message.rolls[0].reroll({ liveRoll: true });
message.update({ rolls: [reroll] });
}
},
{
label: 'DAGGERHEART.UI.ChatLog.rerollDamage',
icon: '<i class="fa-solid fa-dice"></i>',
@ -113,9 +126,10 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
: false;
return (game.user.isGM || message.isAuthor) && hasRolledDamage;
},
callback: li => {
callback: async li => {
const message = game.messages.get(li.dataset.messageId);
new game.system.api.applications.dialogs.RerollDamageDialog(message).render({ force: true });
const update = await message.system.getRerolledDamage();
message.update(update);
}
}
];

View file

@ -56,7 +56,9 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C
async _prepareTrackerContext(context, options) {
await super._prepareTrackerContext(context, options);
const adversaries = context.turns?.filter(x => x.isNPC) ?? [];
const npcs = context.turns?.filter(x => x.isNPC) ?? [];
const adversaries = npcs.filter(x => x.disposition !== CONST.TOKEN_DISPOSITIONS.FRIENDLY);
const friendlies = npcs.filter(x => x.disposition === CONST.TOKEN_DISPOSITIONS.FRIENDLY);
const characters = context.turns?.filter(x => !x.isNPC) ?? [];
const spotlightQueueEnabled = game.settings.get(
CONFIG.DH.id,
@ -75,25 +77,56 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C
Object.assign(context, {
actionTokens: game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).actionTokens,
adversaries,
friendlies,
allCharacters: characters,
characters: characters.filter(x => !spotlightQueueEnabled || x.system.spotlight.requestOrderIndex == 0),
spotlightRequests
});
}
/**
* Open the dialog used to edit the name of the currently viewed Combat encounter.
* @this {CombatTracker}
* @returns {Promise<void>}
*/
static async #onEditName() {
const combat = this.viewed;
if (!combat || !game.user.isGM) return null;
const field = combat.schema.fields.name;
const inputHTML = field.toFormGroup({}, { name: 'name', value: combat.name, autofocus: true }).outerHTML;
const formData = await foundry.applications.api.DialogV2.input({
window: { icon: 'fa-solid fa-tag', title: 'COMBAT.ACTIONS.EditNameTitle' },
position: { width: 480 },
content: inputHTML
});
await combat.update({ name: formData.name || '' });
}
_getCombatContextOptions() {
return [
{
label: 'COMBAT.ClearMovementHistories',
icon: '<i class="fa-solid fa-shoe-prints"></i>',
visible: () => game.user.isGM && this.viewed?.combatants.size > 0,
callback: () => this.viewed.clearMovementHistories()
label: 'COMBAT.ACTIONS.EditName',
icon: 'fa-solid fa-tag',
visible: () => game.user.isGM && !!this.viewed,
onClick: () => DhCombatTracker.#onEditName.call(this)
},
{
label: 'COMBAT.Delete',
icon: '<i class="fa-solid fa-trash"></i>',
label: 'COMBAT.ACTIONS.LinkToScene',
icon: '<i class="fa-solid fa-link"></i>',
visible: () => game.user.isGM && !this.scene,
onClick: () => this.viewed.toggleSceneLink()
},
{
label: 'COMBAT.ACTIONS.UnlinkFromScene',
icon: '<i class="fa-solid fa-unlink"></i>',
visible: () => game.user.isGM && !!this.scene,
onClick: () => this.viewed.toggleSceneLink()
},
{
label: 'COMBAT.End',
icon: 'fa-solid fa-xmark',
visible: () => game.user.isGM && !!this.viewed,
callback: () => this.viewed.endCombat()
onClick: () => this.viewed.endCombat()
}
];
}
@ -129,7 +162,8 @@ export default class DhCombatTracker extends foundry.applications.sidebar.tabs.C
active: index === combat.turn,
canPing: combatant.sceneId === canvas.scene?.id && game.user.hasPermission('PING_CANVAS'),
type: combatant.actor?.system?.type,
img: await this._getCombatantThumbnail(combatant)
img: await this._getCombatantThumbnail(combatant),
disposition: combatant.token?.disposition
};
turn.css = [turn.active ? 'active' : null, hidden ? 'hide' : null, isDefeated ? 'defeated' : null].filterJoin(

View file

@ -1,6 +1,6 @@
import { DhCountdown } from '../../data/countdowns.mjs';
import { waitForDiceSoNice } from '../../helpers/utils.mjs';
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -114,7 +114,7 @@ export default class CountdownEdit extends HandlebarsApplicationMixin(Applicatio
}
await this.data.updateSource(update);
await emitAsGM(GMUpdateEvent.UpdateCountdowns, this.gmSetSetting.bind(this.data), this.data, null, {
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, this.gmSetSetting.bind(this.data), this.data, null, {
refreshType: RefreshType.Countdown
});

View file

@ -1,5 +1,5 @@
import { waitForDiceSoNice } from '../../helpers/utils.mjs';
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -21,19 +21,19 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
static DEFAULT_OPTIONS = {
id: 'countdowns',
tag: 'div',
classes: ['daggerheart', 'dh-style', 'countdowns', 'faded-ui'],
classes: ['daggerheart', 'dh-style', 'countdowns'],
window: {
icon: 'fa-solid fa-clock-rotate-left',
frame: true,
frame: false,
title: 'DAGGERHEART.UI.Countdowns.title',
positioned: false,
resizable: false,
minimizable: false
},
actions: {
toggleViewMode: DhCountdowns.#toggleViewMode,
editCountdowns: DhCountdowns.#editCountdowns,
loopCountdown: DhCountdowns.#loopCountdown,
toggleViewMode: DhCountdowns.#onToggleViewMode,
editCountdowns: DhCountdowns.#onEditCountdowns,
loopCountdown: DhCountdowns.#onLoopCountdown,
decreaseCountdown: (_, target) => this.editCountdown(false, target),
increaseCountdown: (_, target) => this.editCountdown(true, target)
},
@ -62,20 +62,6 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
if (iconOnly) frame.classList.add('icon-only');
else frame.classList.remove('icon-only');
const header = frame.querySelector('.window-header');
header.querySelector('button[data-action="close"]').remove();
header.querySelector('button[data-action="toggleControls"]').remove();
if (game.user.isGM) {
const editTooltip = game.i18n.localize('DAGGERHEART.APPLICATIONS.CountdownEdit.editTitle');
const editButton = `<a style="margin-right: 8px;" class="header-control" data-tooltip="${editTooltip}" aria-label="${editTooltip}" data-action="editCountdowns"><i class="fa-solid fa-wrench"></i></a>`;
header.insertAdjacentHTML('beforeEnd', editButton);
}
const minimizeTooltip = game.i18n.localize('DAGGERHEART.UI.Countdowns.toggleIconMode');
const minimizeButton = `<a class="header-control" data-tooltip="${minimizeTooltip}" aria-label="${minimizeTooltip}" data-action="toggleViewMode"><i class="fa-solid fa-down-left-and-up-right-to-center"></i></a>`;
header.insertAdjacentHTML('beforeEnd', minimizeButton);
return frame;
}
@ -161,7 +147,7 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
return true;
}
static async #toggleViewMode() {
static async #onToggleViewMode() {
const currentMode = game.user.getFlag(CONFIG.DH.id, CONFIG.DH.FLAGS.userFlags.countdownMode);
const appMode = CONFIG.DH.GENERAL.countdownAppMode;
const newMode = currentMode === appMode.textIcon ? appMode.iconOnly : appMode.textIcon;
@ -172,15 +158,16 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
this.render();
}
static async #editCountdowns() {
static async #onEditCountdowns() {
new game.system.api.applications.ui.CountdownEdit().render(true);
}
static async #loopCountdown(_, target) {
static async #onLoopCountdown(_, target) {
if (!DhCountdowns.canPerformEdit()) return;
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
const countdown = settings.countdowns[target.id];
const countdownId = target.closest('[data-countdown]').dataset.countdown;
const countdown = settings.countdowns[countdownId];
let progressMax = countdown.progress.start;
let message = null;
@ -199,12 +186,12 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
await waitForDiceSoNice(message);
await settings.updateSource({
[`countdowns.${target.id}.progress`]: {
[`countdowns.${countdownId}.progress`]: {
current: newMax,
start: newMax
}
});
await emitAsGM(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
refreshType: RefreshType.Countdown
});
}
@ -213,12 +200,13 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
if (!DhCountdowns.canPerformEdit()) return;
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);
const countdown = settings.countdowns[target.id];
const countdownId = target.closest('[data-countdown]').dataset.countdown;
const countdown = settings.countdowns[countdownId];
const newCurrent = increase
? Math.min(countdown.progress.current + 1, countdown.progress.start)
: Math.max(countdown.progress.current - 1, 0);
await settings.updateSource({ [`countdowns.${target.id}.progress.current`]: newCurrent });
await emitAsGM(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
await settings.updateSource({ [`countdowns.${countdownId}.progress.current`]: newCurrent });
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
refreshType: RefreshType.Countdown
});
}
@ -277,7 +265,7 @@ export default class DhCountdowns extends HandlebarsApplicationMixin(Application
return acc;
}, {})
};
await emitAsGM(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
await emitGMUpdate(GMUpdateEvent.UpdateCountdowns, DhCountdowns.gmSetSetting.bind(settings), settings, null, {
refreshType: RefreshType.Countdown
});
}

View file

@ -1,4 +1,4 @@
import { emitAsGM, GMUpdateEvent } from '../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent } from '../../systemRegistration/socket.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -104,7 +104,7 @@ export default class FearTracker extends HandlebarsApplicationMixin(ApplicationV
}
async updateFear(value) {
return emitAsGM(
return emitGMUpdate(
GMUpdateEvent.UpdateFear,
game.settings.set.bind(game.settings, CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear),
value

View file

@ -1,3 +1,4 @@
import { getDocFromElement } from '../../helpers/utils.mjs';
import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
@ -47,7 +48,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
expandContent: this.expandContent,
resetFilters: this.resetFilters,
sortList: this.sortList,
openSettings: this.openSettings
openSettings: this.openSettings,
viewSheet: this.#onViewSheet
},
position: {
left: 100,
@ -109,8 +111,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
CONFIG.DH.id,
CONFIG.DH.FLAGS[`${this.compendiumBrowserTypeKey}`].position
);
options.position = userPresetPosition ?? ItemBrowser.DEFAULT_OPTIONS.position;
delete options.position.zIndex;
if (!userPresetPosition) {
const width = noFolder === true || lite === true ? 600 : 850;
@ -277,7 +279,7 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
(await foundry.applications.ux.TextEditor.implementation.enrichHTML(item.description));
}
this.fieldFilter = this._createFieldFilter();
this.fieldFilter = await this._createFieldFilter();
if (this.presets?.filter) {
Object.entries(this.presets.filter).forEach(([k, v]) => {
@ -306,7 +308,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
{
items: this.items,
menu: this.selectedMenu,
formatLabel: this.formatLabel
formatLabel: this.formatLabel,
viewSheet: this.items[0] instanceof Actor
}
);
@ -355,12 +358,12 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
);
}
_createFieldFilter() {
async _createFieldFilter() {
const filters = ItemBrowser.getFolderConfig(this.selectedMenu.data, 'filters');
filters.forEach(f => {
for (const f of filters) {
if (typeof f.field === 'string') f.field = foundry.utils.getProperty(game, f.field);
else if (typeof f.choices === 'function') {
f.choices = f.choices(this.items);
f.choices = await f.choices(this.items);
}
// Clear field label so template uses our custom label parameter
@ -370,7 +373,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
f.name ??= f.key;
f.value = this.presets?.filter?.[f.name]?.value ?? null;
});
}
return filters;
}
@ -567,6 +571,11 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
}
}
static async #onViewSheet(_, target) {
const document = await getDocFromElement(target);
document?.sheet?.render(true);
}
_createDragProcess() {
new foundry.applications.ux.DragDrop.implementation({
dragSelector: '.item-container',
@ -605,7 +614,16 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
items: {
folder: 'equipments',
render: {
noFolder: true
folders: [
'equipments',
'ancestries',
'classes',
'subclasses',
'domains',
'communities',
'beastforms'
// excluded: features
]
}
},
compendium: {}

View file

@ -1,4 +1,4 @@
import { emitAsGM, GMUpdateEvent } from '../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent } from '../../systemRegistration/socket.mjs';
export default class DhSceneNavigation extends foundry.applications.ui.SceneNavigation {
/** @inheritdoc */
@ -68,7 +68,7 @@ export default class DhSceneNavigation extends foundry.applications.ui.SceneNavi
1
)[0];
newEnvironments.unshift(newFirst);
emitAsGM(
emitGMUpdate(
GMUpdateEvent.UpdateDocument,
scene.update.bind(scene),
{ 'flags.daggerheart.sceneEnvironments': newEnvironments },

View file

@ -57,14 +57,14 @@ export default class DhRegionLayer extends foundry.canvas.layers.RegionLayer {
}
async placeRegion(data, options = {}) {
const preConfirm = ({ _event, document, _create, _options }) => {
const shape = document.shapes[0];
const preConfirm = data => {
const shape = data.document.shapes[0];
const isEmanation = shape.type === 'emanation';
if (isEmanation) {
const token = this.#findTokenInBounds(shape.base.origin);
if (!token) return options.preConfirm?.() ?? true;
if (!token) return options.preConfirm?.(data) ?? true;
const shapeData = shape.toObject();
document.updateSource({
data.document.updateSource({
shapes: [
{
...shapeData,
@ -80,10 +80,10 @@ export default class DhRegionLayer extends foundry.canvas.layers.RegionLayer {
});
}
return options?.preConfirm?.() ?? true;
return options?.preConfirm?.(data) ?? true;
};
super.placeRegion(data, { ...options, preConfirm });
return await super.placeRegion(data, { ...options, preConfirm });
}
/** Searches for token at origin point, returning null if there are no tokens or multiple overlapping tokens */

View file

@ -249,9 +249,6 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
/** @inheritDoc */
_drawBar(number, bar, data) {
const val = Number(data.value);
const pct = Math.clamp(val, 0, data.max) / data.max;
// Determine sizing
const { width, height } = this.document.getSize();
const s = canvas.dimensions.uiScale;
@ -259,17 +256,19 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
const bh = 8 * (this.document.height >= 2 ? 1.5 : 1) * s;
// Determine the color to use
const fillColor =
number === 0 ? foundry.utils.Color.fromRGB([1, 0, 0]) : foundry.utils.Color.fromString('#0032b1');
const Color = foundry.utils.Color;
const fillColor = number === 0 ? Color.fromRGB([1, 0, 0]) : Color.fromString('#0032b1');
const emptyColor = Color.fromRGB([0, 0, 0]);
// Draw the bar
const widthUnit = bw / data.max;
// Draw the bar (accounting floating point numbers from bar animations)
const widthUnit = bw / Math.ceil(data.max);
bar.clear().lineStyle(s, 0x000000, 1.0);
const sections = [...Array(data.max).keys()];
for (let mark of sections) {
const sections = [...Array(Math.ceil(data.max)).keys()];
for (const mark of sections) {
const x = mark * widthUnit;
const marked = mark + 1 <= data.value;
const color = marked ? fillColor : foundry.utils.Color.fromRGB([0, 0, 0]);
const marked = mark < Math.ceil(data.value);
const remainder = mark === Math.ceil(data.value) - 1 ? data.value % 1 : 0;
const color = !marked ? emptyColor : remainder ? emptyColor.mix(fillColor, remainder) : fillColor;
if (mark === 0 || mark === sections.length - 1) {
bar.beginFill(color, marked ? 1.0 : 0.5).drawRect(x, 0, widthUnit, bh, 2 * s); // Would like drawRoundedRect, but it's very troublsome with the corners. Leaving for now.
} else {

View file

@ -70,6 +70,49 @@ export const typeConfig = {
}
]
},
environments: {
columns: [
{
key: 'system.tier',
label: 'DAGGERHEART.GENERAL.Tiers.singular'
},
{
key: 'system.type',
label: 'DAGGERHEART.GENERAL.type',
format: type => {
if (!type) return '-';
return CONFIG.DH.ACTOR.environmentTypes[type].label;
}
}
],
filters: [
{
key: 'system.tier',
label: 'DAGGERHEART.GENERAL.Tiers.singular',
field: 'system.api.models.actors.DhEnvironment.schema.fields.tier'
},
{
key: 'system.type',
label: 'DAGGERHEART.GENERAL.type',
field: 'system.api.models.actors.DhEnvironment.schema.fields.type'
},
{
key: 'system.difficulty',
name: 'difficulty.min',
label: 'DAGGERHEART.UI.ItemBrowser.difficultyMin',
field: 'system.api.models.actors.DhEnvironment.schema.fields.difficulty',
operator: 'gte'
},
{
key: 'system.difficulty',
name: 'difficulty.max',
label: 'DAGGERHEART.UI.ItemBrowser.difficultyMax',
field: 'system.api.models.actors.DhEnvironment.schema.fields.difficulty',
operator: 'lte'
}
]
},
items: {
columns: [
{
@ -177,8 +220,8 @@ export const typeConfig = {
key: 'system.secondary',
label: 'DAGGERHEART.UI.ItemBrowser.subtype',
choices: [
{ value: false, label: 'DAGGERHEART.ITEMS.Weapon.primaryWeapon' },
{ value: true, label: 'DAGGERHEART.ITEMS.Weapon.secondaryWeapon' }
{ value: false, label: 'DAGGERHEART.ITEMS.Weapon.primaryWeapon.full' },
{ value: true, label: 'DAGGERHEART.ITEMS.Weapon.secondaryWeapon.full' }
]
},
{
@ -383,7 +426,8 @@ export const typeConfig = {
{
key: 'system.linkedClass',
label: 'TYPES.Item.class',
format: linkedClass => linkedClass?.name ?? 'DAGGERHEART.UI.ItemBrowser.missing'
format: linkedClass =>
foundry.utils.fromUuidSync(linkedClass)?.name ?? 'DAGGERHEART.UI.ItemBrowser.missing'
},
{
key: 'system.spellcastingTrait',
@ -393,15 +437,20 @@ export const typeConfig = {
],
filters: [
{
key: 'system.linkedClass.uuid',
key: 'system.linkedClass',
label: 'TYPES.Item.class',
choices: items => {
const list = items
.filter(item => item.system.linkedClass)
.map(item => ({
value: item.system.linkedClass.uuid,
label: item.system.linkedClass.name
}));
choices: async items => {
const list = [];
for (const item of items.filter(item => item.system.linkedClass)) {
const linkedClass = await foundry.utils.fromUuid(item.system.linkedClass);
if (linkedClass) {
list.push({
value: linkedClass.uuid,
label: linkedClass.name
});
}
}
return list.reduce((a, c) => {
if (!a.find(i => i.value === c.value)) a.push(c);
return a;
@ -555,7 +604,8 @@ export const compendiumConfig = {
id: 'environments',
keys: ['environments'],
label: 'DAGGERHEART.UI.ItemBrowser.folders.environments',
type: ['environment']
type: ['environment'],
listType: 'environments'
},
beastforms: {
id: 'beastforms',

View file

@ -453,7 +453,7 @@ export const allArmorFeatures = () => {
const feature = homebrewFeatures[key];
const actions = feature.actions.map(action => ({
...action,
effects: action.effects.map(effect => feature.effects.find(x => x.id === effect._id)),
effects: action.effects?.map(effect => feature.effects.find(x => x.id === effect._id)) ?? [],
type: action.type
}));
const actionEffects = actions.flatMap(a => a.effects);
@ -1407,7 +1407,7 @@ export const allWeaponFeatures = () => {
const actions = feature.actions.map(action => ({
...action,
effects: action.effects.map(effect => feature.effects.find(x => x.id === effect._id)),
effects: action.effects?.map(effect => feature.effects.find(x => x.id === effect._id)) ?? [],
type: action.type
}));
const actionEffects = actions.flatMap(a => a.effects);

View file

@ -57,15 +57,9 @@ const companionBaseResources = Object.freeze({
stress: {
id: 'stress',
initial: 0,
max: 0,
max: 3,
reverse: true,
label: 'DAGGERHEART.GENERAL.stress'
},
hope: {
id: 'hope',
initial: 0,
reverse: false,
label: 'DAGGERHEART.GENERAL.hope'
}
});

View file

@ -1,4 +1,3 @@
import { DHDamageData } from '../fields/action/damageField.mjs';
import DHDamageAction from './damageAction.mjs';
export default class DHAttackAction extends DHDamageAction {
@ -12,8 +11,19 @@ export default class DHAttackAction extends DHDamageAction {
super.prepareData();
if (!!this.item?.system?.attack) {
if (this.damage.includeBase) {
const baseDamage = this.getParentDamage();
this.damage.parts.hitPoints = new DHDamageData(baseDamage);
const baseDamage = this.getParentHitPointDamage();
if (baseDamage) {
if (!this.damage.parts.hitPoints) {
this.damage.parts.hitPoints = baseDamage;
} else {
for (const type of baseDamage.type) this.damage.parts.hitPoints.type.add(type);
this.damage.parts.hitPoints.value.custom = {
enabled: true,
formula: `${baseDamage.value.getFormula()} + ${this.damage.parts.hitPoints.value.getFormula()}`
};
}
}
}
if (this.roll.useDefault) {
this.roll.trait = this.item.system.attack.roll.trait;
@ -22,16 +32,8 @@ export default class DHAttackAction extends DHDamageAction {
}
}
getParentDamage() {
return {
value: {
multiplier: 'prof',
dice: this.item?.system?.attack.damage.parts.hitPoints.value.dice,
bonus: this.item?.system?.attack.damage.parts.hitPoints.value.bonus ?? 0
},
type: this.item?.system?.attack.damage.parts.hitPoints.type,
base: true
};
getParentHitPointDamage() {
return this.item?.system?.attack.damage.parts.hitPoints;
}
get damageFormula() {
@ -73,7 +75,12 @@ export default class DHAttackAction extends DHDamageAction {
const useAltDamage = this.actor?.effects?.find(x => x.type === 'horde')?.active;
for (const { value, valueAlt, type } of damage.parts) {
const usedValue = useAltDamage ? valueAlt : value;
const str = Roll.replaceFormulaData(usedValue.getFormula(), this.actor?.getRollData() ?? {});
const damageString = Roll.replaceFormulaData(usedValue.getFormula(), this.actor?.getRollData() ?? {});
const str = damageString
? damageString
: game.i18n.format('DAGGERHEART.GENERAL.missingX', {
x: game.i18n.localize('DAGGERHEART.GENERAL.damage')
});
const icons = Array.from(type)
.map(t => CONFIG.DH.GENERAL.damageTypes[t]?.icon)

View file

@ -148,10 +148,14 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
: null;
}
/** Returns true if the action is usable */
/**
* Returns true if the action is usable.
* An action is usable on any actor type. For example, an adversary might have a base attack action.
*/
get usable() {
const actor = this.actor;
return this.isOwner && actor?.type === 'character';
const pack = actor?.pack ? game.packs.get(actor.pack) : null;
return !pack?.locked && this.isOwner;
}
static getRollType(parent) {

View file

@ -36,7 +36,7 @@ export default class DhCountdownAction extends DHBaseAction {
/** @inheritDoc */
static migrateData(source) {
for (const countdown of source.countdown) {
for (const countdown of Object.values(source.countdown)) {
if (countdown.progress.max) {
countdown.progress.startFormula = countdown.progress.max;
countdown.progress.start = 1;

View file

@ -35,6 +35,7 @@ export default class BeastformEffect extends BaseEffect {
static migrateData(source) {
if (!source.characterTokenData.tokenSize.height) source.characterTokenData.tokenSize.height = 1;
if (!source.characterTokenData.tokenSize.width) source.characterTokenData.tokenSize.width = 1;
if (!source.characterTokenData.tokenSize.depth) source.characterTokenData.tokenSize.depth = 1;
return super.migrateData(source);
}
@ -52,7 +53,8 @@ export default class BeastformEffect extends BaseEffect {
if (this.parent.parent.type === 'character') {
const baseUpdate = {
height: this.characterTokenData.tokenSize.height,
width: this.characterTokenData.tokenSize.width
width: this.characterTokenData.tokenSize.width,
depth: this.characterTokenData.tokenSize.depth
};
const update = {
...baseUpdate,

View file

@ -1,15 +1,17 @@
import DhCharacter from './character.mjs';
import DhCompanion from './companion.mjs';
import DhAdversary from './adversary.mjs';
import DhNPC from './npc.mjs';
import DhEnvironment from './environment.mjs';
import DhParty from './party.mjs';
export { DhCharacter, DhCompanion, DhAdversary, DhEnvironment, DhParty };
export { DhCharacter, DhCompanion, DhAdversary, DhNPC, DhEnvironment, DhParty };
export const config = {
character: DhCharacter,
companion: DhCompanion,
adversary: DhAdversary,
npc: DhNPC,
environment: DhEnvironment,
party: DhParty
};

View file

@ -3,8 +3,7 @@ import { ActionField } from '../fields/actionField.mjs';
import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs';
import { bonusField } from '../fields/actorField.mjs';
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
import { getTierAdjustedAdversary } from './tierAdjustment.mjs';
export default class DhpAdversary extends DhCreature {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
@ -189,6 +188,9 @@ export default class DhpAdversary extends DhCreature {
prepareDerivedData() {
super.prepareDerivedData();
this.attack.roll.isStandardAttack = true;
// Clamp resources (must be done last to ensure all updates occur)
this.resources.clamp();
}
_getTags() {
@ -203,205 +205,6 @@ export default class DhpAdversary extends DhCreature {
/** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */
adjustForTier(tier) {
const source = this.parent.toObject(true);
/** @type {(2 | 3 | 4)[]} */
const tiers = new Array(Math.abs(tier - this.tier))
.fill(0)
.map((_, idx) => idx + Math.min(tier, this.tier) + 1);
if (tier < this.tier) tiers.reverse();
const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard];
const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] }));
// Apply simple tier changes
const scale = tier > this.tier ? 1 : -1;
for (const entry of tierEntries) {
source.system.difficulty += scale * entry.difficulty;
source.system.damageThresholds.major += scale * entry.majorThreshold;
source.system.damageThresholds.severe += scale * entry.severeThreshold;
source.system.resources.hitPoints.max += scale * entry.hp;
source.system.resources.stress.max += scale * entry.stress;
source.system.attack.roll.bonus += scale * entry.attack;
}
// Get the mean and standard deviation of expected damage in the previous and new tier
// The data we have is for attack scaling, but we reuse this for action scaling later
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
const damageMeta = {
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
newDamageRange: { tier, ...expectedDamageData[tier] },
type: 'attack'
};
// Update damage of base attack
try {
this.#adjustActionDamage(source.system.attack, damageMeta);
} catch (err) {
ui.notifications.warn('Failed to convert attack damage of adversary');
console.error(err);
}
// Update damage of each item action, making sure to also update the description if possible
const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g;
for (const item of source.items) {
// Replace damage inlines with new formulas
for (const withDescription of [item.system, ...Object.values(item.system.actions)]) {
withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => {
const { value: formula } = parseInlineParams(inner);
if (!formula || !type) return match;
try {
const adjusted = this.#calculateAdjustedDamage(formula, { ...damageMeta, type: 'action' });
const newFormula = [
adjusted.diceQuantity ? `${adjusted.diceQuantity}d${adjusted.faces}` : null,
adjusted.bonus
]
.filter(p => !!p)
.join('+');
return match.replace(formula, newFormula);
} catch {
return match;
}
});
}
// Update damage in item actions
// Parse damage, and convert all formula matches in the descriptions to the new damage
for (const action of Object.values(item.system.actions)) {
try {
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
if (!result) continue;
for (const { previousFormula, formula } of Object.values(result)) {
const oldFormulaRegexp = new RegExp(
previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?')
);
item.system.description = item.system.description.replace(oldFormulaRegexp, formula);
action.description = action.description.replace(oldFormulaRegexp, formula);
}
} catch (err) {
ui.notifications.warn(`Failed to convert action damage for item ${item.name}`);
console.error(err);
}
}
}
// Finally set the tier of the source data, now that everything is complete
source.system.tier = tier;
return source;
}
/**
* Converts a damage object to a new damage range
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
* @throws error if the formula is the wrong type
*/
#calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) {
const terms = parseTermsFromSimpleFormula(formula);
const flatTerms = terms.filter(t => t.diceQuantity === 0);
const diceTerms = terms.filter(t => t.diceQuantity > 0);
if (flatTerms.length > 1 || diceTerms.length > 1) {
throw new Error('invalid formula for conversion');
}
const value = {
...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }),
bonus: flatTerms[0]?.bonus ?? 0
};
const previousExpected = calculateExpectedValue(value);
if (previousExpected === 0) return value; // nothing to do
const dieSizes = [4, 6, 8, 10, 12, 20];
const steps = newDamageRange.tier - currentDamageRange.tier;
const increasing = steps > 0;
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
// If this was just a flat number, convert to the expected damage and exit
if (value.diceQuantity === 0) {
value.bonus = Math.round(expected);
return value;
}
const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1;
const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 });
// Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die
const baseOverages = Math.floor(value.bonus / getExpectedDie());
// Prestep. Change number of dice for attacks, bump up/down for actions
// We never bump up to d20, though we might bump down from it
if (type === 'attack') {
const minimum = increasing ? value.diceQuantity : 0;
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
} else {
const currentIdx = dieSizes.indexOf(value.faces);
value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)];
}
value.bonus = Math.round(expected - getBaseAverage());
// Attempt to handle negative values.
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
if (value.bonus < 0) {
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
const currentIdx = dieSizes.indexOf(value.faces);
// If step downs alone don't suffice, change the flat modifier, then calculate steps required again
// If this isn't sufficient, the result will be slightly off. This is unlikely to happen
if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) {
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
value.bonus = Math.round(expected - getBaseAverage());
if (value.bonus >= 0) return value; // complete
}
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)];
value.bonus = Math.max(0, Math.round(expected - getBaseAverage()));
}
// If value is really high, we add a number of dice based on the number of overages
// This attempts to preserve a similar amount of variance when increasing an action
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
if (type !== 'attack' && increasing && overagesToRemove > 0) {
value.diceQuantity += overagesToRemove;
value.bonus = Math.round(expected - getBaseAverage());
}
return value;
}
/**
* Updates damage to reflect a specific value.
* @throws if damage structure is invalid for conversion
* @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage
*/
#adjustActionDamage(action, damageMeta) {
if (!action.damage?.parts.hitPoints) return null;
const result = {};
for (const property of ['value', 'valueAlt']) {
const data = action.damage.parts.hitPoints[property];
const previousFormula = data.custom.enabled
? data.custom.formula
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0]
.filter(p => !!p)
.join('+');
const value = this.#calculateAdjustedDamage(previousFormula, damageMeta);
const formula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
.filter(p => !!p)
.join('+');
if (value.diceQuantity) {
data.custom.enabled = false;
data.bonus = value.bonus;
data.dice = `d${value.faces}`;
data.flatMultiplier = value.diceQuantity;
} else if (!value.diceQuantity) {
data.custom.enabled = true;
data.custom.formula = formula;
}
result[property] = { previousFormula, formula, value };
}
return result;
return getTierAdjustedAdversary(source, tier);
}
}

View file

@ -1,6 +1,6 @@
import DHBaseActorSettings from '../../applications/sheets/api/actor-setting.mjs';
import DHItem from '../../documents/item.mjs';
import { getScrollTextData } from '../../helpers/utils.mjs';
import { createShallowProxy, getScrollTextData } from '../../helpers/utils.mjs';
const fields = foundry.data.fields;
@ -180,8 +180,7 @@ export default class BaseDataActor extends foundry.abstract.TypeDataModel {
* @returns {object}
*/
getRollData() {
const data = { ...this };
return data;
return createShallowProxy(this);
}
/**

View file

@ -399,8 +399,9 @@ export default class DhCharacter extends DhCreature {
return this.domains.map(key => {
const domain = allDomainData[key];
return {
id: key,
...domain,
label: game.i18n.localize(domain.label)
label: game.i18n.localize(domain?.label) ?? key
};
});
}
@ -418,14 +419,11 @@ export default class DhCharacter extends DhCreature {
}
get loadoutSlot() {
const loadoutCount = this.domainCards.loadout?.length ?? 0,
worldSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxLoadout,
max = !worldSetting ? null : worldSetting + this.bonuses.maxLoadout;
const loadoutCount = this.domainCards.loadout?.length ?? 0;
const worldSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxLoadout;
return {
current: loadoutCount,
available: !max ? true : Math.max(max - loadoutCount, 0),
max
available: loadoutCount < worldSetting
};
}
@ -600,6 +598,8 @@ export default class DhCharacter extends DhCreature {
communityFeatures = [],
classFeatures = [],
subclassFeatures = [],
multiclassFeatures = [],
multiclassSubclassFeatures = [],
companionFeatures = [],
features = [];
@ -609,9 +609,9 @@ export default class DhCharacter extends DhCreature {
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.community.id) {
communityFeatures.push(item);
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.class.id) {
classFeatures.push(item);
(item.system.multiclassOrigin ? multiclassFeatures : classFeatures).push(item);
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.subclass.id) {
subclassFeatures.push(item);
(item.system.multiclassOrigin ? multiclassSubclassFeatures : subclassFeatures).push(item);
} else if (item.system.originItemType === CONFIG.DH.ITEM.featureTypes.companion.id) {
companionFeatures.push(item);
} else if (item.type === 'feature' && !item.system.type) {
@ -640,6 +640,24 @@ export default class DhCharacter extends DhCreature {
type: 'subclass',
values: subclassFeatures
},
...(multiclassFeatures.length
? {
multiclassFeatures: {
title: `${game.i18n.localize('DAGGERHEART.GENERAL.multiclass')} - ${this.multiclass.value?.name}`,
type: 'multiclass',
values: multiclassFeatures
}
}
: {}),
...(multiclassSubclassFeatures.length
? {
multiclassSubclassFeatures: {
title: `${game.i18n.localize('DAGGERHEART.GENERAL.multiclass')} ${game.i18n.localize('TYPES.Item.subclass')} - ${this.multiclass.subclass?.name}`,
type: 'multiclassSubclass',
values: multiclassSubclassFeatures
}
}
: {}),
companionFeatures: {
title: game.i18n.localize('DAGGERHEART.ACTORS.Character.companionFeatures'),
type: 'companion',
@ -804,6 +822,8 @@ export default class DhCharacter extends DhCreature {
prepareDerivedData() {
super.prepareDerivedData();
this.resources.hope.max -= this.scars;
if (this.companion) {
for (let levelKey in this.companion.system.levelData.levelups) {
const level = this.companion.system.levelData.levelups[levelKey];
@ -817,7 +837,6 @@ export default class DhCharacter extends DhCreature {
}
}
this.resources.hope.max -= this.scars;
this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait;
this.resources.armor = {
@ -835,6 +854,9 @@ export default class DhCharacter extends DhCreature {
}
this.attack.damage.parts.hitPoints.value.custom.formula = `@prof${this.basicAttackDamageDice}${this.rules.attack.damage.bonus ? ` + ${this.rules.attack.damage.bonus}` : ''}`;
// Clamp resources (must be done last to ensure all updates occur)
this.resources.clamp();
}
getRollData() {
@ -866,16 +888,17 @@ export default class DhCharacter extends DhCreature {
/* 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.hope) changes.system.resources.hope = { value: 0 };
changes.system.resources.hope = {
...changes.system.resources.hope,
value: changes.system.resources.hope.value + newHopeValue
};
const diff = this.scars - changes.system.scars;
const newHopeMax = this.resources.hope.max + diff;
const newHopeValue = Math.min(newHopeMax, this.resources.hope.value);
if (newHopeValue != this.resources.hope.value) {
changes.system = foundry.utils.mergeObject(changes.system ?? {}, {
resources: {
hope: {
value: (changes.system?.resources?.hope?.value ?? 0) + newHopeMax
}
}
});
}
}

View file

@ -146,9 +146,6 @@ export default class DhCompanion extends DhCreature {
const level = this.levelData.levelups[levelKey];
for (let selection of level.selections) {
switch (selection.type) {
case 'hope':
this.resources.hope += selection.value;
break;
case 'vicious':
if (selection.data[0] === 'damage') {
this.attack.damage.parts.hitPoints.value.dice = adjustDice(
@ -185,6 +182,9 @@ export default class DhCompanion extends DhCreature {
return acc;
}, this.partner.system.companionData.levelupChoices);
}
// Clamp resources (must be done last to ensure all updates occur)
this.resources.clamp();
}
async _preUpdate(changes, options, userId) {

View file

@ -60,14 +60,4 @@ export default class DhCreature extends BaseDataActor {
}
}
}
prepareDerivedData() {
const minLimitResource = resource => {
if (resource) resource.value = Math.min(resource.value, resource.max);
};
minLimitResource(this.resources.stress);
minLimitResource(this.resources.hitPoints);
minLimitResource(this.resources.hope);
}
}

43
module/data/actor/npc.mjs Normal file
View file

@ -0,0 +1,43 @@
import DHNPCSettings from '../../applications/sheets-configs/npc-settings.mjs';
import BaseDataActor from './base.mjs';
export default class DhpNPC extends BaseDataActor {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.NPC'];
static get metadata() {
return foundry.utils.mergeObject(super.metadata, {
label: 'TYPES.Actor.npc',
type: 'npc',
settingSheet: DHNPCSettings,
hasResistances: false,
hasAttribution: true
});
}
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
difficulty: new fields.NumberField({
nullable: true,
initial: null,
integer: true,
label: 'DAGGERHEART.GENERAL.difficulty'
}),
description: new fields.HTMLField({ label: 'DAGGERHEART.GENERAL.description' }),
motives: new fields.StringField(),
notes: new fields.HTMLField()
};
}
/**@inheritdoc */
static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/actors/drama-masks.svg';
get features() {
return this.parent.items.filter(x => x.type === 'feature');
}
isItemValid(source) {
return super.isItemValid(source) || source.type === 'feature';
}
}

View file

@ -18,7 +18,7 @@ export default class DhParty extends BaseDataActor {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
partyMembers: new ForeignDocumentUUIDArrayField({ type: 'Actor' }, { prune: true }),
partyMembers: new ForeignDocumentUUIDArrayField({ type: 'Actor' }),
notes: new fields.HTMLField(),
gold: new GoldField(),
tagTeam: new fields.EmbeddedDataField(TagTeamData),

View file

@ -0,0 +1,219 @@
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
import { parseInlineParams } from '../../enrichers/parser.mjs';
export function getTierAdjustedAdversary(source, tier) {
const currentTier = source.tier ?? 1;
/** @type {(2 | 3 | 4)[]} */
const tiers = new Array(Math.abs(tier - currentTier))
.fill(0)
.map((_, idx) => idx + Math.min(tier, currentTier) + 1);
if (tier < currentTier) tiers.reverse();
const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard];
const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] }));
// Apply simple tier changes
const scale = tier > currentTier ? 1 : -1;
for (const entry of tierEntries) {
source.system.difficulty += scale * entry.difficulty;
source.system.damageThresholds.major += scale * entry.majorThreshold;
source.system.damageThresholds.severe += scale * entry.severeThreshold;
source.system.resources.hitPoints.max += scale * entry.hp;
source.system.resources.stress.max += scale * entry.stress;
source.system.attack.roll.bonus += scale * entry.attack;
}
// Get the mean and standard deviation of expected damage in the previous and new tier
// The data we have is for attack scaling, but we reuse this for action scaling later
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
const damageMeta = {
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
newDamageRange: { tier, ...expectedDamageData[tier] }
};
// Store initial attack damage for abilities that have you deal a "standard attack"
const initialAttack = {
type: source.system.attack.damage?.parts.hitPoints?.type?.toSorted(),
value: getDamagePartsFormula(source.system.attack.damage?.parts.hitPoints?.value)
};
// Update damage of base attack.
try {
const damage = source.system.attack.damage;
if (!damage?.parts.hitPoints) throw new Error('Unexpected missing attack in adversary');
for (const property of ['value', 'valueAlt']) {
const data = damage.parts.hitPoints[property];
const previousFormula = getDamagePartsFormula(data);
const { value, formula } = calculateAdjustedDamage(previousFormula, 'attack', damageMeta);
applyAdjustedDamage(data, value, formula);
}
} catch (err) {
ui.notifications.warn('Failed to convert attack damage of adversary');
console.error(err);
}
// Update damage of each item action, making sure to also update the description if possible
const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g;
for (const item of source.items) {
// Replace damage inlines with new formulas. Keep a record for a specific check later
const descriptionFormulas = [];
for (const withDescription of [item.system, ...Object.values(item.system.actions)]) {
withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => {
const { value: formula } = parseInlineParams(inner, { first: 'value' });
if (!formula) return match;
try {
const newFormula = calculateAdjustedDamage(formula, 'action', damageMeta)?.formula;
descriptionFormulas.push(formula);
return match.replace(formula, newFormula);
} catch {
return match;
}
});
}
// Update damage in item actions and convert all formula matches in the descriptions to the new damage
for (const action of Object.values(item.system.actions)) {
if (!action.damage?.parts.hitPoints) continue;
try {
// Apply conversions and save a record. If it matches attack damage *and* Its not in the description, use attack conversion instead
const result = [];
for (const property of ['value', 'valueAlt']) {
const { [property]: data, type: damageType } = action.damage.parts.hitPoints;
const previousFormula = getDamagePartsFormula(data);
const isActuallyAttack =
previousFormula === initialAttack.value &&
foundry.utils.equals(damageType.toSorted(), initialAttack.type) &&
!descriptionFormulas.includes(previousFormula);
const type = isActuallyAttack ? 'attack' : 'action';
const { value, formula } = calculateAdjustedDamage(previousFormula, type, damageMeta);
applyAdjustedDamage(data, value, formula);
result.push({ previousFormula, formula });
}
// Override text in the description with those values
for (const { previousFormula, formula } of Object.values(result)) {
const oldFormulaRegexp = new RegExp(
previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?')
);
item.system.description = item.system.description.replace(oldFormulaRegexp, formula);
action.description = action.description.replace(oldFormulaRegexp, formula);
}
} catch (err) {
ui.notifications.warn(`Failed to convert action damage for item ${item.name}`);
console.error(err);
}
}
}
// Finally set the tier of the source data, now that everything is complete
source.system.tier = tier;
return source;
}
/**
* Converts a damage object to a new damage range
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
* @throws error if the formula is the wrong type
*/
function calculateAdjustedDamage(formula, type, { currentDamageRange, newDamageRange }) {
const terms = parseTermsFromSimpleFormula(formula);
const flatTerms = terms.filter(t => t.diceQuantity === 0);
const diceTerms = terms.filter(t => t.diceQuantity > 0);
if (flatTerms.length > 1 || diceTerms.length > 1) {
throw new Error('invalid formula for conversion');
}
const value = {
...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }),
bonus: flatTerms[0]?.bonus ?? 0
};
const previousExpected = calculateExpectedValue(value);
if (previousExpected === 0) return value; // nothing to do
const dieSizes = [4, 6, 8, 10, 12, 20];
const steps = newDamageRange.tier - currentDamageRange.tier;
const increasing = steps > 0;
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
// If this was just a flat number, convert to the expected damage and exit
if (value.diceQuantity === 0) {
value.bonus = Math.round(expected);
return value;
}
const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1;
const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 });
// Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die
const baseOverages = Math.floor(value.bonus / getExpectedDie());
// Prestep. Change number of dice for attacks, bump up/down for actions
// We never bump up to d20, though we might bump down from it
if (type === 'attack') {
const minimum = increasing ? value.diceQuantity : 0;
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
} else {
const currentIdx = dieSizes.indexOf(value.faces);
value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)];
}
value.bonus = Math.round(expected - getBaseAverage());
// Attempt to handle negative values.
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
if (value.bonus < 0) {
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
const currentIdx = dieSizes.indexOf(value.faces);
// If step downs alone don't suffice, change the flat modifier, then calculate steps required again
// If this isn't sufficient, the result will be slightly off. This is unlikely to happen
if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) {
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
value.bonus = Math.round(expected - getBaseAverage());
if (value.bonus >= 0) return value; // complete
}
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)];
value.bonus = Math.max(0, Math.round(expected - getBaseAverage()));
}
// If value is really high, we add a number of dice based on the number of overages
// This attempts to preserve a similar amount of variance when increasing an action
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
if (type !== 'attack' && increasing && overagesToRemove > 0) {
value.diceQuantity += overagesToRemove;
value.bonus = Math.round(expected - getBaseAverage());
}
const newFormula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
.filter(p => !!p)
.join('+');
return { value, formula: newFormula };
}
function getDamagePartsFormula(data) {
return data.custom.enabled
? data.custom.formula
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0].filter(p => !!p).join('+');
}
/**
* Updates damage to reflect a specific value.
* @throws if damage structure is invalid for conversion
* @returns the converted formula and value as a simplified term, or null if it doesn't deal HP damage
*/
function applyAdjustedDamage(diceData, value, formula) {
if (value.diceQuantity) {
diceData.custom.enabled = false;
diceData.bonus = value.bonus;
diceData.dice = `d${value.faces}`;
diceData.flatMultiplier = value.diceQuantity;
} else if (!value.diceQuantity) {
diceData.custom.enabled = true;
diceData.custom.formula = formula;
}
}

View file

@ -1,3 +1,5 @@
import { triggerChatRollFx } from '../../helpers/utils.mjs';
const fields = foundry.data.fields;
const targetsField = () =>
@ -130,6 +132,35 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
});
}
/* TODO: Change how damage data is stored somehow to enable better rerolling */
async getRerolledDamage() {
if (!this.damage) return;
const rerolls = [];
const update = { system: { damage: {} } };
for (const partKey in this.damage) {
const part = this.damage[partKey];
const testRoll = Roll.fromData(part.parts[0].roll);
const rerolled = await testRoll.reroll();
rerolls.push(rerolled);
if (!update.system.damage[partKey]) update.system.damage[partKey] = { parts: [part.parts[0]] };
const partData = update.system.damage[partKey].parts[0];
update.system.damage[partKey].total = rerolled.total;
partData.modifierTotal = rerolled.terms.reduce((acc, x) => {
if (x.isDeterministic && !x.operator) acc += x.total;
return acc;
}, 0);
partData.dice = rerolled.dice.map(d => ({ ...d.toJSON(), dice: d.denomination }));
partData.total = rerolled.total;
partData.roll = rerolled.toJSON();
}
await triggerChatRollFx(rerolls);
return update;
}
registerTargetHook() {
if (!this.parent.isAuthor || !this.hasTarget) return;
if (this.targetMode && this.parent.targetHook !== null) {

View file

@ -11,11 +11,13 @@ export default class CompendiumBrowserSettings extends foundry.abstract.DataMode
})
),
excludedPacks: new fields.TypedObjectField(
new fields.SchemaField({
excludedDocumentTypes: new fields.ArrayField(
new fields.StringField({ required: true, choices: CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES })
)
})
new fields.TypedObjectField(
new fields.SchemaField({
excludedDocumentTypes: new fields.ArrayField(
new fields.StringField({ required: true, choices: CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES })
)
})
)
)
};
}
@ -28,7 +30,7 @@ export default class CompendiumBrowserSettings extends foundry.abstract.DataMode
const excludedSourceData = this.excludedSources[packageName];
if (excludedSourceData && excludedSourceData.excludedDocumentTypes.includes(pack.metadata.type)) return true;
const excludedPackData = this.excludedPacks[item.pack];
const excludedPackData = this.excludedPacks[packageName]?.[pack.metadata.name];
if (excludedPackData && excludedPackData.excludedDocumentTypes.includes(pack.metadata.type)) return true;
return false;

View file

@ -103,7 +103,7 @@ export default class CostField extends fields.ArrayField {
static calcCosts(costs) {
const resources = CostField.getResources.call(this, costs);
let filteredCosts = costs;
if (this.parent?.metadata.isQuantifiable && this.parent.consumeOnUse === false) {
if (this.parent?.isInventoryItem && this.parent.consumeOnUse === false) {
filteredCosts = filteredCosts.filter(c => c.key !== 'quantity');
}

View file

@ -1,4 +1,4 @@
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../../../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent, RefreshType, socketEvent } from '../../../systemRegistration/socket.mjs';
const fields = foundry.data.fields;
@ -78,7 +78,7 @@ export default class CountdownField extends fields.ArrayField {
);
}
await emitAsGM(
await emitGMUpdate(
GMUpdateEvent.UpdateCountdowns,
async () => {
const countdownSetting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Countdowns);

View file

@ -72,9 +72,6 @@ export default class DamageField extends fields.SchemaField {
damageConfig.source.message = messageId;
damageConfig.directDamage = !!damageConfig.source?.message;
// if(damageConfig.source?.message && game.modules.get('dice-so-nice')?.active)
// await game.dice3d.waitFor3DAnimationByMessageID(damageConfig.source.message);
const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig);
if (!damageResult) return false;
if (damageResult.actionChatMessageHandled) config.actionChatMessageHandled = true;

View file

@ -1,5 +1,3 @@
import { emitAsGM, GMUpdateEvent } from '../../../systemRegistration/socket.mjs';
const fields = foundry.data.fields;
export default class EffectsField extends fields.ArrayField {
@ -34,8 +32,7 @@ export default class EffectsField extends fields.ArrayField {
}
if (EffectsField.getAutomation() || force) {
targets ??= (message.system?.targets ?? config.targets).filter(t => !config.hasRoll || t.hit);
await emitAsGM(GMUpdateEvent.UpdateEffect, EffectsField.applyEffects.bind(this), targets, this.uuid);
// EffectsField.applyEffects.call(this, config.targets.filter(t => !config.hasRoll || t.hit));
EffectsField.applyEffects.call(this, targets);
}
}
@ -59,7 +56,7 @@ export default class EffectsField extends fields.ArrayField {
if (!token) return;
const messageToken = token.document ?? token;
const conditionImmunities = messageToken.actor.system.rules.conditionImmunities ?? {};
const conditionImmunities = messageToken.actor.system.rules?.conditionImmunities ?? {};
messageTargets.push({
token: messageToken,
conditionImmunities: Object.values(conditionImmunities).some(x => x)

View file

@ -1,4 +1,4 @@
import { itemAbleRollParse } from '../../../helpers/utils.mjs';
import { itemAbleRollParse, triggerChatRollFx } from '../../../helpers/utils.mjs';
import FormulaField from '../formulaField.mjs';
const fields = foundry.data.fields;
@ -40,7 +40,7 @@ export default class DHSummonField extends fields.ArrayField {
const roll = new Roll(itemAbleRollParse(summon.count, this.actor, this.item));
await roll.evaluate();
const count = roll.total;
if (!roll.isDeterministic && game.modules.get('dice-so-nice')?.active) rolls.push(roll);
if (!roll.isDeterministic) rolls.push(roll);
const actor = await DHSummonField.getWorldActor(await foundry.utils.fromUuid(summon.actorUUID));
/* Extending summon data in memory so it's available in actionField.toChat. Think it's harmless, but ugly. Could maybe find a better way. */
@ -56,7 +56,7 @@ export default class DHSummonField extends fields.ArrayField {
}
}
if (rolls.length) await Promise.all(rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true)));
if (rolls.length) await triggerChatRollFx(rolls);
this.actor.sheet?.minimize();
DHSummonField.handleSummon(summonData, this.actor);

View file

@ -80,6 +80,18 @@ class ResourcesField extends fields.TypedObjectField {
value.isReversed = resources[key].reverse;
value.max = typeof resource.max === 'number' ? (value.max ?? resource.max) : null;
}
Object.defineProperty(data, 'clamp', {
value: function () {
for (const key of Object.keys(this)) {
const resource = this[key];
if (typeof resource?.max === 'number') {
resource.value = Math.clamp(resource.value, 0, resource.max);
}
}
},
enumerable: false
});
return data;
}

View file

@ -10,6 +10,7 @@ export default class ForeignDocumentUUIDArrayField extends foundry.data.fields.A
*/
constructor(fieldOption = {}, options = {}, context = {}) {
super(new ForeignDocumentUUIDField(fieldOption), options, context);
this.options.prune ??= true;
}
/** @inheritdoc */

View file

@ -30,7 +30,7 @@ export class CharacterData extends foundry.abstract.DataModel {
}),
rollData: new fields.JSONField({ nullable: true, initial: null }),
selected: new fields.BooleanField({ initial: false }),
successfull: new fields.BooleanField({ nullable: true, initial: null })
successful: new fields.BooleanField({ nullable: true, initial: null })
};
}

View file

@ -1,6 +1,6 @@
import BaseDataItem from './base.mjs';
import ItemLinkFields from '../../data/fields/itemLinkFields.mjs';
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
export default class DHAncestry extends BaseDataItem {
/** @inheritDoc */
@ -45,6 +45,10 @@ export default class DHAncestry extends BaseDataItem {
/**@inheritdoc */
async getDescriptionData() {
// Preload all ancestry features for acquisition from the cache
// todo: make feature acquisition async and replace feature helpers for methods
await fromUuids(this._source.features.map(f => f.item));
const baseDescription = this.description;
const features = await getFeaturesHTMLData(this.features);

View file

@ -141,16 +141,6 @@ export default class DHArmor extends AttachableItem {
}
}
_onUpdate(a, b, c) {
super._onUpdate(a, b, c);
if (this.actor?.type === 'character') {
for (const party of this.actor.parties) {
party.render();
}
}
}
/** @inheritDoc */
static migrateDocumentData(source) {
if (!source.system.armor) {

View file

@ -7,7 +7,12 @@
* @property {boolean} isInventoryItem- Indicates whether items of this type is a Inventory Item
*/
import { addLinkedItemsDiff, getScrollTextData, updateLinkedItemApps } from '../../helpers/utils.mjs';
import {
addLinkedItemsDiff,
getScrollTextData,
createShallowProxy,
updateLinkedItemApps
} from '../../helpers/utils.mjs';
import { ActionsField } from '../fields/actionField.mjs';
import FormulaField from '../fields/formulaField.mjs';
@ -159,8 +164,8 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
* @returns {object}
*/
getRollData(options = {}) {
const actorRollData = this.actor?.getRollData() ?? {};
const data = { ...actorRollData, item: { ...this } };
const data = this.actor?.getRollData() ?? {};
data.item = createShallowProxy(this);
return data;
}
@ -195,7 +200,7 @@ export default class BaseDataItem extends foundry.abstract.TypeDataModel {
const features = [];
for (let f of this.features) {
const fBase = f.item ?? f;
const feature = fBase.system ? fBase : await foundry.utils.fromUuid(fBase.uuid);
const feature = fBase.pack ? await foundry.utils.fromUuid(fBase.uuid) : fBase;
features.push(
foundry.utils.mergeObject(
feature.toObject(),

View file

@ -51,7 +51,8 @@ export default class DHBeastform extends BaseDataItem {
}),
scale: new fields.NumberField({ nullable: false, min: 0.2, max: 3, step: 0.05, initial: 1 }),
height: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true }),
width: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true })
width: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true }),
depth: new fields.NumberField({ integer: true, min: 1, initial: null, nullable: true })
}),
mainTrait: new fields.StringField({
required: true,
@ -192,7 +193,8 @@ export default class DHBeastform extends BaseDataItem {
tokenSize: {
scale: this.parent.parent.prototypeToken.texture.scaleX,
height: this.parent.parent.prototypeToken.height,
width: this.parent.parent.prototypeToken.width
width: this.parent.parent.prototypeToken.width,
depth: this.parent.parent.prototypeToken.depth
}
},
advantageOn: this.advantageOn,
@ -211,10 +213,12 @@ export default class DHBeastform extends BaseDataItem {
: null;
const width = autoTokenSize ?? this.tokenSize.width;
const height = autoTokenSize ?? this.tokenSize.height;
const depth = autoTokenSize ?? this.tokenSize.depth;
const prototypeTokenUpdate = {
height,
width,
depth,
texture: {
src: this.tokenImg,
scaleX: this.tokenSize.scale,

View file

@ -2,7 +2,7 @@ import BaseDataItem from './base.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
import ItemLinkFields from '../fields/itemLinkFields.mjs';
import { addLinkedItemsDiff, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
import { addLinkedItemsDiff, fromUuids, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
import { DhLevelOption } from '../levelTier.mjs';
export default class DHClass extends BaseDataItem {
@ -32,7 +32,6 @@ export default class DHClass extends BaseDataItem {
}),
evasion: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.evasion' }),
features: new ItemLinkFields(),
subclasses: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
inventory: new fields.SchemaField({
take: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
choiceA: new ForeignDocumentUUIDArrayField({ type: 'Item', required: false }),
@ -78,6 +77,25 @@ export default class DHClass extends BaseDataItem {
return this.features.filter(x => x.type === CONFIG.DH.ITEM.featureSubTypes.class).map(x => x.item);
}
async fetchSubclasses() {
const uuids = [this.parent.uuid, this.parent._stats?.compendiumSource].filter(u => !!u);
const subclasses = game.items.filter(x => x.type === 'subclass' && uuids.includes(x.system.linkedClass));
for (const pack of game.packs) {
const packIds = [];
const indexes = await pack.getIndex({ fields: ['system.linkedClass'] });
for (const index of indexes) {
if (index.type !== 'subclass') continue;
if (!uuids.includes(index.system?.linkedClass)) continue;
if (subclasses.find(x => x.uuid === index.uuid)) continue;
packIds.push(index._id);
}
if (packIds.length > 0) subclasses.push(...(await pack.getDocuments({ _id__in: packIds })));
}
return subclasses;
}
async _preCreate(data, options, user) {
if (this.actor?.type === 'character') {
const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;
@ -207,6 +225,10 @@ export default class DHClass extends BaseDataItem {
classItems.push(contentLink.outerHTML);
}
// Preload all class features for acquisition from the cache
// todo: make feature acquisition async and replace feature helpers for methods
await fromUuids(this._source.features.map(f => f.item));
const hopeFeatures = await getFeaturesHTMLData(this.hopeFeatures);
const classFeatures = await getFeaturesHTMLData(this.classFeatures);

View file

@ -1,4 +1,4 @@
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
import BaseDataItem from './base.mjs';
@ -27,6 +27,10 @@ export default class DHCommunity extends BaseDataItem {
/**@inheritdoc */
async getDescriptionData() {
// Preload all community features for acquisition from the cache
// todo: make feature acquisition async and replace feature helpers for methods
await fromUuids(this._source.features);
const baseDescription = this.description;
const features = await getFeaturesHTMLData(this.features);

View file

@ -1,5 +1,4 @@
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import { fromUuids, getFeaturesHTMLData } from '../../helpers/utils.mjs';
import ItemLinkFields from '../fields/itemLinkFields.mjs';
import BaseDataItem from './base.mjs';
@ -28,7 +27,7 @@ export default class DHSubclass extends BaseDataItem {
features: new ItemLinkFields(),
featureState: new fields.NumberField({ required: true, initial: 1, min: 1 }),
isMulticlass: new fields.BooleanField({ initial: false }),
linkedClass: new ForeignDocumentUUIDField({ type: 'Item', nullable: true, initial: null })
linkedClass: new fields.DocumentUUIDField({ type: 'Item', nullable: true, initial: null })
};
}
@ -56,37 +55,30 @@ export default class DHSubclass extends BaseDataItem {
if (allowed === false) return;
if (this.actor?.type === 'character') {
const dataUuid = data.uuid ?? data._stats.compendiumSource ?? `Item.${data._id}`;
if (this.actor.system.class.subclass) {
if (this.actor.system.multiclass.subclass) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassesAlreadyPresent'));
return false;
} else {
const multiclass = this.actor.items.find(x => x.type === 'class' && x.system.isMulticlass);
if (!multiclass) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.missingMulticlass'));
return false;
}
const { value: actorClass, subclass: existingSubclass } = this.actor.system.class;
const { value: multiclass, subclass: existingMultisubclass } = this.actor.system.multiclass;
if (!actorClass && !multiclass) {
ui.notifications.warn('DAGGERHEART.UI.Notifications.missingClass', { localize: true });
return false;
}
if (existingSubclass && existingMultisubclass) {
ui.notifications.warn('DAGGERHEART.UI.Notifications.subclassesAlreadyPresent', { localize: true });
return false;
}
if (existingSubclass && !multiclass) {
ui.notifications.warn('DAGGERHEART.UI.Notifications.missingMulticlass', { localize: true });
return false;
}
if (multiclass.system.subclasses.every(x => x.uuid !== dataUuid)) {
ui.notifications.error(
game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInMulticlass')
);
return false;
}
await this.updateSource({ isMulticlass: true });
}
} else {
const actorClass = this.actor.items.find(x => x.type === 'class' && !x.system.isMulticlass);
if (!actorClass) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.missingClass'));
return false;
}
if (actorClass.system.subclasses.every(x => x.uuid !== dataUuid)) {
ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.subclassNotInClass'));
return false;
}
const match = [multiclass, actorClass].find(
c => c && (c._stats.compendiumSource ?? c.uuid) === this.linkedClass
);
if (!match) {
const key = multiclass ? 'subclassNotInMulticlass' : 'subclassNotInClass';
ui.notifications.warn(`DAGGERHEART.UI.Notifications.${key}`, { localize: true });
return false;
} else if (match.system.isMulticlass) {
await this.updateSource({ isMulticlass: true });
}
}
}
@ -98,6 +90,11 @@ export default class DHSubclass extends BaseDataItem {
const spellcastTrait = this.spellcastingTrait
? game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.spellcastingTrait].label)
: null;
// Preload all subclass features for acquisition from the cache
// todo: make feature acquisition async and replace feature helpers for methods
await fromUuids(this._source.features.map(f => f.item));
const foundationFeatures = await getFeaturesHTMLData(this.foundationFeatures);
const specializationFeatures = await getFeaturesHTMLData(this.specializationFeatures);
const masteryFeatures = await getFeaturesHTMLData(this.masteryFeatures);

View file

@ -28,7 +28,10 @@ export default class DHWeapon extends AttachableItem {
equipped: new fields.BooleanField({ initial: false }),
//SETTINGS
secondary: new fields.BooleanField({ initial: false, label: 'DAGGERHEART.ITEMS.Weapon.secondaryWeapon' }),
secondary: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.ITEMS.Weapon.secondaryWeapon.full'
}),
burden: new fields.StringField({
required: true,
choices: CONFIG.DH.GENERAL.burden,

View file

@ -19,7 +19,7 @@ export default class DHScene extends foundry.abstract.DataModel {
close: new fields.NumberField({ integer: true, label: 'DAGGERHEART.CONFIG.Range.close.name' }),
far: new fields.NumberField({ integer: true, label: 'DAGGERHEART.CONFIG.Range.far.name' })
}),
sceneEnvironments: new ForeignDocumentUUIDArrayField({ type: 'Actor', prune: true })
sceneEnvironments: new ForeignDocumentUUIDArrayField({ type: 'Actor' })
};
}
}

View file

@ -1,4 +1,5 @@
import { defaultRestOptions } from '../../config/generalConfig.mjs';
import { resetAndRerenderActors } from '../../helpers/utils.mjs';
import { ActionsField } from '../fields/actionField.mjs';
const currencyField = (initial, label, icon) =>
@ -54,7 +55,7 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
maxDomains: new fields.NumberField({
required: true,
integer: true,
min: 1,
min: 0,
initial: 2,
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxDomains.label'
}),
@ -196,6 +197,12 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
return source;
}
_initialize(options) {
super._initialize(options);
this.maxDomains ||= Infinity;
this.maxLoadout ||= Infinity;
}
/** Invoked by the setting when data changes */
handleChange() {
if (this.maxFear) {
@ -203,7 +210,7 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
}
this.refreshConfig();
this.#resetActors();
resetAndRerenderActors();
}
/** Update config values based on homebrew data. Make sure the references don't change */
@ -224,29 +231,6 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
});
}
}
/**
* Triggers a reset and non-forced re-render on all given actors (if given)
* or all world actors and actors in all scenes to show immediate results for a changed setting.
*/
#resetActors() {
const actors = new Set(
[
game.actors.contents,
game.scenes.contents.flatMap(s => s.tokens.contents).flatMap(t => t.actor ?? [])
].flat()
);
for (const actor of actors) {
for (const app of Object.values(actor.apps)) {
for (const element of app.element?.querySelectorAll('prose-mirror.active')) {
element.open = false; // This triggers a save
}
}
actor.reset();
actor.render();
}
}
}
export class Resource extends foundry.abstract.DataModel {

View file

@ -1,3 +1,5 @@
import { resetAndRerenderActors } from '../../helpers/utils.mjs';
export default class DhMetagaming extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
@ -6,7 +8,24 @@ export default class DhMetagaming extends foundry.abstract.DataModel {
initial: false,
label: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hideObserverPermissionInChat.label',
hint: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hideObserverPermissionInChat.hint'
}),
hidePartyStats: new fields.StringField({
initial: 'never',
label: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.label',
hint: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.hint',
required: true,
nullable: false,
choices: {
never: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.choices.never',
players: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.choices.players',
always: 'DAGGERHEART.SETTINGS.Metagaming.FIELDS.hidePartyStats.choices.always'
}
})
};
}
/** Invoked by the setting when data changes */
handleChange() {
resetAndRerenderActors();
}
}

View file

@ -1,4 +1,5 @@
import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs';
import { triggerChatRollFx } from '../helpers/utils.mjs';
import DHRoll from './dhRoll.mjs';
export default class D20Roll extends DHRoll {
@ -224,4 +225,15 @@ export default class D20Roll extends DHRoll {
resetFormula() {
return (this._formula = this.constructor.getFormula(this.terms));
}
async reroll(options) {
const result = await super.reroll(options);
if (this instanceof game.system.api.dice.DualityRoll) return result;
if (options?.liveRoll) {
await triggerChatRollFx([result]);
}
return result;
}
}

View file

@ -1,5 +1,5 @@
import DamageDialog from '../applications/dialogs/damageDialog.mjs';
import { parseRallyDice } from '../helpers/utils.mjs';
import { parseRallyDice, triggerChatRollFx } from '../helpers/utils.mjs';
import DHRoll from './dhRoll.mjs';
export default class DamageRoll extends DHRoll {
@ -18,7 +18,12 @@ export default class DamageRoll extends DHRoll {
if (config.evaluate !== false) for (const roll of config.roll) await roll.roll.evaluate();
roll._evaluated = true;
const parts = config.roll.map(r => this.postEvaluate(r));
const parts = [];
for (const roll of config.roll) {
parts.push(this.postEvaluate(roll));
roll.roll = JSON.stringify(roll.roll.toJSON());
}
config.damage = this.unifyDamageRoll(parts);
}
@ -38,25 +43,24 @@ export default class DamageRoll extends DHRoll {
const chatMessage = config.source?.message
? ui.chat.collection.get(config.source.message)
: getDocumentClass('ChatMessage').applyMode({}, config.rollMode ?? 'public');
const diceRolls = [];
if (game.modules.get('dice-so-nice')?.active) {
const pool = foundry.dice.terms.PoolTerm.fromRolls(
Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll))
),
diceRoll = Roll.fromTerms([pool]);
await game.dice3d.showForRoll(
diceRoll,
game.user,
true,
chatMessage.whisper?.length > 0 ? chatMessage.whisper : null,
chatMessage.blind
);
config.mute = true;
const pool = foundry.dice.terms.PoolTerm.fromRolls(
Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll))
);
diceRolls.push(Roll.fromTerms([pool]));
}
await triggerChatRollFx(diceRolls, {
whisper: chatMessage.whisper?.length > 0 ? chatMessage.whisper : null,
blind: chatMessage.blind
});
await super.buildPost(roll, config, message);
if (config.source?.message) {
chatMessage.update({ 'system.damage': config.damage });
if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
}
}
@ -319,9 +323,10 @@ export default class DamageRoll extends DHRoll {
const newIndex = parsedDiceTerms[dice].results.length;
await term.reroll(`/r1=${termResult.result}`);
const diceRolls = [];
if (game.modules.get('dice-so-nice')?.active) {
const newResult = parsedDiceTerms[dice].results[newIndex];
const diceSoNiceRoll = {
diceRolls.push({
_evaluated: true,
dice: [
new foundry.dice.terms.Die({
@ -332,11 +337,10 @@ export default class DamageRoll extends DHRoll {
})
],
options: { appearance: {} }
};
await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true);
});
}
await triggerChatRollFx(diceRolls);
await parsedRoll.evaluate();
const results = parsedRoll.dice[dice].results.map(result => ({

View file

@ -1,4 +1,5 @@
import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs';
import { triggerChatRollFx } from '../helpers/utils.mjs';
export default class DHRoll extends Roll {
baseTerms = [];
@ -36,6 +37,7 @@ export default class DHRoll extends Roll {
static async buildConfigure(config = {}, message = {}) {
config.hooks = [...this.getHooks(), ''];
config.dialog ??= {};
config.damageOptions ??= {};
for (const hook of config.hooks) {
if (Hooks.call(`${CONFIG.DH.id}.preRoll${hook.capitalize()}`, config, message) === false) return null;
@ -75,9 +77,7 @@ export default class DHRoll extends Roll {
}
if (config.skips?.createMessage) {
if (game.modules.get('dice-so-nice')?.active) {
await game.dice3d.showForRoll(roll, game.user, true);
}
await triggerChatRollFx([roll]);
} else if (!config.source?.message) {
config.message = await this.toMessage(roll, config);
}
@ -85,6 +85,7 @@ export default class DHRoll extends Roll {
static postEvaluate(roll, config = {}) {
return {
...roll.options.roll,
total: roll.total,
formula: roll.formula,
dice: roll.dice.map(d => ({
@ -141,8 +142,8 @@ export default class DHRoll extends Roll {
const metagamingSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Metagaming);
const chatData = await this._prepareChatRenderContext({ flavor, isPrivate, ...options });
return foundry.applications.handlebars.renderTemplate(template, {
...chatData,
roll: this,
...chatData,
parent: chatData.parent,
targetMode: chatData.targetMode,
areas: chatData.action?.areas,
@ -257,7 +258,7 @@ export default class DHRoll extends Roll {
if (!roll.terms[i].isDeterministic) continue;
const termTotal = roll.terms[i].total;
if (typeof termTotal === 'number') {
const multiplier = roll.terms[i - 1]?.operator === ' - ' ? -1 : 1;
const multiplier = roll.terms[i - 1]?.operator === '-' ? -1 : 1;
modifierTotal += multiplier * termTotal;
}
}

View file

@ -1,4 +1,4 @@
import { ResourceUpdateMap } from '../../data/action/baseAction.mjs';
import { updateResourcesForDualityReroll } from '../helpers.mjs';
export default class DualityDie extends foundry.dice.terms.Die {
constructor(options) {
@ -12,24 +12,6 @@ export default class DualityDie extends foundry.dice.terms.Die {
return roll.withHope ? 1 : roll.withFear ? -1 : 0;
}
#updateResources(oldDuality, newDuality, actor) {
const { hopeFear } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation);
if (game.user.isGM ? !hopeFear.gm : !hopeFear.players) return;
const updates = [];
const hope = (newDuality >= 0 ? 1 : 0) - (oldDuality >= 0 ? 1 : 0);
const stress = (newDuality === 0 ? 1 : 0) - (oldDuality === 0 ? 1 : 0);
const fear = (newDuality === -1 ? 1 : 0) - (oldDuality === -1 ? 1 : 0);
if (hope !== 0) updates.push({ key: 'hope', value: hope, total: -1 * hope, enabled: true });
if (stress !== 0) updates.push({ key: 'stress', value: -1 * stress, total: stress, enabled: true });
if (fear !== 0) updates.push({ key: 'fear', value: fear, total: -1 * fear, enabled: true });
const resourceUpdates = new ResourceUpdateMap(actor);
resourceUpdates.addResources(updates);
resourceUpdates.updateResources();
}
async reroll(modifier, options) {
const oldDuality = this.#getDualityState(options.liveRoll.roll);
await super.reroll(modifier, options);
@ -57,7 +39,7 @@ export default class DualityDie extends foundry.dice.terms.Die {
if (options.liveRoll.isReaction) return;
const newDuality = this.#getDualityState(options.liveRoll.roll);
this.#updateResources(oldDuality, newDuality, options.liveRoll.actor);
updateResourcesForDualityReroll(oldDuality, newDuality, options.liveRoll.actor);
}
}

View file

@ -1,6 +1,8 @@
import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs';
import D20Roll from './d20Roll.mjs';
import { parseRallyDice, setDiceSoNiceForDualityRoll } from '../helpers/utils.mjs';
import { getDiceSoNicePresets } from '../config/generalConfig.mjs';
import { updateResourcesForDualityReroll } from './helpers.mjs';
export default class DualityRoll extends D20Roll {
_advantageNumber = 1;
@ -128,32 +130,34 @@ export default class DualityRoll extends D20Roll {
}
createBaseDice() {
if (
this.dice[0] instanceof game.system.api.dice.diceTypes.HopeDie &&
this.dice[1] instanceof game.system.api.dice.diceTypes.FearDie
) {
this.terms = [this.terms[0], this.terms[1], this.terms[2]];
return;
}
this.terms = [this.terms[0], this.terms[1], this.terms[2]];
this.terms[0] = new game.system.api.dice.diceTypes.HopeDie({
faces: this.data.rules.roll?.hopeFaces ?? 12
faces: this.terms[0]?.faces ?? this.data.rules.dualityRoll?.defaultHopeDice ?? 12
});
this.terms[1] = new foundry.dice.terms.OperatorTerm({ operator: '+' });
this.terms[2] = new game.system.api.dice.diceTypes.FearDie({
faces: this.data.rules.roll?.fearFaces ?? 12
faces: this.terms[2]?.faces ?? this.data.rules.dualityRoll?.defaultFearDice ?? 12
});
}
applyAdvantage() {
if (this.hasAdvantage || this.hasDisadvantage) {
const dieFaces = this.advantageFaces,
advDie = new foundry.dice.terms.Die({ faces: dieFaces, number: this.advantageNumber });
if (this.advantageNumber > 1) advDie.modifiers = ['kh'];
this.terms.push(
new foundry.dice.terms.OperatorTerm({ operator: this.hasDisadvantage ? '-' : '+' }),
advDie
);
const advDieClass = this.hasAdvantage
? game.system.api.dice.diceTypes.AdvantageDie
: this.hasDisadvantage
? game.system.api.dice.diceTypes.DisadvantageDie
: null;
if (advDieClass) {
const advDie = new advDieClass({ faces: this.advantageFaces, number: this.advantageNumber });
if (this.terms.length < 4) {
if (this.advantageNumber > 1) advDie.modifiers = ['kh'];
this.terms.push(
new foundry.dice.terms.OperatorTerm({ operator: this.hasDisadvantage ? '-' : '+' }),
advDie
);
} else {
this.terms[4] = advDie;
}
}
if (this.rallyFaces)
this.terms.push(
@ -378,4 +382,40 @@ export default class DualityRoll extends D20Roll {
if (currentCombatant?.actorId == config.data.id) ui.combat.setCombatantSpotlight(currentCombatant.id);
}
}
async reroll(options) {
const oldDuality = this.withHope ? 1 : this.withFear ? -1 : 0;
const rerolled = DualityRoll.fromData((await super.reroll(options)).toJSON());
if (options?.liveRoll) {
if (game.modules.get('dice-so-nice')?.active) {
const diceAppearance = await getDiceSoNicePresets(
rerolled,
rerolled.dHope.denomination,
rerolled.dFear.denomination
);
rerolled.dHope.options.appearance = diceAppearance.hope.appearance;
rerolled.dFear.options.appearance = diceAppearance.fear.appearance;
if (rerolled.dAdvantage) rerolled.dAdvantage.options.appearance = diceAppearance.advantage.appearance;
if (rerolled.dDisadvantage)
rerolled.dDisadvantage.options.appearance = diceAppearance.disadvantage.appearance;
await game.dice3d.showForRoll(rerolled, game.user, true);
} else {
foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
}
if (this.options.actionType === 'reaction') return;
const newDuality = rerolled.withHope ? 1 : rerolled.withFear ? -1 : 0;
const actor = await foundry.utils.fromUuid(this.options.source.actor);
updateResourcesForDualityReroll(oldDuality, newDuality, actor);
}
return rerolled;
}
fromJSON(json) {
return super.fromJSON(json);
}
}

19
module/dice/helpers.mjs Normal file
View file

@ -0,0 +1,19 @@
import { ResourceUpdateMap } from '../data/action/baseAction.mjs';
export function updateResourcesForDualityReroll(oldDuality, newDuality, actor) {
const { hopeFear } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation);
if (game.user.isGM ? !hopeFear.gm : !hopeFear.players) return;
const updates = [];
const hope = (newDuality >= 0 ? 1 : 0) - (oldDuality >= 0 ? 1 : 0);
const stress = (newDuality === 0 ? 1 : 0) - (oldDuality === 0 ? 1 : 0);
const fear = (newDuality === -1 ? 1 : 0) - (oldDuality === -1 ? 1 : 0);
if (hope !== 0) updates.push({ key: 'hope', value: hope, total: -1 * hope, enabled: true });
if (stress !== 0) updates.push({ key: 'stress', value: -1 * stress, total: stress, enabled: true });
if (fear !== 0) updates.push({ key: 'fear', value: fear, total: -1 * fear, enabled: true });
const resourceUpdates = new ResourceUpdateMap(actor);
resourceUpdates.addResources(updates);
resourceUpdates.updateResources();
}

View file

@ -169,27 +169,37 @@ export default class DhActiveEffect extends foundry.documents.ActiveEffect {
super._applyChangeUnguided(actor, change, changes, options);
}
/** Recursively finds the first parent document of the given object */
static #resolveParentDocument(model, documentClass) {
if (!model) return null;
return model instanceof documentClass
? model
: model.parent
? this.#resolveParentDocument(model.parent, documentClass)
: null;
}
static getChangeValue(model, change, effect) {
let key = change.value.toString();
const isOriginTarget = key.toLowerCase().includes('origin.@');
let parseModel = model;
if (isOriginTarget && effect.origin) {
key = change.key.replaceAll(/origin\.@/gi, '@');
try {
const originEffect = foundry.utils.fromUuidSync(effect.origin);
const doc =
originEffect.parent?.parent instanceof game.system.api.documents.DhpActor
? originEffect.parent
: originEffect.parent.parent;
if (doc) parseModel = doc;
} catch (_) {}
let value = change.value.toString();
const useOrigin = value.toLowerCase().includes('origin.@') && effect.origin;
let origin = null;
if (effect.origin) {
if (useOrigin) value = value.replaceAll(/origin\.@/gi, '@');
const originEffect = foundry.utils.fromUuidSync(effect.origin);
origin = this.#resolveParentDocument(originEffect, Item);
}
// Get the actor and item documents. Note that actor roll data is inclusive of system roll data
const actor = this.#resolveParentDocument(model, Actor);
const item =
(useOrigin ? origin : null) ??
this.#resolveParentDocument(effect.parent, Item) ??
(origin?.actor === actor ? origin : null);
const stackingParsedValue = effect.system.stacking
? Roll.replaceFormulaData(key, { stacks: effect.system.stacking.value })
: key;
const evalValue = itemAbleRollParse(stackingParsedValue, parseModel, effect.parent);
return evalValue ?? key;
? Roll.replaceFormulaData(value, { stacks: effect.system.stacking.value })
: value;
const evalValue = this.effectSafeEval(itemAbleRollParse(stackingParsedValue, actor, item));
return evalValue ?? value;
}
/**

View file

@ -1,7 +1,7 @@
import { emitAsGM, GMUpdateEvent } from '../systemRegistration/socket.mjs';
import { emitGMUpdate, GMUpdateEvent } from '../systemRegistration/socket.mjs';
import { LevelOptionType } from '../data/levelTier.mjs';
import DHFeature from '../data/item/feature.mjs';
import { createScrollText, damageKeyToNumber, getDamageKey } from '../helpers/utils.mjs';
import { createScrollText, damageKeyToNumber, getDamageKey, createShallowProxy } from '../helpers/utils.mjs';
import DhCompanionLevelUp from '../applications/levelup/companionLevelup.mjs';
import { ResourceUpdateMap } from '../data/action/baseAction.mjs';
import { abilities } from '../config/actorConfig.mjs';
@ -65,6 +65,11 @@ export default class DhpActor extends Actor {
};
}
static createDialog(data, createOptions, options, renderOptions) {
options.classes = [options.classes ?? [], 'actor-create'].flat(); // handled in hook
return super.createDialog(data, createOptions, options, renderOptions);
}
/* -------------------------------------------- */
/** @inheritDoc */
@ -99,7 +104,7 @@ export default class DhpActor extends Actor {
}
// Configure prototype token settings
if (['character', 'companion', 'party'].includes(this.type))
if (['character', 'companion', 'party'].includes(this.type)) {
Object.assign(update, {
prototypeToken: {
sight: { enabled: true },
@ -107,13 +112,35 @@ export default class DhpActor extends Actor {
disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
}
});
}
if (this.type === 'npc') {
Object.assign(update, {
prototypeToken: {
disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
}
});
}
this.updateSource(update);
}
/** Perform a render, debounced in order to prevent overloading repeat render requests */
renderDebounced = foundry.utils.debounce(options => {
return this.render(options);
}, 10);
_onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId) {
super._onUpdateDescendantDocuments(parent, collection, documents, changes, options, userId);
for (const party of this.parties) {
party.renderDebounced({ parts: ['partyMembers'] });
}
}
_onUpdate(changes, options, userId) {
super._onUpdate(changes, options, userId);
for (const party of this.parties) {
party.render({ parts: ['partyMembers'] });
party.renderDebounced({ parts: ['partyMembers'] });
}
}
@ -132,17 +159,20 @@ export default class DhpActor extends Actor {
_onDelete(options, userId) {
super._onDelete(options, userId);
for (const party of this.parties) {
party.render({ parts: ['partyMembers'] });
party.renderDebounced({ parts: ['partyMembers'] });
}
}
async updateLevel(newLevel) {
if (!['character', 'companion'].includes(this.type) || newLevel === this.system.levelData.level.changed) return;
const tiers = Object.values(game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LevelTiers).tiers);
const maxLevel = tiers.reduce((acc, tier) => Math.max(acc, tier.levels.end), 0);
const multiclassMinLevel = Math.min(
maxLevel,
...tiers.filter(t => t.options.multiclass).map(t => t.levels.start)
);
if (newLevel > this.system.levelData.level.current) {
const maxLevel = Object.values(
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.LevelTiers).tiers
).reduce((acc, tier) => Math.max(acc, tier.levels.end), 0);
if (newLevel > maxLevel) {
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.tooHighLevel'));
}
@ -217,18 +247,19 @@ export default class DhpActor extends Actor {
this.system.multiclass.subclass.update({ 'system.featureState': subclassFeatureState.multiclass });
}
if (multiclass) {
const multiclassItem = this.items.find(x => x.uuid === multiclass.itemUuid);
const multiclassFeatures = this.items.filter(
x => x.system.originItemType === 'class' && x.system.multiclassOrigin
);
const subclassFeatures = this.items.filter(
x => x.system.originItemType === 'subclass' && x.system.multiclassOrigin
// Remove multiclass if we're removing a multiclass feature or if we're below the multiclass minimum level
// Multclasses cannot be manually removed on the sheet, so this allows recovering in the case of errors
if (multiclass || newLevel < multiclassMinLevel) {
const multiclassItems = this.items.filter(
x =>
x.uuid === multiclass?.itemUuid ||
x.system.isMulticlass ||
(['class', 'subclass'].includes(x.system.originItemType) && x.system.multiclassOrigin)
);
this.deleteEmbeddedDocuments(
'Item',
[multiclassItem, ...multiclassFeatures, ...subclassFeatures].map(x => x.id)
multiclassItems.map(x => x.id)
);
this.update({
@ -267,6 +298,7 @@ export default class DhpActor extends Actor {
async levelUp(levelupData) {
const levelupAuto = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation).levelupAuto;
const getStatsWithSource = document => ({ ...(document._stats ?? {}), compendiumSource: document.uuid });
const levelups = {};
for (var levelKey of Object.keys(levelupData)) {
@ -379,8 +411,8 @@ export default class DhpActor extends Actor {
const embeddedItem = await this.createEmbeddedDocuments('Item', [
{
...multiclassData,
uuid: multiclassItem.uuid,
_stats: multiclassItem._stats,
uuid: multiclassItem.uuid, // todo: replace with setting an id and using keepId
_stats: getStatsWithSource(multiclassItem),
system: {
...multiclassData.system,
features: multiclassData.system.features.filter(x => x.type !== 'hope'),
@ -393,8 +425,8 @@ export default class DhpActor extends Actor {
await this.createEmbeddedDocuments('Item', [
{
...subclassData,
uuid: subclassItem.uuid,
_stats: subclassItem._stats,
uuid: subclassItem.uuid, // todo: replace with setting an id and using keepId
_stats: getStatsWithSource(subclassItem),
system: {
...subclassData.system,
isMulticlass: true
@ -414,8 +446,8 @@ export default class DhpActor extends Actor {
const embeddedItem = await this.createEmbeddedDocuments('Item', [
{
...cardData,
uuid: cardItem.uuid,
_stats: cardItem._stats,
uuid: cardItem.uuid, // todo: replace with setting an id and using keepId
_stats: getStatsWithSource(cardItem),
system: {
...cardData.system,
inVault: true
@ -436,8 +468,7 @@ export default class DhpActor extends Actor {
const embeddedItem = await this.createEmbeddedDocuments('Item', [
{
...cardData,
uuid: cardItem.uuid,
_stats: cardItem._stats,
_stats: getStatsWithSource(cardItem),
system: {
...cardData.system,
inVault: true
@ -593,10 +624,7 @@ export default class DhpActor extends Actor {
/**@inheritdoc */
getRollData() {
const rollData = foundry.utils.deepClone(super.getRollData());
/* system gets repeated infinately which causes issues when trying to use the data for document creation */
delete rollData.system;
const rollData = createShallowProxy(super.getRollData());
rollData.id = this.id;
rollData.name = this.name;
rollData.system = this.system.getRollData();
@ -828,7 +856,7 @@ export default class DhpActor extends Actor {
const u = updates[key];
if (key === 'items') {
Object.values(u).forEach(async item => {
await emitAsGM(
await emitGMUpdate(
GMUpdateEvent.UpdateDocument,
item.target.update.bind(item.target),
item.resources,
@ -837,7 +865,7 @@ export default class DhpActor extends Actor {
});
} else {
if (Object.keys(u.resources).length > 0) {
await emitAsGM(
await emitGMUpdate(
GMUpdateEvent.UpdateDocument,
u.target.update.bind(u.target),
u.resources,

View file

@ -1,4 +1,4 @@
import { emitAsGM, GMUpdateEvent } from '../systemRegistration/socket.mjs';
import { emitGMUpdate, emitGMCreate, GMUpdateEvent } from '../systemRegistration/socket.mjs';
export default class DhpChatMessage extends foundry.documents.ChatMessage {
targetHook = null;
@ -183,7 +183,11 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
if (pendingingSaves.length) {
const confirm = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.title') },
content: `<p>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.unfinishedRolls')}</p><p>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.confirmation')}</p><p><i>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.warning')}</i></p>`
content: `
<p>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.unfinishedRolls')}</p>
<p><i>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.warning')}</i></p>
<p>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.confirmation')}</p>
`
});
if (!confirm) return;
}
@ -214,7 +218,7 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
const action = this.system.action;
if (!action || !action?.hasSave) return;
game.system.api.fields.ActionFields.SaveField.rollSave.call(action, token.actor, event).then(result =>
emitAsGM(
emitGMUpdate(
GMUpdateEvent.UpdateSaveMessage,
game.system.api.fields.ActionFields.SaveField.updateSaveMessage.bind(
action,
@ -247,8 +251,24 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
const targets = this.filterPermTargets(this.system.hitTargets),
config = foundry.utils.deepClone(this.system);
config.event = event;
if (targets.length === 0)
ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelectedOrPerm'));
return ui.notifications.info(game.i18n.localize('DAGGERHEART.UI.Notifications.noTargetsSelectedOrPerm'));
else if (config.hasSave) {
const pendingingSaves = targets.filter(t => t.saved.success === null);
if (pendingingSaves.length) {
const confirm = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.title') },
content: `
<p>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.unfinishedRolls')}</p>
<p><i>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.warning')}</i></p>
<p>${game.i18n.localize('DAGGERHEART.APPLICATIONS.PendingReactionsDialog.confirmation')}</p>
`
});
if (!confirm) return;
}
}
this.consumeOnSuccess();
this.system.action?.workflow.get('effects')?.execute(config, targets, true);
}
@ -259,27 +279,47 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
const { shape: type, size: range } = selectedArea;
const shapeData = CONFIG.Canvas.layers.regions.layerClass.getTemplateShape({ type, range });
await canvas.regions.placeRegion(
{
name: selectedArea.name,
shapes: [shapeData],
restriction: { enabled: false, type: 'move', priority: 0 },
behaviors: [
{
name: game.i18n.localize('TYPES.RegionBehavior.applyActiveEffect'),
type: 'applyActiveEffect',
system: {
effects: effects
}
}
],
displayMeasurements: true,
locked: false,
ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE },
visibility: CONST.REGION_VISIBILITY.ALWAYS
},
{ create: true }
);
const scene = game.scenes.get(game.user.viewedScene);
const level = scene.levels.find(x => x.isView);
const regionData = {
name: selectedArea.name,
levels: level ? [level.id] : [],
shapes: [shapeData],
restriction: { enabled: false, type: 'move', priority: 0 },
behaviors:
effects.length > 0
? [
{
name: game.i18n.localize('TYPES.RegionBehavior.applyActiveEffect'),
type: 'applyActiveEffect',
system: {
effects: effects
}
}
]
: [],
displayMeasurements: true,
locked: false,
ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE },
visibility: CONST.REGION_VISIBILITY.ALWAYS
};
const placeRegion = data => {
canvas.regions.placeRegion(data, { create: true });
};
// Regions with effects must be placed by the GM
if (effects.length > 0 && !game.user.isGM) {
if (!game.users.activeGM)
return ui.notifications.error(
game.i18n.localize('DAGGERHEART.UI.Notifications.behaviorRegionRequiresGM')
);
const region = await canvas.regions.placeRegion(regionData, { create: false });
emitGMCreate('Region', placeRegion, region, scene.id);
} else {
placeRegion(regionData);
}
};
if (this.system.action.areas.length === 1) createArea(this.system.action.areas[0]);

View file

@ -54,13 +54,7 @@ export default class DHItem extends foundry.documents.Item {
* @returns
*/
getRollData(options = {}) {
let data;
if (this.system.getRollData) data = this.system.getRollData(options);
else {
const actorRollData = this.actor?.getRollData(options) ?? {};
data = { ...actorRollData, item: { ...this.system } };
}
let data = this.system.getRollData(options);
if (data?.item) {
data.item.flags = { ...this.flags };
data.item.name = this.name;
@ -79,13 +73,16 @@ export default class DHItem extends foundry.documents.Item {
/** Returns true if the item can be used */
get usable() {
const actor = this.actor;
const actionsList = this.system.actionsList;
return this.isOwner && actor?.type === 'character' && (actionsList?.size || actionsList?.length);
const pack = actor?.pack ? game.packs.get(actor.pack) : null;
const hasActions = this.system.actionsList?.size || this.system.actionsList?.length;
const isValidType = actor?.type === 'character' || this.type === 'feature';
return !pack?.locked && this.isOwner && isValidType && hasActions;
}
/** @inheritdoc */
static async createDialog(data = {}, createOptions = {}, options = {}) {
const { folders, types, template, context = {}, ...dialogOptions } = options;
dialogOptions.classes = [options.classes ?? [], 'item-create'].flat(); // handled in hook
if (types?.length === 0) {
throw new Error('The array of sub-types to restrict to must not be empty.');

View file

@ -20,7 +20,7 @@ export default class DhScene extends Scene {
const prototype = tokenDoc.actor?.prototypeToken ?? tokenDoc;
this.#sizeSyncBatch.set(tokenDoc.id, {
size: tokenSize,
prototypeSize: { width: prototype.width, height: prototype.height },
prototypeSize: { width: prototype.width, height: prototype.height, depth: prototype.depth },
position: { x: tokenDoc.x, y: tokenDoc.y, elevation: tokenDoc.elevation }
});
this.#processSyncBatch();
@ -36,11 +36,13 @@ export default class DhScene extends Scene {
const tokenSize = tokenSizes[size];
const width = size !== CONFIG.DH.ACTOR.tokenSize.custom.id ? tokenSize : prototypeSize.width;
const height = size !== CONFIG.DH.ACTOR.tokenSize.custom.id ? tokenSize : prototypeSize.height;
const depth = size !== CONFIG.DH.ACTOR.tokenSize.custom.id ? tokenSize : prototypeSize.depth;
const updatedPosition = DHToken.getSnappedPositionInSquareGrid(this.grid, position, width, height);
return {
_id,
width,
height,
depth,
...updatedPosition
};
});

View file

@ -66,7 +66,8 @@ export default class DHToken extends CONFIG.Token.documentClass {
if (tokenSize && actor.system.size !== CONFIG.DH.ACTOR.tokenSize.custom.id) {
document.updateSource({
width: tokenSize,
height: tokenSize
height: tokenSize,
depth: tokenSize
});
}
}
@ -90,7 +91,7 @@ export default class DHToken extends CONFIG.Token.documentClass {
) {
const tokenSizes = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).tokenSizes;
const tokenSize = tokenSizes[update.system.size];
if (tokenSize !== this.width || tokenSize !== this.height) {
if (tokenSize !== this.width || tokenSize !== this.height || tokenSize !== this.depth) {
this.parent?.syncTokenDimensions(this, update.system.size);
}
}

View file

@ -15,6 +15,7 @@ export default class DhTokenManager {
if (tokenSize && actor.system.size !== CONFIG.DH.ACTOR.tokenSize.custom.id) {
tokenData.width = tokenSize;
tokenData.height = tokenSize;
tokenData.depth = tokenSize;
}
}

View file

@ -3,7 +3,6 @@ import { AdversaryBPPerEncounter, BaseBPPerEncounter } from '../config/encounter
export default class DhTooltipManager extends foundry.helpers.interaction.TooltipManager {
#wide = false;
#bordered = false;
#active = false;
async activate(element, options = {}) {
const { TextEditor } = foundry.applications.ux;
@ -350,7 +349,9 @@ export default class DhTooltipManager extends foundry.helpers.interaction.Toolti
async getBattlepointHTML(combatId) {
const combat = game.combats.get(combatId);
const adversaries =
combat.turns?.filter(x => x.actor?.isNPC)?.map(x => ({ ...x.actor, type: x.actor.system.type })) ?? [];
combat.turns
?.filter(x => x.actor?.isNPC && x.token.disposition === CONST.TOKEN_DISPOSITIONS.HOSTILE)
?.map(x => ({ ...x.actor, type: x.actor.system.type })) ?? [];
const characters = combat.turns?.filter(x => !x.isNPC && x.actor) ?? [];
const nrCharacters = characters.length;

View file

@ -1,7 +1,7 @@
import { parseInlineParams } from './parser.mjs';
export default function DhDamageEnricher(match, _options) {
const { value, type, inline } = parseInlineParams(match[1]);
const { value, type, inline } = parseInlineParams(match[1], { first: 'value' });
if (!value || !type) return match[0];
return getDamageMessage(value, type, inline, match[0]);
}
@ -59,7 +59,7 @@ export const renderDamageButton = async event => {
{
formula: value,
applyTo: CONFIG.DH.GENERAL.healingTypes.hitPoints.id,
type: type
damageTypes: type
}
]
};

View file

@ -8,7 +8,7 @@ export function parseInlineParams(paramString, { first } = {}) {
const parts = paramString.split('|').map(x => x.trim());
const params = {};
for (const [idx, param] of parts.entries()) {
if (first && idx === 0) {
if (first && idx === 0 && !param.includes(':')) {
params[first] = param;
} else {
const parts = param.split(':');

View file

@ -189,17 +189,14 @@ export const getDeleteKeys = (property, innerProperty, innerPropertyDefaultValue
// Fix on Foundry native formula replacement for DH
const nativeReplaceFormulaData = Roll.replaceFormulaData;
Roll.replaceFormulaData = function (formula, baseData = {}, { missing, warn = false } = {}) {
Roll.replaceFormulaData = function (formula, data = {}, { missing, warn = false } = {}) {
/* Inserting global data */
const data = {
...baseData,
partySize: game.actors?.party?.system.partyMembers.length ?? 0
};
const defaultingTypes = [
...Object.keys(CONFIG.DH.GENERAL.multiplierTypes).map(x => ({ term: x, default: 1 })),
{ term: 'partySize', default: game.actors?.party?.system.partyMembers.length ?? 0 }
];
const terms = Object.keys(CONFIG.DH.GENERAL.multiplierTypes).map(type => {
return { term: type, default: 1 };
});
formula = terms.reduce((a, c) => a.replaceAll(`@${c.term}`, data[c.term] ?? c.default), formula);
formula = defaultingTypes.reduce((a, c) => a.replaceAll(`@${c.term}`, data[c.term] ?? c.default), formula);
return nativeReplaceFormulaData(formula, data, { missing, warn });
};
@ -375,10 +372,11 @@ export const itemAbleRollParse = (value, actor, item) => {
const isItemTarget = value.toLowerCase().includes('item.@');
const slicedValue = isItemTarget ? value.replaceAll(/item\.@/gi, '@') : value;
const model = isItemTarget ? item : actor;
const model = isItemTarget || item instanceof Item ? item : actor;
const rollData = isItemTarget || !model?.getRollData ? model : model.getRollData();
try {
return Roll.replaceFormulaData(slicedValue, isItemTarget || !model?.getRollData ? model : model.getRollData());
return Roll.replaceFormulaData(slicedValue, rollData);
} catch (_) {
return '';
}
@ -451,14 +449,9 @@ export async function createEmbeddedItemsWithEffects(actor, baseData) {
effects: data.effects?.map(effect => effect.toObject())
});
}
await actor.createEmbeddedDocuments('Item', effectData);
}
export const slugify = name => {
return name.toLowerCase().replaceAll(' ', '-').replaceAll('.', '');
};
export function shuffleArray(array) {
let currentIndex = array.length;
while (currentIndex != 0) {
@ -759,9 +752,12 @@ export function getArmorSources(actor) {
// Get the origin item. Since the actor is already loaded, it should already be cached
// Consider the relative function versions if this causes an issue
const origin = doc.origin ? foundry.utils.fromUuidSync(doc.origin) : doc;
const useParentName = doc.parent && !(doc.parent instanceof Actor);
const name = doc.origin || !useParentName ? doc.name : doc.parent.name;
return {
origin,
name: origin.name,
name,
document: doc,
data: doc.system.armor ?? doc.system.armorData,
disabled: !!doc.disabled || !!doc.isSuppressed
@ -792,6 +788,26 @@ export function getArmorSources(actor) {
});
}
/**
* Triggers a reset and non-forced re-render on all given actors (if given)
* or all world actors and actors in all scenes to show immediate results for a changed setting.
*/
export function resetAndRerenderActors() {
const actors = new Set(
[game.actors.contents, game.scenes.contents.flatMap(s => s.tokens.contents).flatMap(t => t.actor ?? [])].flat()
);
for (const actor of actors) {
for (const app of Object.values(actor.apps)) {
for (const element of app.element?.querySelectorAll('prose-mirror.active')) {
element.open = false; // This triggers a save
}
}
actor.reset();
actor.render();
}
}
/**
* Returns an array sorted by a function that returns a thing to compare, or an array to compare in order
* Similar to lodash's sortBy function.
@ -812,3 +828,94 @@ export function sortBy(arr, fn) {
};
return arr.sort(cmp);
}
/**
* Creates a proxy that allows retrieval of top data but diverts updates to a different object.
* Generally used for roll data
*/
export function createShallowProxy(obj) {
if (obj._isShallowProxy) return obj;
const overrides = {};
return new Proxy(obj, {
get(target, prop, receiver) {
if (prop === '_isShallowProxy') return true;
if (prop in overrides) return overrides[prop];
return Reflect.get(target, prop, receiver);
},
set(_target, prop, newValue) {
overrides[prop] = newValue;
return true;
},
deleteProperty(_target, prop) {
delete overrides[prop];
return true;
},
has(target, key) {
return key in overrides || key in target;
}
});
}
export function camelize(str) {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, (part, index) => {
return index === 0 ? part.toLowerCase() : part.toUpperCase();
})
.replace(/\s+/g, '');
}
/** Bulk load a list of documents using uuids. Returns the documents in the same order */
export async function fromUuids(uuids) {
// Set up base entries. Each step works on a sublist of these objects
const entries = uuids.map(uuid => ({
uuid,
parsed: foundry.utils.parseUuid(uuid),
value: foundry.utils.fromUuidSync(uuid)
}));
// Handle missing uuids for embedded documents first
// A value may be index data, so we check if its a document
const packEmbeddedEntries = entries.filter(
e =>
!(e.value instanceof Document) &&
e.parsed &&
e.parsed.collection instanceof foundry.documents.collections.CompendiumCollection &&
e.parsed.embedded.length > 0
);
await Promise.all(
packEmbeddedEntries.map(async e => {
e.value = await fromUuid(e.uuid);
return true;
})
);
// Handle missing top level pack stuff, by batching per pack
const missingTopLevel = entries.filter(e => !(e.value instanceof Document) && e.value?.pack);
for (const packGroup of Object.values(Object.groupBy(missingTopLevel, e => e.value.pack))) {
const pack = game.packs.get(packGroup[0].value.pack);
if (!pack) continue;
const ids = packGroup.map(p => p.parsed?.id).filter(id => !!id);
const documents = await pack.getDocuments({ _id__in: ids });
for (const p of packGroup) {
p.value = documents.find(d => d.id === p.parsed.id) ?? p.value;
}
}
return entries.map(e => e.value);
}
/**
* Triggers DiceSoNice rolls or dice roll audio for rolls. Not used for duality rolls.
* @param { Roll[] } rolls
* @return { void }
*/
export async function triggerChatRollFx(rolls, options = { whisper: false, blind: false }) {
const { whisper, blind } = options;
if (game.modules.get('dice-so-nice')?.active) {
const rerollPromises = rolls.map(roll => game.dice3d.showForRoll(roll, game.user, true, whisper, blind));
await Promise.allSettled(rerollPromises);
} else {
foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
}
}

View file

@ -91,7 +91,10 @@ const registerMenuSettings = () => {
game.settings.register(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Metagaming, {
scope: 'world',
config: false,
type: DhMetagaming
type: DhMetagaming,
onChange: value => {
value.handleChange();
}
});
game.settings.register(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, {

Some files were not shown because too many files have changed in this diff Show more