mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-01-18 16:09:03 +01:00
[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:
parent
3103a40c26
commit
9d75157e17
38 changed files with 1166 additions and 258 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
module/applications/dialogs/riskItAllDialog.mjs
Normal file
94
module/applications/dialogs/riskItAllDialog.mjs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export const config = {
|
|||
adversaryRoll: DHActorRoll,
|
||||
damageRoll: DHActorRoll,
|
||||
dualityRoll: DHActorRoll,
|
||||
fateRoll: DHActorRoll,
|
||||
groupRoll: DHGroupRoll,
|
||||
systemMessage: DHSystemMessage
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
85
module/dice/fateRoll.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
80
module/enrichers/FateRollEnricher.mjs
Normal file
80
module/enrichers/FateRollEnricher.mjs
Normal 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;
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue