[V14] Message Rolls Rework (#1757)

* Basic rework to the roll data in messages

* .

* Fixed advantage/disadvantage

* .

* .

* Fixed TagTeamDialog

* Reuse getter in faces setter

* Simplify fate roll type css class

* Add more caution to the dualityRoll fromData function

* Apply suggestion from @CarlosFdez

* Compute modifiers using deterministic terms (#1758)

---------

Co-authored-by: Carlos Fernandez <cfern1990@gmail.com>
Co-authored-by: Carlos Fernandez <CarlosFdez@users.noreply.github.com>
This commit is contained in:
WBHarry 2026-03-31 17:20:22 +02:00 committed by GitHub
parent dbd5ef8bb0
commit f156b12d79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 267 additions and 249 deletions

View file

@ -200,6 +200,7 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
partContext.members[partId] = {
...data,
roll: data.roll,
isEditable: actor.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER),
key: partId,
readyToRoll: Boolean(data.rollChoice),
@ -448,24 +449,19 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
const { member, diceType } = button.dataset;
const memberData = this.party.system.tagTeam.members[member];
const dieIndex = diceType === 'hope' ? 0 : diceType === 'fear' ? 2 : 4;
const { parsedRoll, newRoll } = await game.system.api.dice.DualityRoll.reroll(
memberData.rollData,
dieIndex,
diceType,
false
);
const rollData = parsedRoll.toJSON();
const dieIndex = diceType === 'hope' ? 0 : diceType === 'fear' ? 1 : 2;
const newRoll = game.system.api.dice.DualityRoll.fromData(memberData.rollData);
const dice = newRoll.dice[dieIndex];
await dice.reroll(`/r1=${dice.total}`, {
liveRoll: {
roll: newRoll,
isReaction: true
}
});
const rollData = newRoll.toJSON();
this.updatePartyData(
{
[`system.tagTeam.members.${member}.rollData`]: {
...rollData,
options: {
...rollData.options,
roll: newRoll
}
}
[`system.tagTeam.members.${member}.rollData`]: rollData
},
this.getUpdatingParts(button)
);
@ -700,7 +696,9 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
const error = this.checkInitiatorHopeError(this.party.system.tagTeam.initiator);
if (error) return error;
const mainRoll = (await this.getJoinedRoll()).rollData;
const joinedRoll = await this.getJoinedRoll();
const mainRoll = joinedRoll.rollData;
const finalRoll = foundry.utils.deepClone(joinedRoll.roll);
const mainActor = this.party.system.partyMembers.find(x => x.uuid === mainRoll.options.source.actor);
mainRoll.options.title = game.i18n.localize('DAGGERHEART.APPLICATIONS.TagTeamSelect.chatMessageRollTitle');
@ -711,7 +709,7 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
title: game.i18n.localize('DAGGERHEART.APPLICATIONS.TagTeamSelect.title'),
speaker: cls.getSpeaker({ actor: mainActor }),
system: mainRoll.options,
rolls: [mainRoll],
rolls: [JSON.stringify(joinedRoll.roll)],
sound: null,
flags: { core: { RollTable: true } }
};
@ -723,7 +721,7 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
const fearUpdate = { key: 'fear', value: null, total: null, enabled: true };
for (let memberId in tagTeamData.members) {
const resourceUpdates = [];
const rollGivesHope = mainRoll.options.roll.isCritical || mainRoll.options.roll.result.duality === 1;
const rollGivesHope = finalRoll.isCritical || finalRoll.withHope;
if (memberId === tagTeamData.initiator.memberId) {
const value = tagTeamData.initiator.cost
? rollGivesHope
@ -734,9 +732,8 @@ export default class TagTeamDialog extends HandlebarsApplicationMixin(Applicatio
} else if (rollGivesHope) {
resourceUpdates.push({ key: 'hope', value: 1, total: -1, enabled: true });
}
if (mainRoll.options.roll.isCritical)
resourceUpdates.push({ key: 'stress', value: -1, total: 1, enabled: true });
if (mainRoll.options.roll.result.duality === -1) {
if (finalRoll.isCritical) resourceUpdates.push({ key: 'stress', value: -1, total: 1, enabled: true });
if (finalRoll.withFear) {
fearUpdate.value = fearUpdate.value === null ? 1 : fearUpdate.value + 1;
fearUpdate.total = fearUpdate.total === null ? -1 : fearUpdate.total - 1;
}

View file

@ -175,7 +175,7 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
action.use(event);
}
async rerollEvent(event, message) {
async rerollEvent(event, messageData) {
event.stopPropagation();
if (!event.shiftKey) {
const confirmed = await foundry.applications.api.DialogV2.confirm({
@ -187,6 +187,7 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
if (!confirmed) return;
}
const message = game.messages.get(messageData._id);
const target = event.target.closest('[data-die-index]');
if (target.dataset.type === 'damage') {
@ -209,27 +210,16 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
}
});
} else {
let originalRoll_parsed = message.rolls.map(roll => JSON.parse(roll))[0];
const rollClass =
game.system.api.dice[
message.type === 'dualityRoll'
? 'DualityRoll'
: target.dataset.type === 'damage'
? 'DHRoll'
: 'D20Roll'
];
if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
const { newRoll, parsedRoll } = await rollClass.reroll(
originalRoll_parsed,
target.dataset.dieIndex,
target.dataset.type
);
await game.messages.get(message._id).update({
'system.roll': newRoll,
'rolls': [parsedRoll]
const rerollDice = message.system.roll.dice[target.dataset.dieIndex];
await rerollDice.reroll(`/r1=${rerollDice.total}`, {
liveRoll: {
roll: message.system.roll,
actor: message.system.actionActor,
isReaction: message.system.roll.options.actionType === 'reaction'
}
});
await message.update({
rolls: [message.system.roll.toJSON()]
});
}
}

View file

@ -32,7 +32,6 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
return {
title: new fields.StringField(),
actionDescription: new fields.HTMLField(),
roll: new fields.ObjectField(),
targets: targetsField(),
hasRoll: new fields.BooleanField({ initial: false }),
hasDamage: new fields.BooleanField({ initial: false }),
@ -55,6 +54,16 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
};
}
get roll() {
if (this.parent.type === 'dualityRoll')
return this.parent.rolls.find(x => x instanceof game.system.api.dice.DualityRoll);
if (this.parent.type === 'fateRoll')
return this.parent.rolls.find(x => x instanceof game.system.api.dice.FateRoll);
return null;
}
get actionActor() {
if (!this.source.actor) return null;
return fromUuidSync(this.source.actor);

View file

@ -4,3 +4,4 @@ export { default as DamageRoll } from './damageRoll.mjs';
export { default as DHRoll } from './dhRoll.mjs';
export { default as DualityRoll } from './dualityRoll.mjs';
export { default as FateRoll } from './fateRoll.mjs';
export { diceTypes } from './die/_module.mjs';

View file

@ -217,49 +217,11 @@ export default class D20Roll extends DHRoll {
results: d.results
};
});
data.modifierTotal = this.calculateTotalModifiers(roll);
data.modifierTotal = roll.modifierTotal;
return data;
}
resetFormula() {
return (this._formula = this.constructor.getFormula(this.terms));
}
static async reroll(rollString, _target, message) {
let parsedRoll = game.system.api.dice.D20Roll.fromData(rollString);
parsedRoll = await parsedRoll.reroll();
const newRoll = game.system.api.dice.D20Roll.postEvaluate(parsedRoll, {
targets: message.system.targets,
roll: {
advantage: message.system.roll.advantage?.type,
difficulty: message.system.roll.difficulty ? Number(message.system.roll.difficulty) : null
}
});
if (game.modules.get('dice-so-nice')?.active) {
await game.dice3d.showForRoll(parsedRoll, game.user, true);
}
const rerolled = {
any: true,
rerolls: [
...(message.system.roll.dice[0].rerolled?.rerolls?.length > 0
? [message.system.roll.dice[0].rerolled?.rerolls]
: []),
rollString.terms[0].results
]
};
return {
newRoll: {
...newRoll,
dice: [
{
...newRoll.dice[0],
rerolled: rerolled
}
]
},
parsedRoll
};
}
}

View file

@ -12,6 +12,10 @@ export default class DHRoll extends Roll {
return game.i18n.localize('DAGGERHEART.GENERAL.Roll.basic');
}
get modifierTotal() {
return this.constructor.calculateTotalModifiers(this);
}
static messageType = 'adversaryRoll';
static CHAT_TEMPLATE = 'systems/daggerheart/templates/ui/chat/roll.hbs';
@ -138,6 +142,7 @@ export default class DHRoll extends Roll {
const chatData = await this._prepareChatRenderContext({ flavor, isPrivate, ...options });
return foundry.applications.handlebars.renderTemplate(template, {
...chatData,
roll: this,
parent: chatData.parent,
targetMode: chatData.targetMode,
metagamingSettings
@ -241,16 +246,21 @@ export default class DHRoll extends Roll {
return (this._formula = this.constructor.getFormula(this.terms));
}
/**
* Calculate total modifiers of any rolls, including non-dh rolls.
* This exists because damage rolls still may receive base roll classes
*/
static calculateTotalModifiers(roll) {
let modifierTotal = 0;
for (let i = 0; i < roll.terms.length; i++) {
if (
roll.terms[i] instanceof foundry.dice.terms.NumericTerm &&
!!roll.terms[i - 1] &&
roll.terms[i - 1] instanceof foundry.dice.terms.OperatorTerm
)
modifierTotal += Number(`${roll.terms[i - 1].operator}${roll.terms[i].total}`);
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;
modifierTotal += multiplier * termTotal;
}
}
return modifierTotal;
}

View file

@ -0,0 +1,9 @@
import DualityDie from './dualityDie.mjs';
import AdvantageDie from './advantageDie.mjs';
import DisadvantageDie from './disadvantageDie.mjs';
export const diceTypes = {
DualityDie,
AdvantageDie,
DisadvantageDie
};

View file

@ -0,0 +1,7 @@
export default class AdvantageDie extends foundry.dice.terms.Die {
constructor(options) {
super(options);
this.modifiers = [];
}
}

View file

@ -0,0 +1,7 @@
export default class DisadvantageDie extends foundry.dice.terms.Die {
constructor(options) {
super(options);
this.modifiers = [];
}
}

View file

@ -0,0 +1,62 @@
import { ResourceUpdateMap } from '../../data/action/baseAction.mjs';
export default class DualityDie extends foundry.dice.terms.Die {
constructor(options) {
super(options);
this.modifiers = [];
}
#getDualityState(roll) {
if (!roll) return null;
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);
if (options?.liveRoll) {
/* Can't currently test since DiceSoNice is not v14. Might need to set the appearance earlier if a roll is triggered by super.reroll */
if (game.modules.get('dice-so-nice')?.active) {
const diceSoNiceRoll = {
_evaluated: true,
dice: [this],
options: { appearance: {} }
};
const preset = await getDiceSoNicePreset(diceSoNice[key], faces);
diceSoNiceRoll.dice[0].options.appearance = preset.appearance;
diceSoNiceRoll.dice[0].options.modelFile = preset.modelFile;
await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true);
} else {
foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
}
await options.liveRoll.roll._evaluate();
if (options.liveRoll.isReaction) return;
const newDuality = this.#getDualityState(options.liveRoll.roll);
this.#updateResources(oldDuality, newDuality, options.liveRoll.actor);
}
}
}

