mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-03-07 06:26:13 +01:00
Merge branch 'v14-Dev' into feature/1354-Armor-Effect
This commit is contained in:
commit
934659910f
50 changed files with 987 additions and 102 deletions
|
|
@ -50,13 +50,20 @@ export default class CompendiumBrowserSettings extends HandlebarsApplicationMixi
|
|||
const excludedSourceData = this.browserSettings.excludedSources;
|
||||
const excludedPackData = this.browserSettings.excludedPacks;
|
||||
context.typePackCollections = game.packs.reduce((acc, pack) => {
|
||||
const { type, label, packageType, packageName, id } = pack.metadata;
|
||||
if (packageType === 'world' || !CompendiumBrowserSettings.#browserPackTypes.includes(type)) return acc;
|
||||
const { type, label, packageType, packageName: basePackageName, id } = pack.metadata;
|
||||
if (!CompendiumBrowserSettings.#browserPackTypes.includes(type)) return acc;
|
||||
|
||||
const isWorldPack = packageType === 'world';
|
||||
const packageName = isWorldPack ? 'world' : basePackageName;
|
||||
const sourceChecked =
|
||||
!excludedSourceData[packageName] ||
|
||||
!excludedSourceData[packageName].excludedDocumentTypes.includes(type);
|
||||
const sourceLabel = game.modules.get(packageName)?.title ?? game.system.title;
|
||||
|
||||
const sourceLabel =
|
||||
game.modules.get(packageName)?.title ??
|
||||
(isWorldPack
|
||||
? game.i18n.localize('DAGGERHEART.APPLICATIONS.CompendiumBrowserSettings.worldCompendiums')
|
||||
: game.system.title);
|
||||
if (!acc[type]) acc[type] = { label: game.i18n.localize(`DOCUMENT.${type}s`), sources: {} };
|
||||
if (!acc[type].sources[packageName])
|
||||
acc[type].sources[packageName] = { label: sourceLabel, checked: sourceChecked, packs: [] };
|
||||
|
|
|
|||
|
|
@ -200,7 +200,6 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
|
|||
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
|
||||
|
|
|
|||
|
|
@ -196,6 +196,9 @@ export default class DhpDowntime extends HandlebarsApplicationMixin(ApplicationV
|
|||
.filter(x => x.testUserPermission(game.user, 'LIMITED'))
|
||||
.filter(x => x.uuid !== this.actor.uuid);
|
||||
|
||||
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,
|
||||
|
|
@ -216,7 +219,8 @@ export default class DhpDowntime extends HandlebarsApplicationMixin(ApplicationV
|
|||
actor: { name: this.actor.name, img: this.actor.img },
|
||||
moves: moves,
|
||||
characters: characters,
|
||||
selfId: this.actor.uuid
|
||||
selfId: this.actor.uuid,
|
||||
open: autoExpandDescription ? 'open' : ''
|
||||
}
|
||||
),
|
||||
flags: {
|
||||
|
|
|
|||
|
|
@ -30,22 +30,27 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
|
|||
const group = game.i18n.localize(model.metadata.label);
|
||||
const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model.metadata.type);
|
||||
|
||||
const getLabel = path => {
|
||||
const label = model.schema.getField(path)?.label;
|
||||
return label ? game.i18n.localize(label) : path;
|
||||
const getTranslations = path => {
|
||||
if (path === 'resources.hope.max')
|
||||
return {
|
||||
label: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label'),
|
||||
hint: ''
|
||||
};
|
||||
|
||||
const field = model.schema.getField(path);
|
||||
return {
|
||||
label: field ? game.i18n.localize(field.label) : path,
|
||||
hint: field ? game.i18n.localize(field.hint) : ''
|
||||
};
|
||||
};
|
||||
|
||||
const bars = attributes.bar.flatMap(x => {
|
||||
const joined = `${x.join('.')}.max`;
|
||||
const label =
|
||||
joined === 'resources.hope.max'
|
||||
? 'DAGGERHEART.SETTINGS.Homebrew.FIELDS.maxHope.label'
|
||||
: getLabel(joined);
|
||||
return { value: joined, label, group };
|
||||
return { value: joined, ...getTranslations(joined), group };
|
||||
});
|
||||
const values = attributes.value.flatMap(x => {
|
||||
const joined = x.join('.');
|
||||
return { value: joined, label: getLabel(joined), group };
|
||||
return { value: joined, ...getTranslations(joined), group };
|
||||
});
|
||||
|
||||
const bonuses = getAllLeaves(model.schema.fields.bonuses, group);
|
||||
|
|
|
|||
|
|
@ -43,4 +43,54 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs.
|
|||
event.dataTransfer.setDragImage(preview, w / 2, h / 2);
|
||||
}
|
||||
}
|
||||
|
||||
_getEntryContextOptions() {
|
||||
const options = super._getEntryContextOptions();
|
||||
options.push({
|
||||
name: 'DAGGERHEART.UI.Sidebar.actorDirectory.duplicateToNewTier',
|
||||
icon: `<i class="fa-solid fa-arrow-trend-up" inert></i>`,
|
||||
condition: li => {
|
||||
const actor = game.actors.get(li.dataset.entryId);
|
||||
return actor?.type === 'adversary' && actor.system.type !== 'social';
|
||||
},
|
||||
callback: async li => {
|
||||
const actor = game.actors.get(li.dataset.entryId);
|
||||
if (!actor) throw new Error('Unexpected missing actor');
|
||||
|
||||
const tiers = [1, 2, 3, 4].filter(t => t !== actor.system.tier);
|
||||
const content = document.createElement('div');
|
||||
const select = document.createElement('select');
|
||||
select.name = 'tier';
|
||||
select.append(
|
||||
...tiers.map(t => {
|
||||
const option = document.createElement('option');
|
||||
option.value = t;
|
||||
option.textContent = game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${t}`);
|
||||
return option;
|
||||
})
|
||||
);
|
||||
content.append(select);
|
||||
|
||||
const tier = await foundry.applications.api.Dialog.input({
|
||||
classes: ['dh-style', 'dialog'],
|
||||
window: { title: 'DAGGERHEART.UI.Sidebar.actorDirectory.pickTierTitle' },
|
||||
content,
|
||||
ok: {
|
||||
label: 'Create Adversary',
|
||||
callback: (event, button, dialog) => Number(button.form.elements.tier.value)
|
||||
}
|
||||
});
|
||||
|
||||
if (tier === actor.system.tier) {
|
||||
ui.notifications.warn('This actor is already at this tier');
|
||||
} else if (tier) {
|
||||
const source = actor.system.adjustForTier(tier);
|
||||
await Actor.create(source);
|
||||
ui.notifications.info(`Tier ${tier} ${actor.name} created`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,8 +34,6 @@ export default class FearTracker extends HandlebarsApplicationMixin(ApplicationV
|
|||
position: {
|
||||
width: 222,
|
||||
height: 222
|
||||
// top: "200px",
|
||||
// left: "120px"
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +64,7 @@ export default class FearTracker extends HandlebarsApplicationMixin(ApplicationV
|
|||
max = this.maxFear,
|
||||
percent = (current / max) * 100,
|
||||
isGM = game.user.isGM;
|
||||
// Return the data for rendering
|
||||
|
||||
return { display, current, max, percent, isGM };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ export default class DhSceneNavigation extends foundry.applications.ui.SceneNavi
|
|||
if (scene.flags.daggerheart.sceneEnvironments[0] !== environment.uuid) {
|
||||
const newEnvironments = scene.flags.daggerheart.sceneEnvironments;
|
||||
const newFirst = newEnvironments.splice(
|
||||
newEnvironments.findIndex(x => x === environment.uuid)
|
||||
newEnvironments.findIndex(x => x === environment.uuid),
|
||||
1
|
||||
)[0];
|
||||
newEnvironments.unshift(newFirst);
|
||||
emitAsGM(
|
||||
|
|
|
|||
|
|
@ -494,3 +494,275 @@ export const subclassFeatureLabels = {
|
|||
2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle',
|
||||
3: 'DAGGERHEART.ITEMS.DomainCard.masteryTitle'
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} TierData
|
||||
* @property {number} difficulty
|
||||
* @property {number} majorThreshold
|
||||
* @property {number} severeThreshold
|
||||
* @property {number} hp
|
||||
* @property {number} stress
|
||||
* @property {number} attack
|
||||
* @property {number[]} damage
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Record<string, Record<2 | 3 | 4, TierData>}
|
||||
* Scaling data used to change an adversary's tier. Each rank is applied incrementally.
|
||||
*/
|
||||
export const adversaryScalingData = {
|
||||
bruiser: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 5,
|
||||
severeThreshold: 10,
|
||||
hp: 1,
|
||||
stress: 2,
|
||||
attack: 2
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 7,
|
||||
severeThreshold: 15,
|
||||
hp: 1,
|
||||
stress: 0,
|
||||
attack: 2
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 12,
|
||||
severeThreshold: 25,
|
||||
hp: 1,
|
||||
stress: 0,
|
||||
attack: 2
|
||||
}
|
||||
},
|
||||
horde: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 5,
|
||||
severeThreshold: 8,
|
||||
hp: 2,
|
||||
stress: 0,
|
||||
attack: 0
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 5,
|
||||
severeThreshold: 12,
|
||||
hp: 0,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 10,
|
||||
severeThreshold: 15,
|
||||
hp: 2,
|
||||
stress: 0,
|
||||
attack: 0
|
||||
}
|
||||
},
|
||||
leader: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 6,
|
||||
severeThreshold: 10,
|
||||
hp: 0,
|
||||
stress: 0,
|
||||
attack: 1
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 6,
|
||||
severeThreshold: 15,
|
||||
hp: 1,
|
||||
stress: 0,
|
||||
attack: 2
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 12,
|
||||
severeThreshold: 25,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 3
|
||||
}
|
||||
},
|
||||
minion: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 0,
|
||||
severeThreshold: 0,
|
||||
hp: 0,
|
||||
stress: 0,
|
||||
attack: 1
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 0,
|
||||
severeThreshold: 0,
|
||||
hp: 0,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 0,
|
||||
severeThreshold: 0,
|
||||
hp: 0,
|
||||
stress: 0,
|
||||
attack: 1
|
||||
}
|
||||
},
|
||||
ranged: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 3,
|
||||
severeThreshold: 6,
|
||||
hp: 1,
|
||||
stress: 0,
|
||||
attack: 1
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 7,
|
||||
severeThreshold: 14,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 2
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 5,
|
||||
severeThreshold: 10,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
}
|
||||
},
|
||||
skulk: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 3,
|
||||
severeThreshold: 8,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 8,
|
||||
severeThreshold: 12,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 8,
|
||||
severeThreshold: 10,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
}
|
||||
},
|
||||
solo: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 5,
|
||||
severeThreshold: 10,
|
||||
hp: 0,
|
||||
stress: 1,
|
||||
attack: 2
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 7,
|
||||
severeThreshold: 15,
|
||||
hp: 2,
|
||||
stress: 1,
|
||||
attack: 2
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 12,
|
||||
severeThreshold: 25,
|
||||
hp: 0,
|
||||
stress: 1,
|
||||
attack: 3
|
||||
}
|
||||
},
|
||||
standard: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 3,
|
||||
severeThreshold: 8,
|
||||
hp: 0,
|
||||
stress: 0,
|
||||
attack: 1
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 7,
|
||||
severeThreshold: 15,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 10,
|
||||
severeThreshold: 15,
|
||||
hp: 0,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
}
|
||||
},
|
||||
support: {
|
||||
2: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 3,
|
||||
severeThreshold: 8,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
},
|
||||
3: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 7,
|
||||
severeThreshold: 12,
|
||||
hp: 0,
|
||||
stress: 0,
|
||||
attack: 1
|
||||
},
|
||||
4: {
|
||||
difficulty: 2,
|
||||
majorThreshold: 8,
|
||||
severeThreshold: 10,
|
||||
hp: 1,
|
||||
stress: 1,
|
||||
attack: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Scaling data used for an adversary's damage.
|
||||
* Tier 4 is missing certain adversary types and therefore skews upwards.
|
||||
* We manually set tier 4 data to hopefully lead to better results
|
||||
*/
|
||||
export const adversaryExpectedDamage = {
|
||||
basic: {
|
||||
1: { mean: 7.321428571428571, deviation: 1.962519002770912 },
|
||||
2: { mean: 12.444444444444445, deviation: 2.0631069425529676 },
|
||||
3: { mean: 15.722222222222221, deviation: 2.486565208464823 },
|
||||
4: { mean: 26, deviation: 5.2 }
|
||||
},
|
||||
minion: {
|
||||
1: { mean: 2.142857142857143, deviation: 1.0690449676496976 },
|
||||
2: { mean: 5, deviation: 0.816496580927726 },
|
||||
3: { mean: 6.5, deviation: 2.1213203435596424 },
|
||||
4: { mean: 11, deviation: 1 }
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,6 +34,20 @@ export default class DHAttackAction extends DHDamageAction {
|
|||
};
|
||||
}
|
||||
|
||||
get damageFormula() {
|
||||
const hitPointsPart = this.damage.parts.find(x => x.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);
|
||||
if (!hitPointsPart) return '0';
|
||||
|
||||
return hitPointsPart.value.getFormula();
|
||||
}
|
||||
|
||||
get altDamageFormula() {
|
||||
const hitPointsPart = this.damage.parts.find(x => x.applyTo === CONFIG.DH.GENERAL.healingTypes.hitPoints.id);
|
||||
if (!hitPointsPart) return '0';
|
||||
|
||||
return hitPointsPart.valueAlt.getFormula();
|
||||
}
|
||||
|
||||
async use(event, options) {
|
||||
const result = await super.use(event, options);
|
||||
if (!result.message) return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import DHAdversarySettings from '../../applications/sheets-configs/adversary-set
|
|||
import { ActionField } from '../fields/actionField.mjs';
|
||||
import BaseDataActor, { commonActorRules } from './base.mjs';
|
||||
import { resourceField, bonusField } from '../fields/actorField.mjs';
|
||||
import { calculateExpectedValue, parseTermsFromSimpleFormula } from '../../helpers/utils.mjs';
|
||||
import { adversaryExpectedDamage, adversaryScalingData } from '../../config/actorConfig.mjs';
|
||||
|
||||
export default class DhpAdversary extends BaseDataActor {
|
||||
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
|
||||
|
|
@ -195,4 +197,211 @@ export default class DhpAdversary extends BaseDataActor {
|
|||
];
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Returns source data for this actor adjusted to a new tier, which can be used to create a new actor. */
|
||||
adjustForTier(tier) {
|
||||
const source = this.parent.toObject(true);
|
||||
|
||||
/** @type {(2 | 3 | 4)[]} */
|
||||
const tiers = new Array(Math.abs(tier - this.tier))
|
||||
.fill(0)
|
||||
.map((_, idx) => idx + Math.min(tier, this.tier) + 1);
|
||||
if (tier < this.tier) tiers.reverse();
|
||||
const typeData = adversaryScalingData[source.system.type] ?? adversaryScalingData[source.system.standard];
|
||||
const tierEntries = tiers.map(t => ({ tier: t, ...typeData[t] }));
|
||||
|
||||
// Apply simple tier changes
|
||||
const scale = tier > this.tier ? 1 : -1;
|
||||
for (const entry of tierEntries) {
|
||||
source.system.difficulty += scale * entry.difficulty;
|
||||
source.system.damageThresholds.major += scale * entry.majorThreshold;
|
||||
source.system.damageThresholds.severe += scale * entry.severeThreshold;
|
||||
source.system.resources.hitPoints.max += scale * entry.hp;
|
||||
source.system.resources.stress.max += scale * entry.stress;
|
||||
source.system.attack.roll.bonus += scale * entry.attack;
|
||||
}
|
||||
|
||||
// Get the mean and standard deviation of expected damage in the previous and new tier
|
||||
// The data we have is for attack scaling, but we reuse this for action scaling later
|
||||
const expectedDamageData = adversaryExpectedDamage[source.system.type] ?? adversaryExpectedDamage.basic;
|
||||
const damageMeta = {
|
||||
currentDamageRange: { tier: source.system.tier, ...expectedDamageData[source.system.tier] },
|
||||
newDamageRange: { tier, ...expectedDamageData[tier] },
|
||||
type: 'attack'
|
||||
};
|
||||
|
||||
// Update damage of base attack
|
||||
try {
|
||||
this.#adjustActionDamage(source.system.attack, damageMeta);
|
||||
} catch (err) {
|
||||
ui.notifications.warn('Failed to convert attack damage of adversary');
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
// Update damage of each item action, making sure to also update the description if possible
|
||||
const damageRegex = /@Damage\[([^\[\]]*)\]({[^}]*})?/g;
|
||||
for (const item of source.items) {
|
||||
// Replace damage inlines with new formulas
|
||||
for (const withDescription of [item.system, ...Object.values(item.system.actions)]) {
|
||||
withDescription.description = withDescription.description.replace(damageRegex, (match, inner) => {
|
||||
const { value: formula } = parseInlineParams(inner);
|
||||
if (!formula || !type) return match;
|
||||
|
||||
try {
|
||||
const adjusted = this.#calculateAdjustedDamage(formula, { ...damageMeta, type: 'action' });
|
||||
const newFormula = [
|
||||
adjusted.diceQuantity ? `${adjusted.diceQuantity}d${adjusted.faces}` : null,
|
||||
adjusted.bonus
|
||||
]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
return match.replace(formula, newFormula);
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update damage in item actions
|
||||
for (const action of Object.values(item.system.actions)) {
|
||||
if (!action.damage) continue;
|
||||
|
||||
// Parse damage, and convert all formula matches in the descriptions to the new damage
|
||||
try {
|
||||
const result = this.#adjustActionDamage(action, { ...damageMeta, type: 'action' });
|
||||
for (const { previousFormula, formula } of Object.values(result)) {
|
||||
const oldFormulaRegexp = new RegExp(
|
||||
previousFormula.replace(' ', '').replace('+', '(?:\\s)?\\+(?:\\s)?')
|
||||
);
|
||||
item.system.description = item.system.description.replace(oldFormulaRegexp, formula);
|
||||
action.description = action.description.replace(oldFormulaRegexp, formula);
|
||||
}
|
||||
} catch (err) {
|
||||
ui.notifications.warn(`Failed to convert action damage for item ${item.name}`);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally set the tier of the source data, now that everything is complete
|
||||
source.system.tier = tier;
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a damage object to a new damage range
|
||||
* @returns {{ diceQuantity: number; faces: number; bonus: number }} the adjusted result as a combined term
|
||||
* @throws error if the formula is the wrong type
|
||||
*/
|
||||
#calculateAdjustedDamage(formula, { currentDamageRange, newDamageRange, type }) {
|
||||
const terms = parseTermsFromSimpleFormula(formula);
|
||||
const flatTerms = terms.filter(t => t.diceQuantity === 0);
|
||||
const diceTerms = terms.filter(t => t.diceQuantity > 0);
|
||||
if (flatTerms.length > 1 || diceTerms.length > 1) {
|
||||
throw new Error('invalid formula for conversion');
|
||||
}
|
||||
const value = {
|
||||
...(diceTerms[0] ?? { diceQuantity: 0, faces: 1 }),
|
||||
bonus: flatTerms[0]?.bonus ?? 0
|
||||
};
|
||||
const previousExpected = calculateExpectedValue(value);
|
||||
if (previousExpected === 0) return value; // nothing to do
|
||||
|
||||
const dieSizes = [4, 6, 8, 10, 12, 20];
|
||||
const steps = newDamageRange.tier - currentDamageRange.tier;
|
||||
const increasing = steps > 0;
|
||||
const deviation = (previousExpected - currentDamageRange.mean) / currentDamageRange.deviation;
|
||||
const expected = Math.max(1, newDamageRange.mean + newDamageRange.deviation * deviation);
|
||||
|
||||
// If this was just a flat number, convert to the expected damage and exit
|
||||
if (value.diceQuantity === 0) {
|
||||
value.bonus = Math.round(expected);
|
||||
return value;
|
||||
}
|
||||
|
||||
const getExpectedDie = () => calculateExpectedValue({ diceQuantity: 1, faces: value.faces }) || 1;
|
||||
const getBaseAverage = () => calculateExpectedValue({ ...value, bonus: 0 });
|
||||
|
||||
// Check the number of base overages over the expected die. In the end, if the bonus inflates too much, we add a die
|
||||
const baseOverages = Math.floor(value.bonus / getExpectedDie());
|
||||
|
||||
// Prestep. Change number of dice for attacks, bump up/down for actions
|
||||
// We never bump up to d20, though we might bump down from it
|
||||
if (type === 'attack') {
|
||||
const minimum = increasing ? value.diceQuantity : 0;
|
||||
value.diceQuantity = Math.max(minimum, newDamageRange.tier);
|
||||
} else {
|
||||
const currentIdx = dieSizes.indexOf(value.faces);
|
||||
value.faces = dieSizes[Math.clamp(currentIdx + steps, 0, 4)];
|
||||
}
|
||||
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
|
||||
// Attempt to handle negative values.
|
||||
// If we can do it with only step downs, do so. Otherwise remove tier dice, and try again
|
||||
if (value.bonus < 0) {
|
||||
let stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||
const currentIdx = dieSizes.indexOf(value.faces);
|
||||
|
||||
// If step downs alone don't suffice, change the flat modifier, then calculate steps required again
|
||||
// If this isn't sufficient, the result will be slightly off. This is unlikely to happen
|
||||
if (type !== 'attack' && stepsRequired > currentIdx && value.diceQuantity > 0) {
|
||||
value.diceQuantity -= increasing ? 1 : Math.abs(steps);
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
if (value.bonus >= 0) return value; // complete
|
||||
}
|
||||
|
||||
stepsRequired = Math.ceil(Math.abs(value.bonus) / value.diceQuantity);
|
||||
value.faces = dieSizes[Math.max(0, currentIdx - stepsRequired)];
|
||||
value.bonus = Math.max(0, Math.round(expected - getBaseAverage()));
|
||||
}
|
||||
|
||||
// If value is really high, we add a number of dice based on the number of overages
|
||||
// This attempts to preserve a similar amount of variance when increasing an action
|
||||
const overagesToRemove = Math.floor(value.bonus / getExpectedDie()) - baseOverages;
|
||||
if (type !== 'attack' && increasing && overagesToRemove > 0) {
|
||||
value.diceQuantity += overagesToRemove;
|
||||
value.bonus = Math.round(expected - getBaseAverage());
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates damage to reflect a specific value.
|
||||
* @throws if damage structure is invalid for conversion
|
||||
* @returns the converted formula and value as a simplified term
|
||||
*/
|
||||
#adjustActionDamage(action, damageMeta) {
|
||||
// The current algorithm only returns a value if there is a single damage part
|
||||
const hpDamageParts = action.damage.parts.filter(d => d.applyTo === 'hitPoints');
|
||||
if (hpDamageParts.length !== 1) throw new Error('incorrect number of hp parts');
|
||||
|
||||
const result = {};
|
||||
for (const property of ['value', 'valueAlt']) {
|
||||
const data = hpDamageParts[0][property];
|
||||
const previousFormula = data.custom.enabled
|
||||
? data.custom.formula
|
||||
: [data.flatMultiplier ? `${data.flatMultiplier}${data.dice}` : 0, data.bonus ?? 0]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
const value = this.#calculateAdjustedDamage(previousFormula, damageMeta);
|
||||
const formula = [value.diceQuantity ? `${value.diceQuantity}d${value.faces}` : null, value.bonus]
|
||||
.filter(p => !!p)
|
||||
.join('+');
|
||||
if (value.diceQuantity) {
|
||||
data.custom.enabled = false;
|
||||
data.bonus = value.bonus;
|
||||
data.dice = `d${value.faces}`;
|
||||
data.flatMultiplier = value.diceQuantity;
|
||||
} else if (!value.diceQuantity) {
|
||||
data.custom.enabled = true;
|
||||
data.custom.formula = formula;
|
||||
}
|
||||
|
||||
result[property] = { previousFormula, formula, value };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ export default class CompendiumBrowserSettings extends foundry.abstract.DataMode
|
|||
const pack = game.packs.get(item.pack);
|
||||
if (!pack) return false;
|
||||
|
||||
const excludedSourceData = this.excludedSources[pack.metadata.packageName];
|
||||
const packageName = pack.metadata.packageType === 'world' ? 'world' : pack.metadata.packageName;
|
||||
const excludedSourceData = this.excludedSources[packageName];
|
||||
if (excludedSourceData && excludedSourceData.excludedDocumentTypes.includes(pack.metadata.type)) return true;
|
||||
|
||||
const excludedPackData = this.excludedPacks[item.pack];
|
||||
|
|
|
|||
|
|
@ -269,6 +269,9 @@ export function ActionMixin(Base) {
|
|||
}
|
||||
|
||||
async toChat(origin) {
|
||||
const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance)
|
||||
.expandRollMessage?.desc;
|
||||
|
||||
const cls = getDocumentClass('ChatMessage');
|
||||
const systemData = {
|
||||
title: game.i18n.localize('DAGGERHEART.CONFIG.FeatureForm.action'),
|
||||
|
|
@ -297,7 +300,7 @@ export function ActionMixin(Base) {
|
|||
system: systemData,
|
||||
content: await foundry.applications.handlebars.renderTemplate(
|
||||
'systems/daggerheart/templates/ui/chat/action.hbs',
|
||||
systemData
|
||||
{ ...systemData, open: autoExpandDescription ? 'open' : '' }
|
||||
),
|
||||
flags: {
|
||||
daggerheart: {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default class DHToken extends CONFIG.Token.documentClass {
|
|||
const valueGroup = game.i18n.localize('TOKEN.BarValues');
|
||||
const actorModel = typeKey ? game.system.api.data.actors[`Dh${typeKey.capitalize()}`] : null;
|
||||
const getLabel = path => {
|
||||
const label = actorModel.schema.getField(path)?.label;
|
||||
const label = actorModel?.schema.getField(path)?.label;
|
||||
return label ? game.i18n.localize(label) : path;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -513,3 +513,49 @@ export function getIconVisibleActiveEffects(effects) {
|
|||
return !effect.disabled && (alwaysShown || conditionalShown);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms.
|
||||
* All subtracted terms become negative terms.
|
||||
* If there are no dice, it returns 0d1 for that term.
|
||||
*/
|
||||
export function parseTermsFromSimpleFormula(formula) {
|
||||
const roll = formula instanceof Roll ? formula : new Roll(formula);
|
||||
|
||||
// Parse from right to left so that when we hit an operator, we already have the term.
|
||||
return roll.terms.reduceRight((result, term) => {
|
||||
// Ignore + terms, we assume + by default
|
||||
if (term.expression === ' + ') return result;
|
||||
|
||||
// - terms modify the last term we parsed
|
||||
if (term.expression === ' - ') {
|
||||
const termToModify = result[0];
|
||||
if (termToModify) {
|
||||
if (termToModify.bonus) termToModify.bonus *= -1;
|
||||
if (termToModify.dice) termToModify.dice *= -1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
result.unshift({
|
||||
bonus: term instanceof foundry.dice.terms.NumericTerm ? term.number : 0,
|
||||
diceQuantity: term instanceof foundry.dice.terms.Die ? term.number : 0,
|
||||
faces: term.faces ?? 1
|
||||
});
|
||||
|
||||
return result;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the expectede value from a formula or the results of parseTermsFromSimpleFormula.
|
||||
* @returns {number} the average result of rolling the given dice
|
||||
*/
|
||||
export function calculateExpectedValue(formulaOrTerms) {
|
||||
const terms = Array.isArray(formulaOrTerms)
|
||||
? formulaOrTerms
|
||||
: typeof formulaOrTerms === 'string'
|
||||
? parseTermsFromSimpleFormula(formulaOrTerms)
|
||||
: [formulaOrTerms];
|
||||
return terms.reduce((r, t) => r + (t.bonus ?? 0) + (t.diceQuantity ? (t.diceQuantity * (t.faces + 1)) / 2 : 0), 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ const registerNonConfigSettings = () => {
|
|||
type: Number,
|
||||
default: 0,
|
||||
onChange: () => {
|
||||
if (ui.resources) ui.resources.render({ force: true });
|
||||
if (ui.resources) ui.resources.render();
|
||||
ui.combat.render({ force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue