[Feature] Death moves and Fate rolls (#1463)

* Update the death move descriptions

* Renamed to DhDeathMove

* Partial Fate Roll creation and Fate Roll Enricher (/fr)

* Hide stuff not required for fate roll

* Hide formula display; code removal; start to add Fear die as a choice for Fate roll

* Fix chat message display; start moving towards supporting Hope and Fear for Fate roll

* /fr now supports type=X, where X is Hope or Fear, if not supplied, defaults to Hope

* Fixed DSN rolling; removed console messages; chat message clean up

* Add localisation entry

* Trying to sort out the button for the fate roll

* Style the fate message based on Hope/Fear colors.

* Partial improvement on the fate template buttons - chat display is correct, but the roll dialog is wrong

* Fixed enricher button; localization fixes; debug cleanup

* Error checking for the fate type parsing in all potential problem locations

* Added localization for the fate type parsing error

* Start on Avoid Death death move

* debug stuff

* More death moves setup/testing

* Avoid fate scars update in place, with scars migrating to an integer value.

* Remove some debug code; add Blaze Of Glory shell

* Start on Guaranteed Critical for Blaze of Glory

* Partial implementation of Blaze of Glory

* Dice/critical checks/tests

* Moved detection of guaranteed critical to before the roll dialog is created, so it can be skipped; removed debug code

* Remove debug

* Update Blaze of Glory effect description

* Risk It All - critical roll - clear all stress and HP

* Auto remove all marked stress and HP for Risk It All, if Hope value rolled covers it.

* Display the Death Move description in chat expanded if the appropriate config setting is on

* Made the Blaze of Glory ActiveEffect image use configured version

* Update the current Hope value if the scar value change affects it

* Scars management in the Character details editor

* Separate less file for the Death Moves instead of reusing Downtime

* Added result messages to the Death Move chat output and removed debug statements

* Some localization, style and smaller changes

* Fixed RiskItAll resource handling method

* Risk It All success chat message start

* [Add] Hope/Scar Interplay (#1531)

* Migrated character.maxHope to homebrew settings

* Added a visual for scars

* .

* .

* Pass the hope value in the button data; skeleton risk it all dialog to fill out.

* Start on risk it dialog

* More dialog stuff

* Remove non-existent field

* Dialog templating and logic

* .

* Ensure effect is Applied to Actor (#1547)

Co-authored-by: Chris Ryan <chrisr@blackhole>

* [Fix] 1548 - Standalone Item Add Actions (#1549)

* Fixed so that items not on an actor don't error out on creating actions

* Fixed deletion of items error

* Raised version

* Fix the sliders to do the correct maximums

* Pass the actor id through the button; fix /dr and /fr flavor text

* Remove debug message

---------

Co-authored-by: Chris Ryan <chrisr@blackhole>
Co-authored-by: WBHarry <williambjrklund@gmail.com>
Co-authored-by: WBHarry <89362246+WBHarry@users.noreply.github.com>
This commit is contained in:
Chris Ryan 2026-01-18 00:11:50 +10:00 committed by GitHub
parent 3103a40c26
commit 9d75157e17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1166 additions and 258 deletions

View file

@ -14,3 +14,4 @@ export { default as ResourceDiceDialog } from './resourceDiceDialog.mjs';
export { default as ActionSelectionDialog } from './actionSelectionDialog.mjs';
export { default as GroupRollDialog } from './group-roll-dialog.mjs';
export { default as TagTeamDialog } from './tagTeamDialog.mjs';
export { default as RiskItAllDialog } from './riskItAllDialog.mjs';

View file

@ -123,7 +123,7 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio
context.formula = this.roll.constructFormula(this.config);
if (this.actor?.system?.traits) context.abilities = this.getTraitModifiers();
context.showReaction = !this.config.roll?.type || context.rollType === 'DualityRoll';
context.showReaction = !this.config.skips?.reaction && context.rollType === 'DualityRoll';
context.reactionOverride = this.reactionOverride;
}

View file

@ -1,11 +1,16 @@
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
import { enrichedFateRoll } from '../../enrichers/FateRollEnricher.mjs';
import { enrichedDualityRoll } from '../../enrichers/DualityRollEnricher.mjs';
export default class DhpDeathMove extends HandlebarsApplicationMixin(ApplicationV2) {
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(actor) {
super({});
this.actor = actor;
this.selectedMove = null;
this.showRiskItAllButton = false;
this.riskItAllButtonLabel = '';
this.riskItAllHope = 0;
}
get title() {
@ -38,6 +43,107 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application
return context;
}
async handleAvoidDeath() {
const target = this.actor.uuid;
const config = await enrichedFateRoll({
target,
title: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.avoidDeath.name'),
label: `${game.i18n.localize('DAGGERHEART.GENERAL.hope')} ${game.i18n.localize('DAGGERHEART.GENERAL.fateRoll')}`,
fateType: 'Hope'
});
if (!config.roll.fate) return;
if (config.roll.fate.value <= this.actor.system.levelData.level.current) {
// apply scarring - for now directly apply - later add a button.
const newScarAmount = this.actor.system.scars + 1;
await this.actor.update({
system: {
scars: newScarAmount
}
});
if (newScarAmount >= this.actor.system.resources.hope.max) {
return game.i18n.format('DAGGERHEART.UI.Chat.deathMove.journeysEnd', { scars: newScarAmount });
}
return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.gainScar');
}
return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar');
}
async handleRiskItAll() {
const config = await enrichedDualityRoll({
reaction: true,
traitValue: null,
target: this.actor,
difficulty: null,
title: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.riskItAll.name'),
label: game.i18n.localize('DAGGERHEART.GENERAL.dualityDice'),
actionType: null,
advantage: null,
customConfig: { skips: { resources: true, reaction: true } }
});
if (!config.roll.result) return;
const clearAllStressAndHitpointsUpdates = [
{ key: 'hitPoints', clear: true },
{ key: 'stress', clear: true }
];
let chatMessage = '';
if (config.roll.isCritical) {
config.resourceUpdates.addResources(clearAllStressAndHitpointsUpdates);
chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllCritical');
}
if (config.roll.result.duality == 1) {
if (
config.roll.hope.value >=
this.actor.system.resources.hitPoints.value + this.actor.system.resources.stress.value
) {
config.resourceUpdates.addResources(clearAllStressAndHitpointsUpdates);
chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllSuccessWithEnoughHope');
} else {
chatMessage = game.i18n.format('DAGGERHEART.UI.Chat.deathMove.riskItAllSuccess', {
hope: config.roll.hope.value
});
this.showRiskItAllButton = true;
this.riskItAllHope = config.roll.hope.value;
this.riskItAllButtonLabel = game.i18n.format('DAGGERHEART.UI.Chat.deathMove.riskItAllDialogButton');
}
}
if (config.roll.result.duality == -1) {
chatMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.riskItAllFailure');
}
await config.resourceUpdates.updateResources();
return chatMessage;
}
async handleBlazeOfGlory() {
this.actor.createEmbeddedDocuments('ActiveEffect', [
{
name: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.name'),
description: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.description'),
img: CONFIG.DH.GENERAL.deathMoves.blazeOfGlory.img,
changes: [
{
key: 'system.rules.roll.guaranteedCritical',
mode: 2,
value: 'true'
}
]
}
]);
return game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.blazeOfGlory');
}
static selectMove(_, button) {
const move = button.dataset.move;
this.selectedMove = CONFIG.DH.GENERAL.deathMoves[move];
@ -46,23 +152,49 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application
}
static async takeMove() {
this.close();
let result = '';
if (CONFIG.DH.GENERAL.deathMoves.blazeOfGlory === this.selectedMove) {
result = await this.handleBlazeOfGlory();
}
if (CONFIG.DH.GENERAL.deathMoves.avoidDeath === this.selectedMove) {
result = await this.handleAvoidDeath();
}
if (CONFIG.DH.GENERAL.deathMoves.riskItAll === this.selectedMove) {
result = await this.handleRiskItAll();
}
if (!result) return;
const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance)
.expandRollMessage?.desc;
const cls = getDocumentClass('ChatMessage');
const msg = {
user: game.user.id,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/ui/chat/deathMove.hbs',
{
player: this.actor.name,
actor: { name: this.actor.name, img: this.actor.img },
actor: this.actor,
actorId: this.actor._id,
author: game.users.get(game.user.id),
title: game.i18n.localize(this.selectedMove.name),
img: this.selectedMove.img,
description: game.i18n.localize(this.selectedMove.description)
description: game.i18n.localize(this.selectedMove.description),
result: result,
open: autoExpandDescription ? 'open' : '',
chevron: autoExpandDescription ? 'fa-chevron-up' : 'fa-chevron-down',
showRiskItAllButton: this.showRiskItAllButton,
riskItAllButtonLabel: this.riskItAllButtonLabel,
riskItAllHope: this.riskItAllHope
}
),
title: game.i18n.localize(
'DAGGERHEART.UI.Chat.deathMove.title'
),
title: game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.title'),
speaker: cls.getSpeaker(),
flags: {
daggerheart: {
@ -72,7 +204,5 @@ export default class DhpDeathMove extends HandlebarsApplicationMixin(Application
};
cls.create(msg);
this.close();
}
}

View file

@ -0,0 +1,94 @@
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export default class RiskItAllDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(actor, resourceValue) {
super({});
this.actor = actor;
this.resourceValue = resourceValue;
this.choices = {
hitPoints: 0,
stress: 0
};
}
get title() {
return game.i18n.format('DAGGERHEART.APPLICATIONS.RiskItAllDialog.title', { name: this.actor.name });
}
static DEFAULT_OPTIONS = {
classes: ['daggerheart', 'dh-style', 'dialog', 'views', 'risk-it-all'],
position: { width: 280, height: 'auto' },
window: { icon: 'fa-solid fa-dice fa-xl' },
actions: {
finish: RiskItAllDialog.#finish
}
};
static PARTS = {
application: {
id: 'risk-it-all',
template: 'systems/daggerheart/templates/dialogs/riskItAllDialog.hbs'
}
};
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
for (const input of htmlElement.querySelectorAll('.resource-container input'))
input.addEventListener('change', this.updateChoice.bind(this));
}
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
context.resourceValue = this.resourceValue;
context.maxHitPointsValue = Math.min(this.resourceValue, this.actor.system.resources.hitPoints.max);
context.maxStressValue = Math.min(this.resourceValue, this.actor.system.resources.stress.max);
context.remainingResource = this.resourceValue - this.choices.hitPoints - this.choices.stress;
context.unfinished = context.remainingResource !== 0;
context.choices = this.choices;
context.final = {
hitPoints: {
value: this.actor.system.resources.hitPoints.value - this.choices.hitPoints,
max: this.actor.system.resources.hitPoints.max
},
stress: {
value: this.actor.system.resources.stress.value - this.choices.stress,
max: this.actor.system.resources.stress.max
}
};
context;
return context;
}
updateChoice(event) {
let value = Number.parseInt(event.target.value);
const choiceKey = event.target.dataset.choice;
const actorValue = this.actor.system.resources[choiceKey].value;
const remaining = this.resourceValue - this.choices.hitPoints - this.choices.stress;
const changeAmount = value - this.choices[choiceKey];
/* If trying to increase beyond remaining resource points, just increase to max available */
if (remaining - changeAmount < 0) value = this.choices[choiceKey] + remaining;
else if (actorValue - value < 0) value = actorValue;
this.choices[choiceKey] = value;
this.render();
}
static async #finish() {
const resourceUpdate = Object.keys(this.choices).reduce((acc, resourceKey) => {
const value = this.actor.system.resources[resourceKey].value - this.choices[resourceKey];
acc[resourceKey] = { value };
return acc;
}, {});
await this.actor.update({
'system.resources': resourceUpdate
});
this.close();
}
}

View file

@ -1,5 +1,5 @@
import DHBaseActorSheet from '../api/base-actor.mjs';
import DhpDeathMove from '../../dialogs/deathMove.mjs';
import DhDeathMove from '../../dialogs/deathMove.mjs';
import { abilities } from '../../../config/actorConfig.mjs';
import { CharacterLevelup, LevelupViewMode } from '../../levelup/_module.mjs';
import DhCharacterCreation from '../../characterCreation/characterCreation.mjs';
@ -696,7 +696,7 @@ export default class CharacterSheet extends DHBaseActorSheet {
* @type {ApplicationClickAction}
*/
static async #makeDeathMove() {
await new DhpDeathMove(this.document).render({ force: true });
await new DhDeathMove(this.document).render({ force: true });
}
/**
@ -753,9 +753,8 @@ export default class CharacterSheet extends DHBaseActorSheet {
if (!result) return;
/* This could be avoided by baking config.costs into config.resourceUpdates. Didn't feel like messing with it at the time */
const costResources = result.costs
.filter(x => x.enabled)
.map(cost => ({ ...cost, value: -cost.value, total: -cost.total }));
const costResources = result.costs?.filter(x => x.enabled)
.map(cost => ({ ...cost, value: -cost.value, total: -cost.total })) || {};
config.resourceUpdates.addResources(costResources);
await config.resourceUpdates.updateResources();
}

View file

@ -81,6 +81,9 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
html.querySelectorAll('.group-roll-header-expand-section').forEach(element =>
element.addEventListener('click', this.groupRollExpandSection)
);
html.querySelectorAll('.risk-it-all-button').forEach(element =>
element.addEventListener('click', event => this.riskItAllClearStressAndHitPoints(event, data))
);
};
setupHooks() {
@ -94,15 +97,17 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
/** Ensure the chat theme inherits the interface theme */
_replaceHTML(result, content, options) {
const themedElement = result.log?.querySelector(".chat-log");
themedElement?.classList.remove("themed", "theme-light", "theme-dark");
const themedElement = result.log?.querySelector('.chat-log');
themedElement?.classList.remove('themed', 'theme-light', 'theme-dark');
super._replaceHTML(result, content, options);
}
/** Remove chat log theme from notifications area */
async _onFirstRender(result, content) {
await super._onFirstRender(result, content);
document.querySelector("#chat-notifications .chat-log")?.classList.remove("themed", "theme-light", "theme-dark")
document
.querySelector('#chat-notifications .chat-log')
?.classList.remove('themed', 'theme-light', 'theme-dark');
}
async onRollSimple(event, message) {
@ -383,4 +388,10 @@ export default class DhpChatLog extends foundry.applications.sidebar.tabs.ChatLo
});
event.target.closest('.group-roll-section').querySelector('.group-roll-content').classList.toggle('closed');
}
async riskItAllClearStressAndHitPoints(event, data) {
const resourceValue = event.target.dataset.resourceValue;
const actor = game.actors.get(event.target.dataset.actorId);
new game.system.api.applications.dialogs.RiskItAllDialog(actor, resourceValue).render({ force: true });
}
}