View file

@ -1,8 +1,6 @@
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 { ResourceUpdateMap } from '../data/action/baseAction.mjs';
export default class DualityRoll extends D20Roll {
_advantageFaces = 6;
@ -26,27 +24,31 @@ export default class DualityRoll extends D20Roll {
}
get dHope() {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
if (!(this.dice[0] instanceof game.system.api.dice.diceTypes.DualityDie)) this.createBaseDice();
return this.dice[0];
}
set dHope(faces) {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[0].faces = this.getFaces(faces);
// TODO this should not be asymmetrical with the getter. updateRollConfiguration() should use dHope.faces
this.dHope.faces = this.getFaces(faces);
}
get dFear() {
if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice();
if (!(this.dice[1] instanceof game.system.api.dice.diceTypes.DualityDie)) this.createBaseDice();
return this.dice[1];
}
set dFear(faces) {
if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[1].faces = this.getFaces(faces);
// TODO this should not be asymmetrical with the getter. updateRollConfiguration() should use dFear.faces
this.dFear.faces = this.getFaces(faces);
}
get dAdvantage() {
return this.dice[2];
return this.dice[2] instanceof game.system.api.dice.diceTypes.AdvantageDie ? this.dice[2] : null;
}
get dDisadvantage() {
return this.dice[2] instanceof game.system.api.dice.diceTypes.DisadvantageDie ? this.dice[2] : null;
}
get advantageFaces() {
@ -65,6 +67,11 @@ export default class DualityRoll extends D20Roll {
this._advantageNumber = Number(value);
}
get extraDice() {
const { DualityDie, AdvantageDie, DisadvantageDie } = game.system.api.dice.diceTypes;
return this.dice.filter(x => ![DualityDie, AdvantageDie, DisadvantageDie].some(die => x instanceof die));
}
setRallyChoices() {
return this.data?.parent?.appliedEffects.reduce((a, c) => {
const change = c.system.changes.find(ch => ch.key === 'system.bonuses.rally');
@ -118,22 +125,28 @@ export default class DualityRoll extends D20Roll {
/** @inheritDoc */
static fromData(data) {
data.terms[0].class = foundry.dice.terms.Die.name;
data.terms[2].class = foundry.dice.terms.Die.name;
data.terms[0].class = 'DualityDie';
data.terms[2].class = 'DualityDie';
if (data.options.roll.advantage?.type && data.terms[4]?.faces) {
data.terms[4].class = data.options.roll.advantage.type === 1 ? 'AdvantageDie' : 'DisadvantageDie';
}
return super.fromData(data);
}
createBaseDice() {
if (this.dice[0] instanceof foundry.dice.terms.Die && this.dice[1] instanceof foundry.dice.terms.Die) {
if (
this.dice[0] instanceof game.system.api.dice.diceTypes.DualityDie &&
this.dice[1] instanceof game.system.api.dice.diceTypes.DualityDie
) {
this.terms = [this.terms[0], this.terms[1], this.terms[2]];
return;
}
this.terms[0] = new foundry.dice.terms.Die({
this.terms[0] = new game.system.api.dice.diceTypes.DualityDie({
faces: this.data.rules.dualityRoll?.defaultHopeDice ?? 12
});
this.terms[1] = new foundry.dice.terms.OperatorTerm({ operator: '+' });
this.terms[2] = new foundry.dice.terms.Die({
this.terms[2] = new game.system.api.dice.diceTypes.DualityDie({
faces: this.data.rules.dualityRoll?.defaultFearDice ?? 12
});
}
@ -371,63 +384,4 @@ export default class DualityRoll extends D20Roll {
if (currentCombatant?.actorId == config.data.id) ui.combat.setCombatantSpotlight(currentCombatant.id);
}
}
static async reroll(rollBase, dieIndex, diceType, updateResources = true) {
let parsedRoll = game.system.api.dice.DualityRoll.fromData({ ...rollBase, evaluated: false });
const term = parsedRoll.terms[dieIndex];
await term.reroll(`/r1=${term.total}`);
const result = await parsedRoll.evaluate();
if (game.modules.get('dice-so-nice')?.active) {
const diceSoNiceRoll = {
_evaluated: true,
dice: [
new foundry.dice.terms.Die({
...term,
faces: term._faces,
results: term.results.filter(x => !x.rerolled)
})
],
options: { appearance: {} }
};
const diceSoNicePresets = await getDiceSoNicePresets(`d${term._faces}`, `d${term._faces}`);
if (diceSoNicePresets[diceType]) {
diceSoNiceRoll.dice[0].options = diceSoNicePresets[diceType];
}
await game.dice3d.showForRoll(diceSoNiceRoll, game.user, true);
} else {
foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
}
const newRoll = game.system.api.dice.DualityRoll.postEvaluate(parsedRoll, {
targets: parsedRoll.options.targets ?? [],
roll: {
advantage: parsedRoll.options.roll.advantage?.type,
difficulty: parsedRoll.options.roll.difficulty ? Number(parsedRoll.options.roll.difficulty) : null
}
});
const extraIndex = newRoll.advantage ? 3 : 2;
newRoll.extra = newRoll.extra.slice(extraIndex);
const actor = parsedRoll.options.source.actor
? await foundry.utils.fromUuid(parsedRoll.options.source.actor)
: null;
const config = {
source: { actor: parsedRoll.options.source.actor ?? '' },
targets: parsedRoll.targets,
roll: newRoll,
rerolledRoll: parsedRoll.options.roll,
resourceUpdates: new ResourceUpdateMap(actor)
};
if (updateResources) {
await DualityRoll.addDualityResourceUpdates(config);
await config.resourceUpdates.updateResources();
}
return { newRoll, parsedRoll };
}
}

View file

@ -21,8 +21,8 @@ export default class FateRoll extends D20Roll {
}
set dHope(faces) {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[0].faces = this.getFaces(faces);
// TODO this should not be asymmetrical with the getter. updateRollConfiguration() should use dHope.faces
this.dHope.faces = this.getFaces(faces);
}
get dFear() {
@ -31,8 +31,8 @@ export default class FateRoll extends D20Roll {
}
set dFear(faces) {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[0].faces = this.getFaces(faces);
// TODO this should not be asymmetrical with the getter. updateRollConfiguration() should use dFear.faces
this.dFear.faces = this.getFaces(faces);
}
get isCritical() {
@ -43,6 +43,22 @@ export default class FateRoll extends D20Roll {
return this.data.fateType;
}
get withHope() {
if (!this._evaluatedl) return;
return this.dHope.total >= this.dFear.total;
}
get withFear() {
if (!this._evaluated) return;
return this.dHope.total < this.dFear.total;
}
get totalLabel() {
const label = this.withHope ? 'DAGGERHEART.GENERAL.hope' : 'DAGGERHEART.GENERAL.fear';
return game.i18n.localize(label);
}
static getHooks(hooks) {
return [...(hooks ?? []), 'Fate'];
}

View file

@ -1,4 +1,4 @@
import { emitAsGM, GMUpdateEvent, RefreshType, socketEvent } from '../systemRegistration/socket.mjs';
import { emitAsGM, GMUpdateEvent } from '../systemRegistration/socket.mjs';
export default class DhpChatMessage extends foundry.documents.ChatMessage {
targetHook = null;
@ -78,25 +78,14 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
if (this.isContentVisible) {
if (this.type === 'dualityRoll') {
html.classList.add('duality');
switch (this.system.roll?.result?.duality) {
case 1:
html.classList.add('hope');
break;
case -1:
html.classList.add('fear');
break;
default:
html.classList.add('critical');
break;
}
if (this.system.roll.withHope) html.classList.add('hope');
else if (this.system.roll.withFear) html.classList.add('fear');
else html.classList.add('critical');
}
if (this.type === 'fateRoll') {
html.classList.add('fate');
if (this.system.roll?.fate.fateDie == 'Hope') {
html.classList.add('hope');
}
if (this.system.roll?.fate.fateDie == 'Fear') {
html.classList.add('fear');
if (this.system.roll?.fateDie) {
html.classList.add(this.system.roll.fateDie.toLowerCase());
}
}