Compare commits

...

12 commits

Author SHA1 Message Date
WBHarry
3b011e2580 Added ArmorManagement menu 2026-02-13 15:36:13 +01:00
WBHarry
934659910f Merge branch 'v14-Dev' into feature/1354-Armor-Effect 2026-02-13 13:17:49 +01:00
WBHarry
9b63371f4a Merged with main 2026-02-13 13:17:00 +01:00
WBHarry
472f876ea3 Merge branch 'development' 2026-02-12 22:27:59 +01:00
Carlos Fernandez
7022630316
[PR][Feature] Add support for changing the tier of an adversary (#1503)
* Add support for changing the tier of an adversary

* Move scaling data to actorConfig

* Use a new algorithm using the median average deviation

* Fine tune damage conversion for actions

* Use standard deviation instead and change dialog type

* Use daggerheart style for dialog

* Formatting

* Improve handling of minions and hordes

* Changed to using lookup for Group Attack damage

* Added lookup for Horde feature

* Remove spaces in damage formulas

---------

Co-authored-by: WBHarry <williambjrklund@gmail.com>
2026-02-12 22:27:37 +01:00
WBHarry
e0b3d33f80 Added the ability to exclude world compendiums in the Compendium Browser Settings 2026-02-12 19:27:03 +01:00
WBHarry
60cd28ae82 Fixed the fear tracker showing up while supposed to be hidden 2026-02-12 19:03:21 +01:00
WBHarry
12bcd6e34e Fixed ScenEnvironment menu removing sceneEnvironments when used 2026-02-12 18:55:22 +01:00
WBHarry
6cbe770880
[Fix] ActiveEffectConfig Missing Resistances (#1653)
* Fixed so that ActiveEffectConfig uses missing hints and has resistance in the autocomplete list

* Raised version
2026-02-11 23:59:27 +01:00
WBHarry
95d4003045
Fixed so that messages auto expand the description (#1650) 2026-02-11 23:56:35 +01:00
WBHarry
fa19339868 Fixed better sceneNavigation compatability 2026-02-11 23:34:10 +01:00
WBHarry
17ec77a349 Fixed not being able to open the tokenConfig of actor-less tokens 2026-02-11 00:31:45 +01:00
54 changed files with 1072 additions and 104 deletions

View file

@ -254,14 +254,17 @@ Hooks.on('setup', () => {
}))
];
const actorCommon = {
bar: ['resources.stress'],
value: []
};
const damageThresholds = ['damageThresholds.major', 'damageThresholds.severe'];
const traits = Object.keys(game.system.api.data.actors.DhCharacter.schema.fields.traits.fields).map(
trait => `traits.${trait}.value`
);
const resistance = Object.values(game.system.api.data.actors.DhCharacter.schema.fields.resistance.fields).flatMap(
type => Object.keys(type.fields).map(x => `resistance.${type.name}.${x}`)
);
const actorCommon = {
bar: ['resources.stress'],
value: [...resistance]
};
CONFIG.Actor.trackableAttributes = {
character: {
bar: [...actorCommon.bar, 'resources.hitPoints', 'resources.hope'],

View file

@ -358,7 +358,8 @@
"CompendiumBrowserSettings": {
"title": "Enable Compendiums",
"enableSource": "Enable Source",
"disableSource": "Disable Source"
"disableSource": "Disable Source",
"worldCompendiums": "World Compendiums"
},
"ContextMenu": {
"disableEffect": "Disable Effect",
@ -2128,7 +2129,7 @@
"thresholdImmunities": {
"minor": {
"label": "Threshold Immunities: Minor",
"hint": "Automatically ignores minor damage"
"hint": "Automatically ignores minor damage when set to 1"
}
}
},
@ -3002,7 +3003,9 @@
"tier": "Tier {tier} {type}",
"character": "Level {level} Character",
"companion": "Level {level} - {partner}",
"companionNoPartner": "No Partner"
"companionNoPartner": "No Partner",
"duplicateToNewTier": "Duplicate to New Tier",
"pickTierTitle": "Pick a new tier for this adversary"
},
"daggerheartMenu": {
"title": "Daggerheart Menu",

View file

@ -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: [] };

View file

@ -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

View file

@ -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: {

View file

@ -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);

View file

@ -34,7 +34,8 @@ export default class CharacterSheet extends DHBaseActorSheet {
advanceResourceDie: CharacterSheet.#advanceResourceDie,
cancelBeastform: CharacterSheet.#cancelBeastform,
useDowntime: this.useDowntime,
viewParty: CharacterSheet.#viewParty
viewParty: CharacterSheet.#viewParty,
toggleArmorMangement: CharacterSheet.#toggleArmorManagement
},
window: {
resizable: true,
@ -945,6 +946,77 @@ export default class CharacterSheet extends DHBaseActorSheet {
});
}
static async #toggleArmorManagement(_event, target) {
const existingTooltip = document.body.querySelector('.locked-tooltip .armor-management-container');
if (existingTooltip) {
game.tooltip.dismissLockedTooltips();
return;
}
const armorSources = [];
for (var effect of Array.from(this.document.allApplicableEffects())) {
const origin = effect.origin ? await foundry.utils.fromUuid(effect.origin) : effect.parent;
if (effect.type !== 'armor' || effect.disabled || effect.isSuppressed) continue;
armorSources.push({
uuid: effect.uuid,
name: origin.name,
...effect.system.armorData
});
}
if (armorSources.length <= 1) return;
const html = document.createElement('div');
html.innerHTML = await foundry.applications.handlebars.renderTemplate(
`systems/daggerheart/templates/ui/tooltip/armorManagement.hbs`,
{
sources: armorSources
}
);
game.tooltip.dismissLockedTooltips();
game.tooltip.activate(target, {
html,
locked: true,
cssClass: 'bordered-tooltip',
direction: 'DOWN'
});
html.querySelectorAll('.armor-marks-input').forEach(element => {
element.addEventListener('blur', CharacterSheet.armorSourceUpdate);
element.addEventListener('input', CharacterSheet.armorSourceInput);
});
}
static async armorSourceInput(event) {
const effect = await foundry.utils.fromUuid(event.target.dataset.uuid);
const value = Math.max(Math.min(Number.parseInt(event.target.value), effect.system.armorData.max), 0);
event.target.value = value;
const progressBar = event.target.closest('.status-bar.armor-slots').querySelector('progress');
progressBar.value = value;
}
/** Update specific armor source */
static async armorSourceUpdate(event) {
const effect = await foundry.utils.fromUuid(event.target.dataset.uuid);
if (effect.system.changes.length !== 1) return;
const armorEffect = effect.system.changes[0];
const value = Math.max(Math.min(Number.parseInt(event.target.value), effect.system.armorData.max), 0);
const newChanges = [
{
...armorEffect,
value
}
];
event.target.value = value;
const progressBar = event.target.closest('.status-bar.armor-slots').querySelector('progress');
progressBar.value = value;
await effect.update({ 'system.changes': newChanges });
}
/**
* Open the downtime application.
* @type {ApplicationClickAction}

View file

@ -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;
}
}

View file

@ -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 };
}

View file

@ -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(

View file

@ -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 }
}
};

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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];

View file

@ -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: {

View file

@ -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;
};

View file

@ -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);
}

View file

@ -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 });
}
});

View file

@ -246,7 +246,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"vgguNWz8vG8aoLXR": {

View file

@ -218,10 +218,10 @@
},
"items": [
{
"name": "Horde (1d6+3)",
"name": "Horde",
"type": "feature",
"system": {
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>1d6+3</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -239,7 +239,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 6 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"cbAvPSIhwBMBTI3D": {

View file

@ -239,7 +239,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all Cult @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all Cult @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"EH1preaTWBD4rOvx": {

View file

@ -224,10 +224,10 @@
},
"items": [
{
"name": "Horde (2d4+1)",
"name": "Horde",
"type": "feature",
"system": {
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>2d4+1</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -218,10 +218,10 @@
},
"items": [
{
"name": "Horde (2d4+1)",
"name": "Horde",
"type": "feature",
"system": {
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>2d4+1</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -239,7 +239,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"vXHZVb0Y7Hqu3uso": {

View file

@ -317,7 +317,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 12 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"QHNRSEQmqOcaoXq4": {

View file

@ -229,7 +229,7 @@
"_id": "9RduwBLYcBaiouYk",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": {
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>1d4+1</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -248,7 +248,7 @@
"_id": "fsaBlCjTdq1jM23G",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"q8chow47nQLR9qeF": {

View file

@ -239,7 +239,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 5 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"DjbPQowW1OdBD9Zn": {

View file

@ -294,7 +294,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 10 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"eo7J0v1B5zPHul1M": {

View file

@ -248,7 +248,7 @@
"_id": "1k5TmQIAunM7Bv32",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name] within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name] within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"aoQDb2m32NDxE6ZP": {

View file

@ -242,7 +242,7 @@
"_id": "K08WlZwGqzEo4idT",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 4 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"xTMNAHcoErKuR6TZ": {

View file

@ -239,7 +239,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 11 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"tvQetauskZoHDR5y": {

View file

@ -229,7 +229,7 @@
"_id": "Q7DRbWjHl64CNwag",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": {
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>1d4+1</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -242,7 +242,7 @@
"_id": "R9vrwFNl5BD1YXJo",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"DJBNtd3hWjwsjPwq": {

View file

@ -242,7 +242,7 @@
"_id": "CQZQiEiRH70Br5Ge",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 3 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"ghgFZskDiizJDjcn": {

View file

@ -242,7 +242,7 @@
"_id": "wl9KKEpVWDBu62hU",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 1 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"Sz55uB8xkoNytLwJ": {

View file

@ -223,7 +223,7 @@
"_id": "9Zuu892SO5NmtI4w",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": {
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>1d4+1</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -254,12 +254,12 @@
},
"items": [
{
"name": "Horde (1d4+2)",
"name": "Horde",
"type": "feature",
"_id": "4dSzqtYvH385r9Ng",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": {
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>1d4+2</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -281,7 +281,7 @@
"_id": "WiobzuyvJ46zfsOv",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 2 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"ZC5pKIb9N82vgMWu": {

View file

@ -239,7 +239,7 @@
"name": "Group Attack",
"type": "feature",
"system": {
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal 8 physical damage each. Combine this damage.</p>",
"description": "<p><strong>Spend a Fear</strong> to choose a target and spotlight all @Lookup[@name]s within Close range of them. Those Minions move into Melee range of the target and make one shared attack roll. On a success, they deal @Lookup[@system.attack.damageFormula] physical damage each. Combine this damage.</p>",
"resource": null,
"actions": {
"euP8VA4wvfsCpwN1": {

View file

@ -218,10 +218,10 @@
},
"items": [
{
"name": "Horde (2d6+5)",
"name": "Horde",
"type": "feature",
"system": {
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>2d6+5</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] has marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -218,12 +218,12 @@
},
"items": [
{
"name": "Horde (1d4+2)",
"name": "Horde",
"type": "feature",
"_id": "nNJGAhWu0IuS2ybn",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": {
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>1d4+2</strong> physical damage instead.</p>",
"description": "<p>When the @Lookup[@name] have marked half or more of their HP, their standard attack deals <strong>@Lookup[@system.attack.altDamageFormula]</strong> physical damage instead.</p>",
"resource": null,
"actions": {},
"originItemType": null,

View file

@ -298,6 +298,10 @@
border-radius: 3px;
background: light-dark(@dark-blue, @golden);
clip-path: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
h4 {
font-weight: bold;
@ -306,6 +310,11 @@
color: light-dark(@beige, @dark-blue);
font-size: var(--font-size-12);
}
i {
font-size: 12px;
color: light-dark(@beige, @dark-blue);
}
}
.slot-value {
position: absolute;

View file

@ -1,36 +1,39 @@
#ui-left #ui-left-column-2 {
flex: 0 0 230px;
.scene-navigation {
.scene-wrapper {
display: flex;
gap: 2px;
height: var(--control-size);
width: 100%;
.scene-wrapper {
display: flex;
gap: 2px;
height: var(--control-size);
width: 100%;
.scene-environment {
padding: 0;
> ul {
margin: 0;
padding: 0;
}
img {
border-radius: 4px;
}
.scene-environment {
padding: 0;
img {
border-radius: 4px;
}
}
}
.scene {
justify-content: center;
align-content: center;
background: var(--control-bg-color);
border: 1px solid var(--control-border-color);
border-radius: 4px;
color: var(--control-icon-color);
pointer-events: all;
transition:
border 0.25s,
color 0.25s;
text-shadow: none;
width: 200px;
max-width: 200px;
}
.scene {
justify-content: center;
align-content: center;
background: var(--control-bg-color);
border: 1px solid var(--control-border-color);
border-radius: 4px;
color: var(--control-icon-color);
pointer-events: all;
transition:
border 0.25s,
color 0.25s;
text-shadow: none;
width: 200px;
max-width: 200px;
}
}

View file

@ -1,4 +1,5 @@
@import './tooltip/tooltip.less';
@import './tooltip/armorManagement.less';
@import './tooltip/battlepoints.less';
@import './tooltip/bordered-tooltip.less';
@import './tooltip/domain-cards.less';

View file

@ -0,0 +1,100 @@
.bordered-tooltip.locked-tooltip .daggerheart.armor-management-container {
display: flex;
flex-direction: column;
gap: 16px;
.armor-source-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
.armor-source-label {
font-size: var(--font-size-24);
font-weight: bold;
}
.status-bar {
display: flex;
justify-content: center;
position: relative;
width: 80px;
height: 20px;
.status-value {
position: absolute;
display: flex;
padding: 0 5px;
font-size: 1rem;
align-items: center;
width: 80px;
height: 20px;
justify-content: center;
text-align: center;
z-index: 2;
color: @beige;
input[type='number'] {
background: transparent;
font-size: 1rem;
width: 30px;
height: 15px;
text-align: center;
border: none;
outline: 2px solid transparent;
color: @beige;
&.bar-input {
padding: 0;
color: @beige;
backdrop-filter: none;
background: transparent;
transition: all 0.3s ease;
&:hover,
&:focus {
background: @semi-transparent-dark-blue;
backdrop-filter: blur(9.5px);
}
}
}
.bar-label {
width: 40px;
}
}
.progress-bar {
position: absolute;
appearance: none;
width: 80px;
height: 20px;
border: 1px solid light-dark(@dark-blue, @golden);
border-radius: 6px;
z-index: 1;
background: @dark-blue;
&::-webkit-progress-bar {
border: none;
background: @dark-blue;
border-radius: 6px;
}
&::-webkit-progress-value {
background: @gradient-hp;
border-radius: 6px;
}
&.stress-color::-webkit-progress-value {
background: @gradient-stress;
border-radius: 6px;
}
&::-moz-progress-bar {
background: @gradient-hp;
border-radius: 6px;
}
&.stress-color::-moz-progress-bar {
background: @gradient-stress;
border-radius: 6px;
}
}
}
}
}

View file

@ -61,8 +61,9 @@
value='{{document.system.armorScore.value}}'
max='{{document.system.armorScore.max}}'
></progress>
<div class="status-label">
<div class="status-label" data-action="toggleArmorMangement">
<h4>{{localize "DAGGERHEART.GENERAL.armorSlots"}}</h4>
<i class="fa-solid fa-gear"></i>
</div>
{{/if}}
</div>

View file

@ -1,5 +1,5 @@
<div class="daggerheart chat action">
<details class="action-move">
<details class="action-move" {{this.open}}>
<summary class="action-section">
<img class="action-img" src="{{action.img}}" />
<div class="action-header">

View file

@ -7,7 +7,7 @@
<h2 class="title">{{this.title}}</h2>
<span class="label">{{localize 'DAGGERHEART.UI.Chat.deathMove.title'}}</span>
</div>
<i class="fa-solid {{this.chevron}}"></i>
<i class="fa-solid fa-chevron-down"></i>
</summary>
<div class="description">
{{{this.description}}}

View file

@ -1,7 +1,7 @@
<div class="daggerheart chat downtime">
<ul class="downtime-moves-list">
{{#each moves as | move index |}}
<details class="downtime-move">
<details class="downtime-move" {{@root.open}}>
<summary class="downtime-label">
<img class="downtime-image" src="{{move.img}}" />
<div class="header-label">

View file

@ -37,17 +37,19 @@
{{!-- <menu id="scene-navigation-active" class="scene-navigation-menu flexcol">
{{#each scenes.active as |scene|}}
<li class="scene-wrapper">
<div class="ui-control scene {{scene.cssClass}}" data-scene-id="{{scene.id}}" data-action="viewScene" {{#if scene.tooltip}}data-tooltip-text="{{scene.tooltip}}"{{/if}}>
<span class="scene-name ellipsis">{{scene.name}}</span>
{{#if scene.users}}
<ul class="scene-players">
{{#each scene.users as |user|}}
<li class="scene-player" style="--color-bg:{{user.color}}; --color-border:{{user.border}}"
data-tooltip aria-label="{{user.name}}">{{user.letter}}</li>
{{/each}}
</ul>
{{/if}}
</div>
<ul>
<li class="ui-control scene {{scene.cssClass}}" data-scene-id="{{scene.id}}" data-action="viewScene" {{#if scene.tooltip}}data-tooltip-text="{{scene.tooltip}}"{{/if}}>
<span class="scene-name ellipsis">{{scene.name}}</span>
{{#if scene.users}}
<ul class="scene-players">
{{#each scene.users as |user|}}
<li class="scene-player" style="--color-bg:{{user.color}}; --color-border:{{user.border}}"
data-tooltip aria-label="{{user.name}}">{{user.letter}}</li>
{{/each}}
</ul>
{{/if}}
</li>
</ul>
{{#if scene.hasEnvironments}}
<button class="ui-control scene-environment {{#if (gt scene.environments.length 1)}}many-environments{{/if}}" data-action="openSceneEnvironment" data-scene-id="{{scene.id}}"><img src="{{scene.environmentImage}}" /> </button>
{{/if}}
@ -57,9 +59,11 @@
<menu id="scene-navigation-inactive" class="scene-navigation-menu flexcol">
{{#each scenes.inactive as |scene|}}
<li class="scene-wrapper">
<div class="ui-control scene {{scene.cssClass}}" data-scene-id="{{scene.id}}" data-action="viewScene" {{#if scene.tooltip}}data-tooltip-text="{{scene.tooltip}}"{{/if}}>
<ul>
<li class="ui-control scene {{scene.cssClass}}" data-scene-id="{{scene.id}}" data-action="viewScene" {{#if scene.tooltip}}data-tooltip-text="{{scene.tooltip}}"{{/if}}>
<span class="scene-name ellipsis">{{scene.name}}</span>
</div>
</li>
</ul>
</li>
{{/each}}
</menu> --}}

View file

@ -0,0 +1,19 @@
<div class="daggerheart armor-management-container">
{{#each sources as |source|}}
<div class="armor-source-container">
<label class="armor-source-label">{{source.name}}</label>
<div class="status-bar armor-slots">
<div class='status-value'>
<input class="bar-input armor-marks-input" value="{{source.value}}" data-uuid="{{source.uuid}}" type="number">
<span>/</span>
<span class="bar-label">{{source.max}}</span>
</div>
<progress
class='progress-bar stress-color'
value='{{source.value}}'
max='{{source.max}}'
></progress>
</div>
</div>
{{/each}}
</div>

144
tools/analyze-damage.mjs Normal file
View file

@ -0,0 +1,144 @@
/**
* Internal script to analyze damage and spit out results.
* There isn't enough entries in the database to make a full analysis, some tiers miss some types.
* This script only checks for "minions" and "everything else".
* Maybe if future book monsters can be part of what we release, we can analyze those too.
*/
import fs from 'fs/promises';
import path from 'path';
const allData = [];
// Read adversary pack data for average damage for attacks
const adversariesDirectory = path.join('src/packs/adversaries');
for (const basefile of await fs.readdir(adversariesDirectory)) {
if (!basefile.endsWith('.json')) continue;
const filepath = path.join(adversariesDirectory, basefile);
const data = JSON.parse(await fs.readFile(filepath, 'utf8'));
if (data?.type !== 'adversary' || data.system.type === 'social') continue;
allData.push({
name: data.name,
tier: data.system.tier,
adversaryType: data.system.type,
damage: parseDamage(data.system.attack.damage)
});
}
const adversaryTypes = new Set(allData.map(a => a.adversaryType));
for (const type of [...adversaryTypes].toSorted()) {
const perTier = Object.groupBy(
allData.filter(a => a.adversaryType === type),
a => a.tier
);
console.log(`${type} per Tier: ${[1, 2, 3, 4].map(t => perTier[t]?.length ?? 0).join(' ')}`);
}
const result = {
basic: compileData(allData.filter(d => d.adversaryType !== 'minion')),
solos_and_bruisers: compileData(allData.filter(d => ['solo', 'bruiser'].includes(d.adversaryType))),
leader_and_ranged: compileData(allData.filter(d => ['leader', 'ranged'].includes(d.adversaryType))),
minion: compileData(allData.filter(d => d.adversaryType === 'minion'))
};
console.log(result);
/** Compiles all data for an adversary type (or all entries) */
function compileData(entries) {
// Note: sorting numbers sorts by their string version by default
const results = {};
for (const tier of [1, 2, 3, 4]) {
const tierEntries = entries.filter(e => e.tier === tier);
const allDamage = removeOutliers(tierEntries.map(d => d.damage).sort((a, b) => a - b));
const mean = getMean(allDamage);
if (tier === 4) console.log(allDamage);
results[tier] = {
mean,
deviation: getStandardDeviation(allDamage, { mean })
};
}
return results;
}
function removeOutliers(data) {
if (data.length <= 4) return data;
const startIdx = Math.floor(data.length * 0.25);
const endIdx = Math.ceil(data.length * 0.75);
const iqrBound = (data[endIdx] - data[startIdx]) * 1.25;
return data.filter(d => d >= data[startIdx] - iqrBound && d <= data[endIdx] + iqrBound);
}
function getMedian(numbers) {
numbers = numbers.toSorted((a, b) => a - b);
const medianIdx = numbers.length / 2;
return medianIdx % 1 ? numbers[Math.floor(medianIdx)] : (numbers[medianIdx] + numbers[medianIdx - 1]) / 2;
}
function getMean(numbers) {
if (numbers.length === 0) return NaN;
return numbers.reduce((r, a) => r + a, 0) / numbers.length;
}
function getMedianAverageDeviation(numbers, { median }) {
const residuals = allDamage.map(d => Math.abs(d - median));
return getMedian(residuals);
}
function getStandardDeviation(numbers, { mean }) {
const deviations = numbers.map(r => r - mean);
return Math.sqrt(deviations.reduce((r, d) => r + d * d, 0) / (numbers.length - 1));
}
function parseDamage(damage) {
const formula = damage.parts
.filter(p => p.applyTo === 'hitPoints')
.map(p =>
p.value.custom.enabled
? p.value.custom.formula
: [p.value.flatMultiplier ? `${p.value.flatMultiplier}${p.value.dice}` : 0, p.value.bonus ?? 0]
.filter(p => !!p)
.join('+')
)
.join('+');
return getExpectedDamage(formula);
}
/**
* Given a simple flavor-less formula with only +/- operators, returns a list of damage partial terms.
* All subtracted terms become negative terms.
*/
function getExpectedDamage(formula) {
const terms = formula
.replace('+', ' + ')
.replace('-', ' - ')
.split(' ')
.map(t => t.trim());
let multiplier = 1;
return terms.reduce((total, term) => {
if (term === '-') {
multiplier = -1;
return total;
} else if (term === '+') {
return total;
}
const currentMultiplier = multiplier;
multiplier = 1;
const number = Number(term);
if (!Number.isNaN(number)) {
return total + currentMultiplier * number;
}
const dieMatch = term.match(/(\d+)d(\d+)/);
if (dieMatch) {
const numDice = Number(dieMatch[1]);
const faces = Number(dieMatch[2]);
return total + currentMultiplier * numDice * ((faces + 1) / 2);
}
throw Error(`Unexpected term ${term} in formula ${formula}`);
}, 0);
}