View file

@ -376,14 +376,14 @@ export class ResourceUpdateMap extends Map {
if (!resource.key) continue;
const existing = this.get(resource.key);
if (existing) {
if (!existing || resource.clear) {
this.set(resource.key, resource);
} else if (!existing?.clear) {
this.set(resource.key, {
...existing,
value: existing.value + (resource.value ?? 0),
total: existing.total + (resource.total ?? 0)
});
} else {
this.set(resource.key, resource);
}
}
}

View file

@ -35,7 +35,14 @@ export default class DhCharacter extends BaseDataActor {
'DAGGERHEART.ACTORS.Character.maxHPBonus'
),
stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true),
hope: resourceField(6, 2, 'DAGGERHEART.GENERAL.hope')
hope: new fields.SchemaField({
value: new fields.NumberField({
initial: 2,
min: 0,
integer: true,
label: 'DAGGERHEART.GENERAL.hope'
})
})
}),
traits: new fields.SchemaField({
agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'),
@ -78,12 +85,7 @@ export default class DhCharacter extends BaseDataActor {
bags: new fields.NumberField({ initial: 0, integer: true }),
chests: new fields.NumberField({ initial: 0, integer: true })
}),
scars: new fields.TypedObjectField(
new fields.SchemaField({
name: new fields.StringField({}),
description: new fields.StringField()
})
),
scars: new fields.NumberField({ initial: 0, integer: true, label: 'DAGGERHEART.GENERAL.scars' }),
biography: new fields.SchemaField({
background: new fields.HTMLField(),
connections: new fields.HTMLField(),
@ -301,6 +303,9 @@ export default class DhCharacter extends BaseDataActor {
runeWard: new fields.BooleanField({ initial: false }),
burden: new fields.SchemaField({
ignore: new fields.BooleanField()
}),
roll: new fields.SchemaField({
guaranteedCritical: new fields.BooleanField()
})
})
};
@ -642,7 +647,9 @@ export default class DhCharacter extends BaseDataActor {
? armor.system.baseThresholds.severe + this.levelData.level.current
: this.levelData.level.current * 2
};
this.resources.hope.max -= Object.keys(this.scars).length;
const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope;
this.resources.hope.max = globalHopeMax - this.scars;
this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0;
}
@ -699,6 +706,20 @@ export default class DhCharacter extends BaseDataActor {
changes.system.experiences[experience].core = true;
}
}
/* Scars can alter the amount of current hope */
if (changes.system?.scars) {
const diff = this.system.scars - changes.system.scars;
const newHopeMax = this.system.resources.hope.max + diff;
const newHopeValue = Math.min(newHopeMax, this.system.resources.hope.value);
if (newHopeValue != this.system.resources.hope.value) {
if (!changes.system.resources) changes.system.resources = { hope: { value: 0 } };
changes.system.resources.hope = {
...changes.system.resources.hope,
value: changes.system.resources.hope.value + newHopeValue
};
}
}
}
async _preDelete() {
@ -714,4 +735,11 @@ export default class DhCharacter extends BaseDataActor {
t => !!t
);
}
static migrateData(source) {
if (typeof source.scars === 'object') source.scars = 0;
if (source.resources?.hope?.max) source.scars = Math.max(6 - source.resources.hope.max, 0);
return super.migrateData(source);
}
}

View file

@ -8,6 +8,7 @@ export const config = {
adversaryRoll: DHActorRoll,
damageRoll: DHActorRoll,
dualityRoll: DHActorRoll,
fateRoll: DHActorRoll,
groupRoll: DHGroupRoll,
systemMessage: DHSystemMessage
};

View file

@ -23,6 +23,13 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
initial: 12,
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxFear.label'
}),
maxHope: new fields.NumberField({
required: true,
integer: true,
min: 0,
initial: 6,
label: 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label'
}),
maxLoadout: new fields.NumberField({
required: true,
integer: true,

View file

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

View file

@ -12,6 +12,7 @@ export default class DualityRoll extends D20Roll {
constructor(formula, data = {}, options = {}) {
super(formula, data, options);
this.rallyChoices = this.setRallyChoices();
this.guaranteedCritical = options.guaranteedCritical;
}
static messageType = 'dualityRoll';
@ -25,29 +26,23 @@ export default class DualityRoll extends D20Roll {
}
get dHope() {
// if ( !(this.terms[0] instanceof foundry.dice.terms.Die) ) return;
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
return this.dice[0];
// return this.#hopeDice;
}
set dHope(faces) {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.terms[0].faces = this.getFaces(faces);
// this.#hopeDice = `d${face}`;
this.dice[0].faces = this.getFaces(faces);
}
get dFear() {
// if ( !(this.terms[1] instanceof foundry.dice.terms.Die) ) return;
if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice();
return this.dice[1];
// return this.#fearDice;
}
set dFear(faces) {
if (!(this.dice[1] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[1].faces = this.getFaces(faces);
// this.#fearDice = `d${face}`;
}
get dAdvantage() {
@ -90,26 +85,29 @@ export default class DualityRoll extends D20Roll {
}
get isCritical() {
if (this.guaranteedCritical) return true;
if (!this.dHope._evaluated || !this.dFear._evaluated) return;
return this.dHope.total === this.dFear.total;
}
get withHope() {
if (!this._evaluated) return;
if (!this._evaluated || this.guaranteedCritical) return;
return this.dHope.total > this.dFear.total;
}
get withFear() {
if (!this._evaluated) return;
if (!this._evaluated || this.guaranteedCritical) return;
return this.dHope.total < this.dFear.total;
}
get totalLabel() {
const label = this.withHope
? 'DAGGERHEART.GENERAL.hope'
: this.withFear
? 'DAGGERHEART.GENERAL.fear'
: 'DAGGERHEART.GENERAL.criticalSuccess';
const label = this.guaranteedCritical
? 'DAGGERHEART.GENERAL.guaranteedCriticalSuccess'
: this.isCritical
? 'DAGGERHEART.GENERAL.criticalSuccess'
: this.withHope
? 'DAGGERHEART.GENERAL.hope'
: 'DAGGERHEART.GENERAL.fear';
return game.i18n.localize(label);
}
@ -178,6 +176,21 @@ export default class DualityRoll extends D20Roll {
return modifiers;
}
static async buildConfigure(config = {}, message = {}) {
config.dialog ??= {};
config.guaranteedCritical = config.data?.parent?.appliedEffects.reduce((a, c) => {
const change = c.changes.find(ch => ch.key === 'system.rules.roll.guaranteedCritical');
if (change) a = true;
return a;
}, false);
if (config.guaranteedCritical) {
config.dialog.configure = false;
}
return super.buildConfigure(config, message);
}
getActionChangeKeys() {
const changeKeys = new Set([`system.bonuses.roll.${this.options.actionType}`]);
@ -223,7 +236,7 @@ export default class DualityRoll extends D20Roll {
data.hope = {
dice: roll.dHope.denomination,
value: roll.dHope.total,
value: this.guaranteedCritical ? 0 : roll.dHope.total,
rerolled: {
any: roll.dHope.results.some(x => x.rerolled),
rerolls: roll.dHope.results.filter(x => x.rerolled)
@ -231,7 +244,7 @@ export default class DualityRoll extends D20Roll {
};
data.fear = {
dice: roll.dFear.denomination,
value: roll.dFear.total,
value: this.guaranteedCritical ? 0 : roll.dFear.total,
rerolled: {
any: roll.dFear.results.some(x => x.rerolled),
rerolls: roll.dFear.results.filter(x => x.rerolled)
@ -243,7 +256,7 @@ export default class DualityRoll extends D20Roll {
};
data.result = {
duality: roll.withHope ? 1 : roll.withFear ? -1 : 0,
total: roll.dHope.total + roll.dFear.total,
total: this.guaranteedCritical ? 0 : roll.dHope.total + roll.dFear.total,
label: roll.totalLabel
};

85
module/dice/fateRoll.mjs Normal file
View file

@ -0,0 +1,85 @@
import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs';
import D20Roll from './d20Roll.mjs';
import { setDiceSoNiceForHopeFateRoll, setDiceSoNiceForFearFateRoll } from '../helpers/utils.mjs';
export default class FateRoll extends D20Roll {
constructor(formula, data = {}, options = {}) {
super(formula, data, options);
}
static messageType = 'fateRoll';
static DefaultDialog = D20RollDialog;
get title() {
return game.i18n.localize(`DAGGERHEART.GENERAL.fateRoll`);
}
get dHope() {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
return this.dice[0];
}
set dHope(faces) {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[0].faces = this.getFaces(faces);
}
get dFear() {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
return this.dice[0];
}
set dFear(faces) {
if (!(this.dice[0] instanceof foundry.dice.terms.Die)) this.createBaseDice();
this.dice[0].faces = this.getFaces(faces);
}
get isCritical() {
return false;
}
get fateDie() {
return this.data.fateType;
}
static getHooks(hooks) {
return [...(hooks ?? []), 'Fate'];
}
/** @inheritDoc */
static fromData(data) {
data.terms[0].class = foundry.dice.terms.Die.name;
return super.fromData(data);
}
createBaseDice() {
if (this.dice[0] instanceof foundry.dice.terms.Die) {
this.terms = [this.terms[0]];
return;
}
this.terms[0] = new foundry.dice.terms.Die({ faces: 12 });
}
static async buildEvaluate(roll, config = {}, message = {}) {
await super.buildEvaluate(roll, config, message);
if (roll.fateDie === 'Hope') {
await setDiceSoNiceForHopeFateRoll(roll, config.roll.fate.dice);
} else {
await setDiceSoNiceForFearFateRoll(roll, config.roll.fate.dice);
}
}
static postEvaluate(roll, config = {}) {
const data = super.postEvaluate(roll, config);
data.fate = {
dice: roll.fateDie === 'Hope' ? roll.dHope.denomination : roll.dFear.denomination,
value: roll.fateDie === 'Hope' ? roll.dHope.total : roll.dFear.total,
fateDie: roll.fateDie
};
return data;
}
}

View file

@ -764,16 +764,24 @@ export default class DhpActor extends Actor {
};
}
} else {
const valueFunc = (base, resource, baseMax) => {
if (resource.clear) return baseMax && base.inverted ? baseMax : 0;
return (base.value ?? base) + resource.value;
};
switch (r.key) {
case 'fear':
ui.resources.updateFear(
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear) + r.value
valueFunc(
game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Resources.Fear),
r
)
);
break;
case 'armor':
if (this.system.armor?.system?.marks) {
updates.armor.resources['system.marks.value'] = Math.max(
Math.min(this.system.armor.system.marks.value + r.value, this.system.armorScore),
Math.min(valueFunc(this.system.armor.system.marks, r), this.system.armorScore),
0
);
}
@ -782,7 +790,7 @@ export default class DhpActor extends Actor {
if (this.system.resources?.[r.key]) {
updates.actor.resources[`system.resources.${r.key}.value`] = Math.max(
Math.min(
this.system.resources[r.key].value + r.value,
valueFunc(this.system.resources[r.key], r, this.system.resources[r.key].max),
this.system.resources[r.key].max
),
0

View file

@ -87,6 +87,15 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
break;
}
}
if (this.type === 'fateRoll') {
html.classList.add('fate');
if (this.system.roll?.fate.fateDie == 'Hope') {
html.classList.add('hope');
}
if (this.system.roll?.fate.fateDie == 'Fear') {
html.classList.add('fear');
}
}
const autoExpandRoll = game.settings.get(
CONFIG.DH.id,

View file

@ -2,7 +2,7 @@ import { abilities } from '../config/actorConfig.mjs';
import { getCommandTarget, rollCommandToJSON } from '../helpers/utils.mjs';
export default function DhDualityRollEnricher(match, _options) {
const roll = rollCommandToJSON(match[1], match[0]);
const roll = rollCommandToJSON(match[0]);
if (!roll) return match[0];
return getDualityMessage(roll.result, roll.flavor);
@ -80,7 +80,7 @@ export const renderDualityButton = async event => {
};
export const enrichedDualityRoll = async (
{ reaction, traitValue, target, difficulty, title, label, advantage },
{ reaction, traitValue, target, difficulty, title, label, advantage, customConfig },
event
) => {
const config = {
@ -94,7 +94,8 @@ export const enrichedDualityRoll = async (
type: reaction ? 'reaction' : null
},
type: 'trait',
hasRoll: true
hasRoll: true,
...(customConfig ?? {})
};
if (target) {
@ -105,4 +106,5 @@ export const enrichedDualityRoll = async (
config.source = { actor: null };
await CONFIG.Dice.daggerheart.DualityRoll.build(config);
}
return config;
};

View file

@ -0,0 +1,80 @@
import { getCommandTarget, rollCommandToJSON } from '../helpers/utils.mjs';
export default function DhFateRollEnricher(match, _options) {
const roll = rollCommandToJSON(match[0]);
if (!roll) return match[0];
return getFateMessage(roll.result, roll?.flavor);
}
export function getFateTypeData(fateTypeValue) {
const value = fateTypeValue ? fateTypeValue.capitalize() : 'Hope';
const lowercased = fateTypeValue?.toLowerCase?.() ?? 'hope';
switch (lowercased) {
case 'hope':
case 'fear':
return { value, label: game.i18n.localize(`DAGGERHEART.GENERAL.${lowercased}`) };
default:
return null;
}
}
function getFateMessage(roll, flavor) {
const fateTypeData = getFateTypeData(roll?.type);
if (!fateTypeData)
return ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing'));
const { value: fateType, label: fateTypeLabel } = fateTypeData;
const title = flavor ?? game.i18n.localize('DAGGERHEART.GENERAL.fateRoll');
const fateElement = document.createElement('span');
fateElement.innerHTML = `
<button type="button" class="fate-roll-button${roll?.inline ? ' inline' : ''}"
data-title="${title}"
data-label="${fateTypeLabel}"
data-fateType="${fateType}"
>
${title}
</button>
`;
return fateElement;
}
export const renderFateButton = async event => {
const button = event.currentTarget,
target = getCommandTarget({ allowNull: true });
const fateTypeData = getFateTypeData(button.dataset?.fatetype);
if (!fateTypeData) ui.notifications.error(game.i18n.localize('DAGGERHEART.UI.Notifications.fateTypeParsing'));
const { value: fateType, label: fateTypeLabel } = fateTypeData;
await enrichedFateRoll(
{
target,
title: button.dataset.title,
label: button.dataset.label,
fateType: fateType
},
event
);
};
export const enrichedFateRoll = async ({ target, title, label, fateType }, event) => {
const config = {
event: event ?? {},
title: title,
headerTitle: label,
roll: {},
hasRoll: true,
fateType: fateType,
skips: { reaction: true }
};
config.data = { experiences: {}, traits: {}, fateType: fateType };
config.source = { actor: target?.uuid };
await CONFIG.Dice.daggerheart.FateRoll.build(config);
return config;
};

View file

@ -1,10 +1,11 @@
import { default as DhDamageEnricher, renderDamageButton } from './DamageEnricher.mjs';
import { default as DhDualityRollEnricher, renderDualityButton } from './DualityRollEnricher.mjs';
import { default as DhFateRollEnricher, renderFateButton } from './FateRollEnricher.mjs';
import { default as DhEffectEnricher } from './EffectEnricher.mjs';
import { default as DhTemplateEnricher, renderMeasuredTemplate } from './TemplateEnricher.mjs';
import { default as DhLookupEnricher } from './LookupEnricher.mjs';
export { DhDamageEnricher, DhDualityRollEnricher, DhEffectEnricher, DhTemplateEnricher };
export { DhDamageEnricher, DhDualityRollEnricher, DhEffectEnricher, DhTemplateEnricher, DhFateRollEnricher };
export const enricherConfig = [
{
@ -15,6 +16,10 @@ export const enricherConfig = [
pattern: /\[\[\/dr\s?(.*?)\]\]({[^}]*})?/g,
enricher: DhDualityRollEnricher
},
{
pattern: /\[\[\/fr\s?(.*?)\]\]({[^}]*})?/g,
enricher: DhFateRollEnricher
},
{
pattern: /@Effect\[([^\[\]]*)\]({[^}]*})?/g,
enricher: DhEffectEnricher
@ -38,6 +43,10 @@ export const enricherRenderSetup = element => {
.querySelectorAll('.duality-roll-button')
.forEach(element => element.addEventListener('click', renderDualityButton));
element
.querySelectorAll('.fate-roll-button')
.forEach(element => element.addEventListener('click', renderFateButton));
element
.querySelectorAll('.measured-template-button')
.forEach(element => element.addEventListener('click', renderMeasuredTemplate));

View file

@ -1,14 +1,14 @@
import { diceTypes, getDiceSoNicePresets, range } from '../config/generalConfig.mjs';
import { diceTypes, getDiceSoNicePresets, getDiceSoNicePreset, range } from '../config/generalConfig.mjs';
import Tagify from '@yaireo/tagify';
export const capitalize = string => {
return string.charAt(0).toUpperCase() + string.slice(1);
};
export function rollCommandToJSON(text, raw) {
export function rollCommandToJSON(text) {
if (!text) return {};
const flavorMatch = raw?.match(/{(.*)}$/);
const flavorMatch = text?.match(/{(.*)}$/);
const flavor = flavorMatch ? flavorMatch[1] : null;
// Match key="quoted string" OR key=unquotedValue
@ -31,7 +31,7 @@ export function rollCommandToJSON(text, raw) {
}
result[key] = value;
}
return Object.keys(result).length > 0 ? { result, flavor } : null;
return { result, flavor };
}
export const getCommandTarget = (options = {}) => {
@ -69,6 +69,20 @@ export const setDiceSoNiceForDualityRoll = async (rollResult, advantageState, ho
}
};
export const setDiceSoNiceForHopeFateRoll = async (rollResult, hopeFaces) => {
if (!game.modules.get('dice-so-nice')?.active) return;
const { diceSoNice } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance);
const diceSoNicePresets = await getDiceSoNicePreset(diceSoNice.hope, hopeFaces);
rollResult.dice[0].options = diceSoNicePresets;
};
export const setDiceSoNiceForFearFateRoll = async (rollResult, fearFaces) => {
if (!game.modules.get('dice-so-nice')?.active) return;
const { diceSoNice } = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance);
const diceSoNicePresets = await getDiceSoNicePreset(diceSoNice.fear, fearFaces);
rollResult.dice[0].options = diceSoNicePresets;
};
export const chunkify = (array, chunkSize, mappingFunc) => {
var chunkifiedArray = [];
for (let i = 0; i < array.length; i += chunkSize) {