Merged with main

This commit is contained in:
WBHarry 2026-02-26 16:20:24 +01:00
commit 2123ae1965
177 changed files with 2677 additions and 657 deletions

3
.editorconfig Normal file
View file

@ -0,0 +1,3 @@
[*]
indent_size = 4
indent_style = spaces

View file

@ -242,6 +242,41 @@ Hooks.on('setup', () => {
systemEffect: true systemEffect: true
})) }))
]; ];
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, 'advantageSources', 'disadvantageSources']
};
CONFIG.Actor.trackableAttributes = {
character: {
bar: [...actorCommon.bar, 'resources.hitPoints', 'resources.hope'],
value: [
...actorCommon.value,
...traits,
...damageThresholds,
'proficiency',
'evasion',
'armorScore',
'scars',
'levelData.level.current'
]
},
adversary: {
bar: [...actorCommon.bar, 'resources.hitPoints'],
value: [...actorCommon.value, ...damageThresholds, 'criticalThreshold', 'difficulty']
},
companion: {
bar: [...actorCommon.bar],
value: [...actorCommon.value, 'evasion', 'levelData.level.current']
}
};
}); });
Hooks.on('ready', async () => { Hooks.on('ready', async () => {
@ -309,7 +344,7 @@ Hooks.on('chatMessage', (_, message) => {
? CONFIG.DH.ACTIONS.advantageState.disadvantage.value ? CONFIG.DH.ACTIONS.advantageState.disadvantage.value
: undefined; : undefined;
const difficulty = rollCommand.difficulty; const difficulty = rollCommand.difficulty;
const grantResources = Boolean(rollCommand.grantResources); const grantResources = rollCommand.grantResources;
const target = getCommandTarget({ allowNull: true }); const target = getCommandTarget({ allowNull: true });
const title = const title =
@ -385,10 +420,7 @@ const updateActorsRangeDependentEffects = async token => {
// Get required distance and special case 5 feet to test adjacency // Get required distance and special case 5 feet to test adjacency
const required = rangeMeasurement[range]; const required = rangeMeasurement[range];
const reverse = type === CONFIG.DH.GENERAL.rangeInclusion.outsideRange.id; const reverse = type === CONFIG.DH.GENERAL.rangeInclusion.outsideRange.id;
const inRange = const inRange = userTarget.distanceTo(token.object) <= required;
required === 5
? userTarget.isAdjacentWith(token.object)
: userTarget.distanceTo(token.object) <= required;
if (reverse ? inRange : !inRange) { if (reverse ? inRange : !inRange) {
enabledEffect = false; enabledEffect = false;
break; break;

View file

@ -192,6 +192,9 @@
}, },
"age": "Age", "age": "Age",
"backgroundQuestions": "Backgrounds", "backgroundQuestions": "Backgrounds",
"burden": {
"ignore": { "label": "Burden: Ignore", "hint": "Ignore burden rules" }
},
"companionFeatures": "Companion Features", "companionFeatures": "Companion Features",
"connections": "Connections", "connections": "Connections",
"contextMenu": { "contextMenu": {
@ -214,6 +217,12 @@
"maxEvasionBonus": "Max Evasion Increase", "maxEvasionBonus": "Max Evasion Increase",
"maxHPBonus": "Max HP Increase", "maxHPBonus": "Max HP Increase",
"pronouns": "Pronouns", "pronouns": "Pronouns",
"roll": {
"guaranteedCritical": {
"label": "Guaranteed Critical",
"hint": "Set to 1 to always roll a critical"
}
},
"story": { "story": {
"backgroundTitle": "Background", "backgroundTitle": "Background",
"characteristics": "Characteristics", "characteristics": "Characteristics",
@ -344,6 +353,12 @@
"requestSpotlight": "Request The Spotlight", "requestSpotlight": "Request The Spotlight",
"openCountdowns": "Countdowns" "openCountdowns": "Countdowns"
}, },
"CompendiumBrowserSettings": {
"title": "Enable Compendiums",
"enableSource": "Enable Source",
"disableSource": "Disable Source",
"worldCompendiums": "World Compendiums"
},
"ContextMenu": { "ContextMenu": {
"disableEffect": "Disable Effect", "disableEffect": "Disable Effect",
"enableEffect": "Enable Effect", "enableEffect": "Enable Effect",
@ -444,9 +459,13 @@
"name": "Clear Stress" "name": "Clear Stress"
}, },
"prepare": { "prepare": {
"description": "Describe how you are preparing for the next day's adventure, then gain a Hope. If you choose to Prepare with one or more members of your party, you may each take two Hope.", "description": "Describe how you are preparing for the next day's adventure, then gain a Hope.",
"name": "Prepare" "name": "Prepare"
}, },
"prepareWithFriends": {
"description": "You prepare with one or more members of your party, and you each gain 2 Hope.",
"name": "Prepare (together)"
},
"repairArmor": { "repairArmor": {
"description": "Describe how you spend time repairing your armor and clear all of its Armor Slots. You may also do this to an ally's armor instead.", "description": "Describe how you spend time repairing your armor and clear all of its Armor Slots. You may also do this to an ally's armor instead.",
"name": "Repair Armor" "name": "Repair Armor"
@ -477,7 +496,11 @@
}, },
"prepare": { "prepare": {
"name": "Prepare", "name": "Prepare",
"description": "Describe how you prepare yourself for the path ahead, then gain a Hope. If you choose to Prepare with one or more members of your party, you each gain 2 Hope." "description": "Describe how you prepare yourself for the path ahead, then gain a Hope."
},
"prepareWithFriends": {
"name": "Prepare (together)",
"description": "You prepare with one or more members of your party, and you each gain 2 Hope."
} }
}, },
"refreshable": { "refreshable": {
@ -1841,6 +1864,16 @@
"singular": "Adversary", "singular": "Adversary",
"plural": "Adversaries" "plural": "Adversaries"
}, },
"Attack": {
"hpDamageMultiplier": {
"label": "HP Damage Multiplier",
"hint": "Multiply any damage you deal by this number"
},
"hpDamageTakenMultiplier": {
"label": "HP Damage Taken Multiplier",
"hint": "Multiply any damage dealt to you by this number"
}
},
"Bonuses": { "Bonuses": {
"rest": { "rest": {
"downtimeAction": "Downtime Action", "downtimeAction": "Downtime Action",
@ -2025,16 +2058,40 @@
"reaction": "Reaction Roll" "reaction": "Reaction Roll"
}, },
"Rules": { "Rules": {
"conditionImmunities": {
"hidden": "Condition Immunity: Hidden",
"restrained": "Condition Immunity: Restrained",
"vulnerable": "Condition Immunity: Vulnerable"
},
"damageReduction": { "damageReduction": {
"disabledArmor": { "label": "Disabled Armorslots" },
"increasePerArmorMark": { "increasePerArmorMark": {
"label": "Damage Reduction per Armor Slot", "label": "Damage Reduction per Armor Slot",
"hint": "A used armor slot normally reduces damage by one step. This value increases the number of steps damage is reduced by." "hint": "A used armor slot normally reduces damage by one step. This value increases the number of steps damage is reduced by."
}, },
"magical": {
"label": "Daamge Reduction: Only Magical",
"hint": "Armor can only be used to reduce magical damage"
},
"maxArmorMarkedBonus": "Max Armor Used", "maxArmorMarkedBonus": "Max Armor Used",
"maxArmorMarkedStress": { "maxArmorMarkedStress": {
"label": "Max Armor Used With Stress", "label": "Max Armor Used With Stress",
"hint": "If this value is set you can use up to that much stress to spend additional Armor Marks beyond your normal maximum." "hint": "If this value is set you can use up to that much stress to spend additional Armor Marks beyond your normal maximum."
}, },
"reduceSeverity": {
"magical": {
"label": "Reduce Damage Severity: Magical",
"hint": "Lowers any magical damage received by the set amount of severity degrees"
},
"physical": {
"label": "Reduce Damage Severity: Physical",
"hint": "Lowers any physical damage received by the set amount of severity degrees"
}
},
"physical": {
"label": "Damage Reduction: Only Physical",
"hint": "Armor can only be used to reduce physical damage"
},
"stress": { "stress": {
"any": { "any": {
"label": "Stress Damage Reduction: Any", "label": "Stress Damage Reduction: Any",
@ -2052,6 +2109,12 @@
"label": "Stress Damage Reduction: Minor", "label": "Stress Damage Reduction: Minor",
"hint": "The cost in stress you can pay to reduce minor damage to none." "hint": "The cost in stress you can pay to reduce minor damage to none."
} }
},
"thresholdImmunities": {
"minor": {
"label": "Threshold Immunities: Minor",
"hint": "Automatically ignores minor damage when set to 1"
}
} }
}, },
"attack": { "attack": {
@ -2112,7 +2175,6 @@
"tier4": "tier 4", "tier4": "tier 4",
"domains": "Domains", "domains": "Domains",
"downtime": "Downtime", "downtime": "Downtime",
"itemFeatures": "Item Features",
"roll": "Roll", "roll": "Roll",
"rules": "Rules", "rules": "Rules",
"partyMembers": "Party Members", "partyMembers": "Party Members",
@ -2121,7 +2183,10 @@
"questions": "Questions", "questions": "Questions",
"configuration": "Configuration", "configuration": "Configuration",
"base": "Base", "base": "Base",
"triggers": "Triggers" "triggers": "Triggers",
"deathMoves": "Deathmoves",
"sources": "Sources",
"packs": "Packs"
}, },
"Tiers": { "Tiers": {
"singular": "Tier", "singular": "Tier",
@ -2145,6 +2210,7 @@
"armorSlots": "Armor Slots", "armorSlots": "Armor Slots",
"artistAttribution": "Artwork By: {artist}", "artistAttribution": "Artwork By: {artist}",
"attack": "Attack", "attack": "Attack",
"automation": "Automation",
"basics": "Basics", "basics": "Basics",
"bonus": "Bonus", "bonus": "Bonus",
"burden": "Burden", "burden": "Burden",
@ -2152,6 +2218,7 @@
"continue": "Continue", "continue": "Continue",
"criticalSuccess": "Critical Success", "criticalSuccess": "Critical Success",
"criticalShort": "Critical", "criticalShort": "Critical",
"currentLevel": "Current Level",
"custom": "Custom", "custom": "Custom",
"d20Roll": "D20 Roll", "d20Roll": "D20 Roll",
"damage": "Damage", "damage": "Damage",
@ -2261,6 +2328,7 @@
"single": "Target", "single": "Target",
"plural": "Targets" "plural": "Targets"
}, },
"thingsAndThing": "{things} and {thing}",
"title": "Title", "title": "Title",
"tokenSize": "Token Size", "tokenSize": "Token Size",
"total": "Total", "total": "Total",
@ -2299,7 +2367,8 @@
}, },
"Ancestry": { "Ancestry": {
"primaryFeature": "Primary Feature", "primaryFeature": "Primary Feature",
"secondaryFeature": "Secondary Feature" "secondaryFeature": "Secondary Feature",
"featuresLabel": "Ancestry Features"
}, },
"Armor": { "Armor": {
"baseScore": "Base Score", "baseScore": "Base Score",
@ -2352,7 +2421,12 @@
"evolvedImagePlaceholder": "The image for the form selected for evolution will be used" "evolvedImagePlaceholder": "The image for the form selected for evolution will be used"
}, },
"Class": { "Class": {
"startingEvasionScore": "Starting Evasion Score",
"startingHitPoints": "Starting Hit Points",
"classItems": "Class Items",
"hopeFeatureLabel": "{class}'s Hope Feature",
"hopeFeatures": "Hope Features", "hopeFeatures": "Hope Features",
"classFeature": "Class Feature",
"classFeatures": "Class Features", "classFeatures": "Class Features",
"guide": { "guide": {
"suggestedEquipment": "Suggested Equipments", "suggestedEquipment": "Suggested Equipments",
@ -2365,6 +2439,9 @@
} }
} }
}, },
"Community": {
"featuresLabel": "Community Feature"
},
"Consumable": { "Consumable": {
"consumeOnUse": "Consume On Use", "consumeOnUse": "Consume On Use",
"destroyOnEmpty": "Destroy On Empty" "destroyOnEmpty": "Destroy On Empty"
@ -2380,7 +2457,11 @@
"masteryTitle": "Mastery" "masteryTitle": "Mastery"
}, },
"Subclass": { "Subclass": {
"spellcastingTrait": "Spellcasting Trait" "spellcastingTrait": "Spellcasting Trait",
"spellcastTrait": "Spellcast Trait",
"foundationFeatures": "Foundation Features",
"specializationFeature": "Specialization Feature",
"masteryFeature": "Mastery Feature"
}, },
"Weapon": { "Weapon": {
"weaponType": "Weapon Type", "weaponType": "Weapon Type",
@ -2409,6 +2490,14 @@
"hideAttribution": { "hideAttribution": {
"label": "Hide Attribution" "label": "Hide Attribution"
}, },
"showTokenDistance": {
"label": "Show Token Distance on Hover",
"choices": {
"always": "Always",
"encounters": "Encounters",
"never": "Never"
}
},
"expandedTitle": "Auto-expand Descriptions", "expandedTitle": "Auto-expand Descriptions",
"extendCharacterDescriptions": { "extendCharacterDescriptions": {
"label": "Characters" "label": "Characters"
@ -2551,6 +2640,8 @@
"resetMovesTitle": "Reset {type} Downtime Moves", "resetMovesTitle": "Reset {type} Downtime Moves",
"resetItemFeaturesTitle": "Reset {type}", "resetItemFeaturesTitle": "Reset {type}",
"resetMovesText": "Are you sure you want to reset?", "resetMovesText": "Are you sure you want to reset?",
"deleteItemTitle": "Delete Homebrew Item",
"deleteItemText": "Are you sure you want to delete the item?",
"FIELDS": { "FIELDS": {
"maxFear": { "label": "Max Fear" }, "maxFear": { "label": "Max Fear" },
"maxHope": { "label": "Max Hope" }, "maxHope": { "label": "Max Hope" },
@ -2782,6 +2873,7 @@
"ItemBrowser": { "ItemBrowser": {
"title": "Daggerheart Compendium Browser", "title": "Daggerheart Compendium Browser",
"hint": "Select a Folder in sidebar to start browsing through the compendium", "hint": "Select a Folder in sidebar to start browsing through the compendium",
"browserSettings": "Browser Settings",
"searchPlaceholder": "Search...", "searchPlaceholder": "Search...",
"columnName": "Name", "columnName": "Name",
"tooltipFilters": "Filters", "tooltipFilters": "Filters",
@ -2906,7 +2998,9 @@
"tier": "Tier {tier} {type}", "tier": "Tier {tier} {type}",
"character": "Level {level} Character", "character": "Level {level} Character",
"companion": "Level {level} - {partner}", "companion": "Level {level} - {partner}",
"companionNoPartner": "No Partner" "companionNoPartner": "No Partner",
"duplicateToNewTier": "Duplicate to New Tier",
"pickTierTitle": "Pick a new tier for this adversary"
}, },
"daggerheartMenu": { "daggerheartMenu": {
"title": "Daggerheart Menu", "title": "Daggerheart Menu",
@ -2938,7 +3032,7 @@
"rulesOn": "Rules On", "rulesOn": "Rules On",
"rulesOff": "Rules Off", "rulesOff": "Rules Off",
"remainingUses": "Uses refresh on {type}", "remainingUses": "Uses refresh on {type}",
"rightClickExtand": "Right-Click to extand", "rightClickExtend": "Right-Click to extend",
"companionPartnerLevelBlock": "The companion needs an assigned partner to level up.", "companionPartnerLevelBlock": "The companion needs an assigned partner to level up.",
"configureAttribution": "Configure Attribution", "configureAttribution": "Configure Attribution",
"deleteItem": "Delete Item", "deleteItem": "Delete Item",

View file

@ -0,0 +1,143 @@
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export default class CompendiumBrowserSettings extends HandlebarsApplicationMixin(ApplicationV2) {
constructor() {
super();
this.browserSettings = game.settings
.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.CompendiumBrowserSettings)
.toObject();
}
static DEFAULT_OPTIONS = {
tag: 'div',
classes: ['daggerheart', 'dialog', 'dh-style', 'views', 'compendium-brower-settings'],
window: {
icon: 'fa-solid fa-book',
title: 'DAGGERHEART.APPLICATIONS.CompendiumBrowserSettings.title'
},
position: {
width: 500
},
actions: {
toggleSource: CompendiumBrowserSettings.#toggleSource,
finish: CompendiumBrowserSettings.#finish
}
};
/** @override */
static PARTS = {
packs: {
id: 'packs',
template: 'systems/daggerheart/templates/dialogs/compendiumBrowserSettingsDialog/packs.hbs'
},
footer: { template: 'systems/daggerheart/templates/dialogs/compendiumBrowserSettingsDialog/footer.hbs' }
};
static #browserPackTypes = ['Actor', 'Item'];
_attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options);
for (const element of htmlElement.querySelectorAll('.pack-checkbox'))
element.addEventListener('change', this.toggleTypedPack.bind(this));
}
/**@inheritdoc */
async _prepareContext(_options) {
const context = await super._prepareContext(_options);
const excludedSourceData = this.browserSettings.excludedSources;
const excludedPackData = this.browserSettings.excludedPacks;
context.typePackCollections = game.packs.reduce((acc, pack) => {
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 ??
(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: [] };
const checked = !excludedPackData[id] || !excludedPackData[id].excludedDocumentTypes.includes(type);
acc[type].sources[packageName].packs.push({
pack: id,
type,
label: id === game.system.id ? game.system.title : game.i18n.localize(label),
checked: checked
});
return acc;
}, {});
return context;
}
static #toggleSource(event, button) {
event.stopPropagation();
const { type, source } = button.dataset;
const currentlyExcluded = this.browserSettings.excludedSources[source]
? this.browserSettings.excludedSources[source].excludedDocumentTypes.includes(type)
: false;
if (!this.browserSettings.excludedSources[source])
this.browserSettings.excludedSources[source] = { excludedDocumentTypes: [] };
this.browserSettings.excludedSources[source].excludedDocumentTypes = currentlyExcluded
? this.browserSettings.excludedSources[source].excludedDocumentTypes.filter(x => x !== type)
: [...(this.browserSettings.excludedSources[source]?.excludedDocumentTypes ?? []), type];
const toggleIcon = button.querySelector('a > i');
toggleIcon.classList.toggle('fa-toggle-off');
toggleIcon.classList.toggle('fa-toggle-on');
button.closest('.source-container').querySelector('.checks-container').classList.toggle('collapsed');
}
toggleTypedPack(event) {
event.stopPropagation();
const { type, pack } = event.target.dataset;
const currentlyExcluded = this.browserSettings.excludedPacks[pack]
? this.browserSettings.excludedPacks[pack].excludedDocumentTypes.includes(type)
: false;
if (!this.browserSettings.excludedPacks[pack])
this.browserSettings.excludedPacks[pack] = { excludedDocumentTypes: [] };
this.browserSettings.excludedPacks[pack].excludedDocumentTypes = currentlyExcluded
? this.browserSettings.excludedPacks[pack].excludedDocumentTypes.filter(x => x !== type)
: [...(this.browserSettings.excludedPacks[pack]?.excludedDocumentTypes ?? []), type];
this.render();
}
static async #finish() {
const settings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.CompendiumBrowserSettings);
await settings.updateSource(this.browserSettings);
await game.settings.set(
CONFIG.DH.id,
CONFIG.DH.SETTINGS.gameSettings.CompendiumBrowserSettings,
settings.toObject()
);
this.updated = true;
this.close();
}
static async configure() {
return new Promise(resolve => {
const app = new this();
app.addEventListener('close', () => resolve(app.updated), { once: true });
app.render({ force: true });
});
}
}

View file

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

View file

@ -54,7 +54,11 @@ export default class AttributionDialog extends HandlebarsApplicationMixin(Applic
const after = label.slice(matchIndex + search.length, label.length); const after = label.slice(matchIndex + search.length, label.length);
const element = document.createElement('li'); const element = document.createElement('li');
element.innerHTML = `${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`; element.innerHTML =
`${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`.replaceAll(
' ',
'&nbsp;'
);
if (item.hint) { if (item.hint) {
element.dataset.tooltip = game.i18n.localize(item.hint); element.dataset.tooltip = game.i18n.localize(item.hint);
} }

View file

@ -165,6 +165,7 @@ export default class D20RollDialog extends HandlebarsApplicationMixin(Applicatio
} }
if (rest.hasOwnProperty('trait')) { if (rest.hasOwnProperty('trait')) {
this.config.roll.trait = rest.trait; this.config.roll.trait = rest.trait;
if (!this.config.source.item)
this.config.title = game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', { this.config.title = game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', {
ability: game.i18n.localize(abilities[this.config.roll.trait]?.label) ability: game.i18n.localize(abilities[this.config.roll.trait]?.label)
}); });

View file

@ -43,7 +43,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
return context; return context;
} }
async handleAvoidDeath() { async handleAvoidDeath(useAutomation) {
const target = this.actor.uuid; const target = this.actor.uuid;
const config = await enrichedFateRoll({ const config = await enrichedFateRoll({
target, target,
@ -53,6 +53,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
}); });
if (!config.roll.fate) return; if (!config.roll.fate) return;
if (!useAutomation) return '';
let returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar'); let returnMessage = game.i18n.localize('DAGGERHEART.UI.Chat.deathMove.avoidScar');
if (config.roll.fate.value <= this.actor.system.levelData.level.current) { if (config.roll.fate.value <= this.actor.system.levelData.level.current) {
@ -75,7 +76,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
return returnMessage; return returnMessage;
} }
async handleRiskItAll() { async handleRiskItAll(useAutomation) {
const config = await enrichedDualityRoll({ const config = await enrichedDualityRoll({
reaction: true, reaction: true,
traitValue: null, traitValue: null,
@ -90,6 +91,7 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
}); });
if (!config.roll.result) return; if (!config.roll.result) return;
if (!useAutomation) return '';
const clearAllStressAndHitpointsUpdates = [ const clearAllStressAndHitpointsUpdates = [
{ key: 'hitPoints', clear: true }, { key: 'hitPoints', clear: true },
@ -128,7 +130,9 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
return chatMessage; return chatMessage;
} }
async handleBlazeOfGlory() { async handleBlazeOfGlory(useAutomation) {
if (!useAutomation) return '';
this.actor.createEmbeddedDocuments('ActiveEffect', [ this.actor.createEmbeddedDocuments('ActiveEffect', [
{ {
name: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.name'), name: game.i18n.localize('DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.name'),
@ -160,19 +164,23 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
let result = ''; let result = '';
const deathMoveAutomation = game.settings.get(
CONFIG.DH.id,
CONFIG.DH.SETTINGS.gameSettings.Automation
).deathMoveAutomation;
if (CONFIG.DH.GENERAL.deathMoves.blazeOfGlory === this.selectedMove) { if (CONFIG.DH.GENERAL.deathMoves.blazeOfGlory === this.selectedMove) {
result = await this.handleBlazeOfGlory(); result = await this.handleBlazeOfGlory(deathMoveAutomation.blazeOfGlory);
} }
if (CONFIG.DH.GENERAL.deathMoves.avoidDeath === this.selectedMove) { if (CONFIG.DH.GENERAL.deathMoves.avoidDeath === this.selectedMove) {
result = await this.handleAvoidDeath(); result = await this.handleAvoidDeath(deathMoveAutomation.avoidDeath);
} }
if (CONFIG.DH.GENERAL.deathMoves.riskItAll === this.selectedMove) { if (CONFIG.DH.GENERAL.deathMoves.riskItAll === this.selectedMove) {
result = await this.handleRiskItAll(); result = await this.handleRiskItAll(deathMoveAutomation.riskItAll);
} }
if (!result) return; if (result === undefined) return;
const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance) const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance)
.expandRollMessage?.desc; .expandRollMessage?.desc;
@ -192,7 +200,6 @@ export default class DhDeathMove extends HandlebarsApplicationMixin(ApplicationV
description: game.i18n.localize(this.selectedMove.description), description: game.i18n.localize(this.selectedMove.description),
result: result, result: result,
open: autoExpandDescription ? 'open' : '', open: autoExpandDescription ? 'open' : '',
chevron: autoExpandDescription ? 'fa-chevron-up' : 'fa-chevron-down',
showRiskItAllButton: this.showRiskItAllButton, showRiskItAllButton: this.showRiskItAllButton,
riskItAllButtonLabel: this.riskItAllButtonLabel, riskItAllButtonLabel: this.riskItAllButtonLabel,
riskItAllHope: this.riskItAllHope 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.testUserPermission(game.user, 'LIMITED'))
.filter(x => x.uuid !== this.actor.uuid); .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 cls = getDocumentClass('ChatMessage');
const msg = { const msg = {
user: game.user.id, user: game.user.id,
@ -216,7 +219,8 @@ export default class DhpDowntime extends HandlebarsApplicationMixin(ApplicationV
actor: { name: this.actor.name, img: this.actor.img }, actor: { name: this.actor.name, img: this.actor.img },
moves: moves, moves: moves,
characters: characters, characters: characters,
selfId: this.actor.uuid selfId: this.actor.uuid,
open: autoExpandDescription ? 'open' : ''
} }
), ),
flags: { flags: {

View file

@ -70,7 +70,11 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
element.appendChild(img); element.appendChild(img);
const label = document.createElement('span'); const label = document.createElement('span');
label.innerHTML = `${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`; label.innerHTML =
`${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`.replaceAll(
' ',
'&nbsp;'
);
element.appendChild(label); element.appendChild(label);
return element; return element;
@ -119,7 +123,11 @@ export default class GroupRollDialog extends HandlebarsApplicationMixin(Applicat
element.appendChild(img); element.appendChild(img);
const label = document.createElement('span'); const label = document.createElement('span');
label.innerHTML = `${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`; label.innerHTML =
`${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`.replaceAll(
' ',
'&nbsp;'
);
element.appendChild(label); element.appendChild(label);
return element; return element;

View file

@ -103,7 +103,11 @@ export default class DhSceneConfigSettings extends foundry.applications.sheets.S
/** @override */ /** @override */
async _processSubmitData(event, form, submitData, options) { async _processSubmitData(event, form, submitData, options) {
submitData.flags.daggerheart = this.daggerheartFlag.toObject(); if (!submitData.flags) submitData.flags = {};
submitData.flags.daggerheart = foundry.utils.mergeObject(
this.daggerheartFlag.toObject(),
submitData.flags.daggerheart
);
submitData.flags.daggerheart.sceneEnvironments = submitData.flags.daggerheart.sceneEnvironments.filter(x => submitData.flags.daggerheart.sceneEnvironments = submitData.flags.daggerheart.sceneEnvironments.filter(x =>
foundry.utils.fromUuidSync(x) foundry.utils.fromUuidSync(x)
); );

View file

@ -34,7 +34,7 @@ export default class DhAutomationSettings extends HandlebarsApplicationMixin(App
tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' }, tabs: { template: 'systems/daggerheart/templates/sheets/global/tabs/tab-navigation.hbs' },
header: { template: 'systems/daggerheart/templates/settings/automation-settings/header.hbs' }, header: { template: 'systems/daggerheart/templates/settings/automation-settings/header.hbs' },
general: { template: 'systems/daggerheart/templates/settings/automation-settings/general.hbs' }, general: { template: 'systems/daggerheart/templates/settings/automation-settings/general.hbs' },
rules: { template: 'systems/daggerheart/templates/settings/automation-settings/rules.hbs' }, rules: { template: 'systems/daggerheart/templates/settings/automation-settings/deathMoves.hbs' },
roll: { template: 'systems/daggerheart/templates/settings/automation-settings/roll.hbs' }, roll: { template: 'systems/daggerheart/templates/settings/automation-settings/roll.hbs' },
footer: { template: 'systems/daggerheart/templates/settings/automation-settings/footer.hbs' } footer: { template: 'systems/daggerheart/templates/settings/automation-settings/footer.hbs' }
}; };
@ -42,7 +42,7 @@ export default class DhAutomationSettings extends HandlebarsApplicationMixin(App
/** @inheritdoc */ /** @inheritdoc */
static TABS = { static TABS = {
main: { main: {
tabs: [{ id: 'general' }, { id: 'rules' }, { id: 'roll' }], tabs: [{ id: 'general' }, { id: 'deathMoves' }, { id: 'roll' }],
initial: 'general', initial: 'general',
labelPrefix: 'DAGGERHEART.GENERAL.Tabs' labelPrefix: 'DAGGERHEART.GENERAL.Tabs'
} }

View file

@ -103,6 +103,12 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
? { id: this.selected.adversaryType, ...this.settings.adversaryTypes[this.selected.adversaryType] } ? { id: this.selected.adversaryType, ...this.settings.adversaryTypes[this.selected.adversaryType] }
: null; : null;
break; break;
case 'downtime':
context.restOptions = {
shortRest: CONFIG.DH.GENERAL.defaultRestOptions.shortRest(),
longRest: CONFIG.DH.GENERAL.defaultRestOptions.longRest()
};
break;
} }
return context; return context;
@ -165,7 +171,8 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
name: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.newDowntimeMove'), name: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.newDowntimeMove'),
img: 'icons/magic/life/cross-worn-green.webp', img: 'icons/magic/life/cross-worn-green.webp',
description: '', description: '',
actions: [] actions: [],
effects: []
} }
}); });
} else if (['armorFeatures', 'weaponFeatures'].includes(type)) { } else if (['armorFeatures', 'weaponFeatures'].includes(type)) {
@ -224,6 +231,15 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
} }
static async removeItem(_, target) { static async removeItem(_, target) {
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: {
title: game.i18n.localize(`DAGGERHEART.SETTINGS.Homebrew.deleteItemTitle`)
},
content: game.i18n.localize('DAGGERHEART.SETTINGS.Homebrew.deleteItemText')
});
if (!confirmed) return;
const { type, id } = target.dataset; const { type, id } = target.dataset;
const isDowntime = ['shortRest', 'longRest'].includes(type); const isDowntime = ['shortRest', 'longRest'].includes(type);
const path = isDowntime ? `restMoves.${type}.moves` : `itemFeatures.${type}`; const path = isDowntime ? `restMoves.${type}.moves` : `itemFeatures.${type}`;

View file

@ -314,7 +314,7 @@ export default class DHActionBaseConfig extends DaggerheartSheet(ApplicationV2)
const index = Number.parseInt(button.dataset.index); const index = Number.parseInt(button.dataset.index);
const toggle = (element, codeMirror) => { const toggle = (element, codeMirror) => {
codeMirror.classList.toggle('revealed'); codeMirror.classList.toggle('revealed');
const button = element.querySelector('a > i'); const button = element.querySelector('.expand-trigger > i');
button.classList.toggle('fa-angle-up'); button.classList.toggle('fa-angle-up');
button.classList.toggle('fa-angle-down'); button.classList.toggle('fa-angle-down');
}; };

View file

@ -4,20 +4,60 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
constructor(options) { constructor(options) {
super(options); super(options);
const ignoredActorKeys = ['config', 'DhEnvironment']; const ignoredActorKeys = ['config', 'DhEnvironment', 'DhParty'];
this.changeChoices = Object.keys(game.system.api.models.actors).reduce((acc, key) => {
if (!ignoredActorKeys.includes(key)) { const getAllLeaves = (root, group, parentPath = '') => {
const model = game.system.api.models.actors[key]; const leaves = [];
const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model); const rootKey = `${parentPath ? `${parentPath}.` : ''}${root.name}`;
// As per DHToken._getTrackedAttributesFromSchema, attributes.bar have a max version as well. for (const field of Object.values(root.fields)) {
const maxAttributes = attributes.bar.map(x => [...x, 'max']); if (field instanceof foundry.data.fields.SchemaField)
attributes.value.push(...maxAttributes); leaves.push(...getAllLeaves(field, group, rootKey));
const group = game.i18n.localize(model.metadata.label); else
const choices = CONFIG.Token.documentClass leaves.push({
.getTrackedAttributeChoices(attributes, model) value: `${rootKey}.${field.name}`,
.map(x => ({ ...x, group: group })); label: game.i18n.localize(field.label),
acc.push(...choices); hint: game.i18n.localize(field.hint),
group
});
} }
return leaves;
};
this.changeChoices = Object.keys(game.system.api.models.actors).reduce((acc, key) => {
if (ignoredActorKeys.includes(key)) return acc;
const model = game.system.api.models.actors[key];
const group = game.i18n.localize(model.metadata.label);
const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model.metadata.type);
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`;
return { value: joined, ...getTranslations(joined), group };
});
const values = attributes.value.flatMap(x => {
const joined = x.join('.');
return { value: joined, ...getTranslations(joined), group };
});
const bonuses = getAllLeaves(model.schema.fields.bonuses, group);
const rules = getAllLeaves(model.schema.fields.rules, group);
acc.push(...bars, ...values, ...rules, ...bonuses);
return acc; return acc;
}, []); }, []);
} }
@ -68,14 +108,18 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
}, },
render: function (item, search) { render: function (item, search) {
const label = game.i18n.localize(item.label); const label = game.i18n.localize(item.label);
const matchIndex = label.toLowerCase().indexOf(search); const matchIndex = label.toLowerCase().indexOf(search.toLowerCase());
const beforeText = label.slice(0, matchIndex); const beforeText = label.slice(0, matchIndex);
const matchText = label.slice(matchIndex, matchIndex + search.length); const matchText = label.slice(matchIndex, matchIndex + search.length);
const after = label.slice(matchIndex + search.length, label.length); const after = label.slice(matchIndex + search.length, label.length);
const element = document.createElement('li'); const element = document.createElement('li');
element.innerHTML = `${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`; element.innerHTML =
`${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`.replaceAll(
' ',
'&nbsp;'
);
if (item.hint) { if (item.hint) {
element.dataset.tooltip = game.i18n.localize(item.hint); element.dataset.tooltip = game.i18n.localize(item.hint);
} }

View file

@ -103,7 +103,11 @@ export default class SettingActiveEffectConfig extends HandlebarsApplicationMixi
const after = label.slice(matchIndex + search.length, label.length); const after = label.slice(matchIndex + search.length, label.length);
const element = document.createElement('li'); const element = document.createElement('li');
element.innerHTML = `${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`; element.innerHTML =
`${beforeText}${matchText ? `<strong>${matchText}</strong>` : ''}${after}`.replaceAll(
' ',
'&nbsp;'
);
if (item.hint) { if (item.hint) {
element.dataset.tooltip = game.i18n.localize(item.hint); element.dataset.tooltip = game.i18n.localize(item.hint);
} }

View file

@ -431,18 +431,18 @@ export default function DHApplicationMixin(Base) {
{ {
name: 'disableEffect', name: 'disableEffect',
icon: 'fa-solid fa-lightbulb', icon: 'fa-solid fa-lightbulb',
condition: target => { condition: element => {
const doc = getDocFromElementSync(target); const target = element.closest('[data-item-uuid]');
return doc && !doc.disabled; return !target.dataset.disabled && target.dataset.itemType !== 'beastform';
}, },
callback: async target => (await getDocFromElement(target)).update({ disabled: true }) callback: async target => (await getDocFromElement(target)).update({ disabled: true })
}, },
{ {
name: 'enableEffect', name: 'enableEffect',
icon: 'fa-regular fa-lightbulb', icon: 'fa-regular fa-lightbulb',
condition: target => { condition: element => {
const doc = getDocFromElementSync(target); const target = element.closest('[data-item-uuid]');
return doc && doc.disabled; return target.dataset.disabled && target.dataset.itemType !== 'beastform';
}, },
callback: async target => (await getDocFromElement(target)).update({ disabled: false }) callback: async target => (await getDocFromElement(target)).update({ disabled: false })
} }
@ -570,6 +570,10 @@ export default function DHApplicationMixin(Base) {
options.push({ options.push({
name: 'CONTROLS.CommonDelete', name: 'CONTROLS.CommonDelete',
icon: 'fa-solid fa-trash', icon: 'fa-solid fa-trash',
condition: element => {
const target = element.closest('[data-item-uuid]');
return target.dataset.itemType !== 'beastform';
},
callback: async (target, event) => { callback: async (target, event) => {
const doc = await getDocFromElement(target); const doc = await getDocFromElement(target);
if (event.shiftKey) return doc.delete(); if (event.shiftKey) return doc.delete();

View file

@ -1,7 +1,6 @@
export default function ItemAttachmentSheet(Base) { export default function ItemAttachmentSheet(Base) {
return class extends Base { return class extends Base {
static DEFAULT_OPTIONS = { static DEFAULT_OPTIONS = {
...super.DEFAULT_OPTIONS,
dragDrop: [ dragDrop: [
...(super.DEFAULT_OPTIONS.dragDrop || []), ...(super.DEFAULT_OPTIONS.dragDrop || []),
{ dragSelector: null, dropSelector: '.attachments-section' } { dragSelector: null, dropSelector: '.attachments-section' }

View file

@ -43,4 +43,54 @@ export default class DhActorDirectory extends foundry.applications.sidebar.tabs.
event.dataTransfer.setDragImage(preview, w / 2, h / 2); 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: { position: {
width: 222, width: 222,
height: 222 height: 222
// top: "200px",
// left: "120px"
} }
}; };
@ -66,7 +64,7 @@ export default class FearTracker extends HandlebarsApplicationMixin(ApplicationV
max = this.maxFear, max = this.maxFear,
percent = (current / max) * 100, percent = (current / max) * 100,
isGM = game.user.isGM; isGM = game.user.isGM;
// Return the data for rendering
return { display, current, max, percent, isGM }; return { display, current, max, percent, isGM };
} }

View file

@ -1,3 +1,5 @@
import { RefreshType, socketEvent } from '../../systemRegistration/socket.mjs';
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api; const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
/** /**
@ -17,6 +19,15 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
this.config = CONFIG.DH.ITEMBROWSER.compendiumConfig; this.config = CONFIG.DH.ITEMBROWSER.compendiumConfig;
this.presets = {}; this.presets = {};
this.compendiumBrowserTypeKey = 'compendiumBrowserDefault'; this.compendiumBrowserTypeKey = 'compendiumBrowserDefault';
this.setupHooks = Hooks.on(socketEvent.Refresh, ({ refreshType }) => {
if (refreshType === RefreshType.CompendiumBrowser) {
if (this.rendered) {
this.render();
this.loadItems();
}
}
});
} }
/** @inheritDoc */ /** @inheritDoc */
@ -35,7 +46,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
selectFolder: this.selectFolder, selectFolder: this.selectFolder,
expandContent: this.expandContent, expandContent: this.expandContent,
resetFilters: this.resetFilters, resetFilters: this.resetFilters,
sortList: this.sortList sortList: this.sortList,
openSettings: this.openSettings
}, },
position: { position: {
left: 100, left: 100,
@ -157,6 +169,8 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
context.formatChoices = this.formatChoices; context.formatChoices = this.formatChoices;
context.items = this.items; context.items = this.items;
context.presets = this.presets; context.presets = this.presets;
context.isGM = game.user.isGM;
return context; return context;
} }
@ -214,6 +228,10 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
loadItems() { loadItems() {
let loadTimeout = this.toggleLoader(true); let loadTimeout = this.toggleLoader(true);
const browserSettings = game.settings.get(
CONFIG.DH.id,
CONFIG.DH.SETTINGS.gameSettings.CompendiumBrowserSettings
);
const promises = []; const promises = [];
game.packs.forEach(pack => { game.packs.forEach(pack => {
@ -227,7 +245,7 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
Promise.all(promises).then(async result => { Promise.all(promises).then(async result => {
this.items = ItemBrowser.sortBy( this.items = ItemBrowser.sortBy(
result.flatMap(r => r), result.flatMap(r => r).filter(r => !browserSettings.isEntryExcluded.bind(browserSettings)(r)),
'name' 'name'
); );
@ -512,6 +530,22 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
itemListContainer.replaceChildren(...newOrder); itemListContainer.replaceChildren(...newOrder);
} }
static async openSettings() {
const settingsUpdated = await game.system.api.applications.dialogs.CompendiumBrowserSettingsDialog.configure();
if (settingsUpdated) {
if (this.rendered) {
this.render();
this.loadItems();
}
await game.socket.emit(`system.${CONFIG.DH.id}`, {
action: socketEvent.Refresh,
data: {
refreshType: RefreshType.CompendiumBrowser
}
});
}
}
_createDragProcess() { _createDragProcess() {
new foundry.applications.ux.DragDrop.implementation({ new foundry.applications.ux.DragDrop.implementation({
dragSelector: '.item-container', dragSelector: '.item-container',
@ -571,4 +605,9 @@ export class ItemBrowser extends HandlebarsApplicationMixin(ApplicationV2) {
headerActions.append(button); headerActions.append(button);
} }
} }
async close(options = {}) {
Hooks.off(socketEvent.Refresh, this.setupHooks);
await super.close(options);
}
} }

View file

@ -63,7 +63,8 @@ export default class DhSceneNavigation extends foundry.applications.ui.SceneNavi
if (scene.flags.daggerheart.sceneEnvironments[0] !== environment.uuid) { if (scene.flags.daggerheart.sceneEnvironments[0] !== environment.uuid) {
const newEnvironments = scene.flags.daggerheart.sceneEnvironments; const newEnvironments = scene.flags.daggerheart.sceneEnvironments;
const newFirst = newEnvironments.splice( const newFirst = newEnvironments.splice(
newEnvironments.findIndex(x => x === environment.uuid) newEnvironments.findIndex(x => x === environment.uuid),
1
)[0]; )[0];
newEnvironments.unshift(newFirst); newEnvironments.unshift(newFirst);
emitAsGM( emitAsGM(

View file

@ -18,8 +18,9 @@ export default class DhMeasuredTemplate extends foundry.canvas.placeables.Measur
static getRangeLabels(distanceValue, settings) { static getRangeLabels(distanceValue, settings) {
let result = { distance: distanceValue, units: '' }; let result = { distance: distanceValue, units: '' };
const sceneRangeMeasurement = canvas.scene.flags.daggerheart?.rangeMeasurement; if (!settings.enabled) return result;
const sceneRangeMeasurement = canvas.scene.flags.daggerheart?.rangeMeasurement;
const { disable, custom } = CONFIG.DH.GENERAL.sceneRangeMeasurementSetting; const { disable, custom } = CONFIG.DH.GENERAL.sceneRangeMeasurementSetting;
if (sceneRangeMeasurement?.setting === disable.id) { if (sceneRangeMeasurement?.setting === disable.id) {
result.distance = distanceValue; result.distance = distanceValue;
@ -27,31 +28,9 @@ export default class DhMeasuredTemplate extends foundry.canvas.placeables.Measur
return result; return result;
} }
const melee = sceneRangeMeasurement?.setting === custom.id ? sceneRangeMeasurement.melee : settings.melee; const ranges = sceneRangeMeasurement?.setting === custom.id ? sceneRangeMeasurement : settings;
const veryClose = const distanceKey = ['melee', 'veryClose', 'close', 'far'].find(r => ranges[r] >= distanceValue);
sceneRangeMeasurement?.setting === custom.id ? sceneRangeMeasurement.veryClose : settings.veryClose; result.distance = game.i18n.localize(`DAGGERHEART.CONFIG.Range.${distanceKey ?? 'veryFar'}.name`);
const close = sceneRangeMeasurement?.setting === custom.id ? sceneRangeMeasurement.close : settings.close;
const far = sceneRangeMeasurement?.setting === custom.id ? sceneRangeMeasurement.far : settings.far;
if (distanceValue <= melee) {
result.distance = game.i18n.localize('DAGGERHEART.CONFIG.Range.melee.name');
return result;
}
if (distanceValue <= veryClose) {
result.distance = game.i18n.localize('DAGGERHEART.CONFIG.Range.veryClose.name');
return result;
}
if (distanceValue <= close) {
result.distance = game.i18n.localize('DAGGERHEART.CONFIG.Range.close.name');
return result;
}
if (distanceValue <= far) {
result.distance = game.i18n.localize('DAGGERHEART.CONFIG.Range.far.name');
return result;
}
if (distanceValue > far) {
result.distance = game.i18n.localize('DAGGERHEART.CONFIG.Range.veryFar.name');
}
return result; return result;
} }
} }

View file

@ -1,3 +1,5 @@
import DhMeasuredTemplate from './measuredTemplate.mjs';
export default class DhTokenPlaceable extends foundry.canvas.placeables.Token { export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
/** @inheritdoc */ /** @inheritdoc */
async _draw(options) { async _draw(options) {
@ -52,30 +54,111 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
if (this === target) return 0; if (this === target) return 0;
const originPoint = this.center; const originPoint = this.center;
const destinationPoint = target.center; const targetPoint = target.center;
const thisBounds = this.bounds;
const targetBounds = target.bounds;
const adjacencyBuffer = canvas.grid.distance * 1.75; // handles diagonals with one square elevation difference
// Figure out the elevation difference.
// This intends to return "grid distance" for adjacent ones, so we add that number if not overlapping.
const sizePerUnit = canvas.grid.size / canvas.grid.distance;
const thisHeight = Math.max(thisBounds.width, thisBounds.height) / sizePerUnit;
const targetHeight = Math.max(targetBounds.width, targetBounds.height) / sizePerUnit;
const thisElevation = [this.document.elevation, this.document.elevation + thisHeight];
const targetElevation = [target.document.elevation, target.document.elevation + targetHeight];
const isSameAltitude =
thisElevation[0] < targetElevation[1] && // bottom of this must be at or below the top of target
thisElevation[1] > targetElevation[0]; // top of this must be at or above the bottom of target
const [lower, higher] = [targetElevation, thisElevation].sort((a, b) => a[1] - b[1]);
const elevation = isSameAltitude ? 0 : higher[0] - lower[1] + canvas.grid.distance;
// Compute for gridless. This version returns circular edge to edge + grid distance, // Compute for gridless. This version returns circular edge to edge + grid distance,
// so that tokens that are touching return 5. // so that tokens that are touching return 5.
if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) { if (canvas.grid.type === CONST.GRID_TYPES.GRIDLESS) {
const boundsCorrection = canvas.grid.distance / canvas.grid.size; const boundsCorrection = canvas.grid.distance / canvas.grid.size;
const originRadius = (this.bounds.width * boundsCorrection) / 2; const originRadius = (thisBounds.width * boundsCorrection) / 2;
const targetRadius = (target.bounds.width * boundsCorrection) / 2; const targetRadius = (targetBounds.width * boundsCorrection) / 2;
const distance = canvas.grid.measurePath([originPoint, destinationPoint]).distance; const measuredDistance = canvas.grid.measurePath([
return distance - originRadius - targetRadius + canvas.grid.distance; { ...originPoint, elevation: 0 },
{ ...targetPoint, elevation }
]).distance;
const distance = Math.floor(measuredDistance - originRadius - targetRadius + canvas.grid.distance);
return Math.min(distance, distance > adjacencyBuffer ? Infinity : canvas.grid.distance);
} }
// Compute what the closest grid space of each token is, then compute that distance // Compute what the closest grid space of each token is, then compute that distance
const originEdge = this.#getEdgeBoundary(this.bounds, originPoint, destinationPoint); const originEdge = this.#getEdgeBoundary(thisBounds, originPoint, targetPoint);
const targetEdge = this.#getEdgeBoundary(target.bounds, originPoint, destinationPoint); const targetEdge = this.#getEdgeBoundary(targetBounds, originPoint, targetPoint);
const adjustedOriginPoint = canvas.grid.getTopLeftPoint({ const adjustedOriginPoint = originEdge
? canvas.grid.getTopLeftPoint({
x: originEdge.x + Math.sign(originPoint.x - originEdge.x), x: originEdge.x + Math.sign(originPoint.x - originEdge.x),
y: originEdge.y + Math.sign(originPoint.y - originEdge.y) y: originEdge.y + Math.sign(originPoint.y - originEdge.y)
}); })
const adjustDestinationPoint = canvas.grid.getTopLeftPoint({ : originPoint;
x: targetEdge.x + Math.sign(destinationPoint.x - targetEdge.x), const adjustDestinationPoint = targetEdge
y: targetEdge.y + Math.sign(destinationPoint.y - targetEdge.y) ? canvas.grid.getTopLeftPoint({
}); x: targetEdge.x + Math.sign(targetPoint.x - targetEdge.x),
return canvas.grid.measurePath([adjustedOriginPoint, adjustDestinationPoint]).distance; y: targetEdge.y + Math.sign(targetPoint.y - targetEdge.y)
})
: targetPoint;
const distance = canvas.grid.measurePath([
{ ...adjustedOriginPoint, elevation: 0 },
{ ...adjustDestinationPoint, elevation }
]).distance;
return Math.min(distance, distance > adjacencyBuffer ? Infinity : canvas.grid.distance);
}
_onHoverIn(event, options) {
super._onHoverIn(event, options);
// Check if the setting is enabled
const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance).showTokenDistance;
if (setting === 'never' || (setting === 'encounters' && !game.combat?.started)) return;
// Check if this token isn't invisible and is actually being hovered
const isTokenValid =
this.visible &&
this.hover &&
!this.isPreview &&
!this.document.isSecret &&
!this.controlled &&
!this.animation;
if (!isTokenValid) return;
// Ensure we have a single controlled token
const originToken = canvas.tokens.controlled[0];
if (!originToken || canvas.tokens.controlled.length > 1) return;
// Determine the actual range
const ranges = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).rangeMeasurement;
const distanceResult = DhMeasuredTemplate.getRangeLabels(originToken.distanceTo(this), ranges);
const distanceLabel = `${distanceResult.distance} ${distanceResult.units}`.trim();
// Create the element
const element = document.createElement('div');
element.id = 'token-hover-distance';
element.classList.add('waypoint-label', 'last');
const ruler = document.createElement('i');
ruler.classList.add('fa-solid', 'fa-ruler');
element.appendChild(ruler);
const labelEl = document.createElement('span');
labelEl.classList.add('total-measurement');
labelEl.textContent = distanceLabel;
element.appendChild(labelEl);
// Position the element and add to the DOM
const center = this.getCenterPoint();
element.style.setProperty('--transformY', 'calc(-100% - 10px)');
element.style.setProperty('--position-y', `${this.y}px`);
element.style.setProperty('--position-x', `${center.x}px`);
element.style.setProperty('--ui-scale', String(canvas.dimensions.uiScale));
document.querySelector('#token-hover-distance')?.remove();
document.querySelector('#measurement').appendChild(element);
}
_onHoverOut(...args) {
super._onHoverOut(...args);
document.querySelector('#token-hover-distance')?.remove();
} }
/** Returns the point at which a line starting at origin and ending at destination intersects the edge of the bounds */ /** Returns the point at which a line starting at origin and ending at destination intersects the edge of the bounds */
@ -100,11 +183,6 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
return null; return null;
} }
/** Tests if the token is at least adjacent with another, with some leeway for diagonals */
isAdjacentWith(token) {
return this.distanceTo(token) <= canvas.grid.distance * 1.5;
}
/** @inheritDoc */ /** @inheritDoc */
_drawBar(number, bar, data) { _drawBar(number, bar, data) {
const val = Number(data.value); const val = Number(data.value);

View file

@ -494,3 +494,275 @@ export const subclassFeatureLabels = {
2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle', 2: 'DAGGERHEART.ITEMS.DomainCard.specializationTitle',
3: 'DAGGERHEART.ITEMS.DomainCard.masteryTitle' 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

@ -236,6 +236,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -252,7 +253,8 @@ export const defaultRestOptions = {
] ]
} }
} }
} },
effects: []
}, },
clearStress: { clearStress: {
id: 'clearStress', id: 'clearStress',
@ -285,7 +287,8 @@ export const defaultRestOptions = {
] ]
} }
} }
} },
effects: []
}, },
repairArmor: { repairArmor: {
id: 'repairArmor', id: 'repairArmor',
@ -302,6 +305,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -318,7 +322,8 @@ export const defaultRestOptions = {
] ]
} }
} }
} },
effects: []
}, },
prepare: { prepare: {
id: 'prepare', id: 'prepare',
@ -326,7 +331,57 @@ export const defaultRestOptions = {
icon: 'fa-solid fa-dumbbell', icon: 'fa-solid fa-dumbbell',
img: 'icons/skills/trades/academics-merchant-scribe.webp', img: 'icons/skills/trades/academics-merchant-scribe.webp',
description: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.shortRest.prepare.description'), description: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.shortRest.prepare.description'),
actions: {} actions: {
prepare: {
type: 'healing',
systemPath: 'restMoves.shortRest.moves.prepare.actions',
name: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.shortRest.prepare.name'),
img: 'icons/skills/trades/academics-merchant-scribe.webp',
actionType: 'action',
chatDisplay: false,
target: {
type: 'self'
},
damage: {
parts: [
{
applyTo: healingTypes.hope.id,
value: {
custom: {
enabled: true,
formula: '1'
}
}
}
]
}
},
prepareWithFriends: {
type: 'healing',
systemPath: 'restMoves.shortRest.moves.prepare.actions',
name: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.shortRest.prepareWithFriends.name'),
img: 'icons/skills/trades/academics-merchant-scribe.webp',
actionType: 'action',
chatDisplay: false,
target: {
type: 'self'
},
damage: {
parts: [
{
applyTo: healingTypes.hope.id,
value: {
custom: {
enabled: true,
formula: '2'
}
}
}
]
}
}
},
effects: []
} }
}), }),
longRest: () => ({ longRest: () => ({
@ -345,6 +400,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -361,7 +417,8 @@ export const defaultRestOptions = {
] ]
} }
} }
} },
effects: []
}, },
clearStress: { clearStress: {
id: 'clearStress', id: 'clearStress',
@ -394,7 +451,8 @@ export const defaultRestOptions = {
] ]
} }
} }
} },
effects: []
}, },
repairArmor: { repairArmor: {
id: 'repairArmor', id: 'repairArmor',
@ -411,6 +469,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -427,7 +486,8 @@ export const defaultRestOptions = {
] ]
} }
} }
} },
effects: []
}, },
prepare: { prepare: {
id: 'prepare', id: 'prepare',
@ -435,7 +495,57 @@ export const defaultRestOptions = {
icon: 'fa-solid fa-dumbbell', icon: 'fa-solid fa-dumbbell',
img: 'icons/skills/trades/academics-merchant-scribe.webp', img: 'icons/skills/trades/academics-merchant-scribe.webp',
description: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.longRest.prepare.description'), description: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.longRest.prepare.description'),
actions: {} actions: {
prepare: {
type: 'healing',
systemPath: 'restMoves.longRest.moves.prepare.actions',
name: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.longRest.prepare.name'),
img: 'icons/skills/trades/academics-merchant-scribe.webp',
actionType: 'action',
chatDisplay: false,
target: {
type: 'self'
},
damage: {
parts: [
{
applyTo: healingTypes.hope.id,
value: {
custom: {
enabled: true,
formula: '1'
}
}
}
]
}
},
prepareWithFriends: {
type: 'healing',
systemPath: 'restMoves.longRest.moves.prepare.actions',
name: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.longRest.prepareWithFriends.name'),
img: 'icons/skills/trades/academics-merchant-scribe.webp',
actionType: 'action',
chatDisplay: false,
target: {
type: 'self'
},
damage: {
parts: [
{
applyTo: healingTypes.hope.id,
value: {
custom: {
enabled: true,
formula: '2'
}
}
}
]
}
}
},
effects: []
}, },
workOnAProject: { workOnAProject: {
id: 'workOnAProject', id: 'workOnAProject',
@ -443,7 +553,8 @@ export const defaultRestOptions = {
icon: 'fa-solid fa-diagram-project', icon: 'fa-solid fa-diagram-project',
img: 'icons/skills/social/thumbsup-approval-like.webp', img: 'icons/skills/social/thumbsup-approval-like.webp',
description: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.longRest.workOnAProject.description'), description: game.i18n.localize('DAGGERHEART.APPLICATIONS.Downtime.longRest.workOnAProject.description'),
actions: {} actions: {},
effects: []
} }
}) })
}; };

View file

@ -30,6 +30,7 @@ export const gameSettings = {
LastMigrationVersion: 'LastMigrationVersion', LastMigrationVersion: 'LastMigrationVersion',
TagTeamRoll: 'TagTeamRoll', TagTeamRoll: 'TagTeamRoll',
SpotlightRequestQueue: 'SpotlightRequestQueue', SpotlightRequestQueue: 'SpotlightRequestQueue',
CompendiumBrowserSettings: 'CompendiumBrowserSettings'
}; };
export const actionAutomationChoices = { export const actionAutomationChoices = {

View file

@ -3,6 +3,7 @@ export { default as DhCombatant } from './combatant.mjs';
export { default as DhTagTeamRoll } from './tagTeamRoll.mjs'; export { default as DhTagTeamRoll } from './tagTeamRoll.mjs';
export { default as DhRollTable } from './rollTable.mjs'; export { default as DhRollTable } from './rollTable.mjs';
export { default as RegisteredTriggers } from './registeredTriggers.mjs'; export { default as RegisteredTriggers } from './registeredTriggers.mjs';
export { default as CompendiumBrowserSettings } from './compendiumBrowserSettings.mjs';
export * as countdowns from './countdowns.mjs'; export * as countdowns from './countdowns.mjs';
export * as actions from './action/_module.mjs'; export * as actions from './action/_module.mjs';

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) { async use(event, options) {
const result = await super.use(event, options); const result = await super.use(event, options);
if (!result.message) return; if (!result.message) return;

View file

@ -114,9 +114,24 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
* Return Item the action is attached too. * Return Item the action is attached too.
*/ */
get item() { get item() {
if (!this.parent.parent && this.systemPath)
return foundry.utils.getProperty(this.parent, this.systemPath).get(this.id);
return this.parent.parent; return this.parent.parent;
} }
get applyEffects() {
if (this.item.systemPath) {
const itemEffectIds = this.item.effects.map(x => x._id);
const movePathSplit = this.item.systemPath.split('.');
movePathSplit.pop();
const move = foundry.utils.getProperty(this.parent, movePathSplit.join('.'));
return new Collection(itemEffectIds.map(id => [id, move.effects.find(x => x.id === id)]));
}
return this.item.effects;
}
/** /**
* Return the first Actor parent found. * Return the first Actor parent found.
*/ */
@ -125,7 +140,7 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
? this.item ? this.item
: this.item?.parent instanceof DhpActor : this.item?.parent instanceof DhpActor
? this.item.parent ? this.item.parent
: this.item?.actor; : null;
} }
static getRollType(parent) { static getRollType(parent) {
@ -214,7 +229,7 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
if (Hooks.call(`${CONFIG.DH.id}.postUseAction`, this, config) === false) return; if (Hooks.call(`${CONFIG.DH.id}.postUseAction`, this, config) === false) return;
if (this.chatDisplay) await this.toChat(); if (this.chatDisplay && !config.actionChatMessageHandled) await this.toChat();
return config; return config;
} }
@ -225,9 +240,13 @@ export default class DHBaseAction extends ActionMixin(foundry.abstract.DataModel
* @returns {object} * @returns {object}
*/ */
prepareBaseConfig(event) { prepareBaseConfig(event) {
const isActor = this.item instanceof CONFIG.Actor.documentClass;
const actionTitle = game.i18n.localize(this.name);
const itemTitle = isActor || this.item.name === actionTitle ? '' : `${this.item.name} - `;
const config = { const config = {
event, event,
title: `${this.item instanceof CONFIG.Actor.documentClass ? '' : `${this.item.name}: `}${game.i18n.localize(this.name)}`, title: `${itemTitle}${actionTitle}`,
source: { source: {
item: this.item._id, item: this.item._id,
originItem: this.originItem, originItem: this.originItem,

View file

@ -1,9 +1,12 @@
import DHAdversarySettings from '../../applications/sheets-configs/adversary-settings.mjs'; import DHAdversarySettings from '../../applications/sheets-configs/adversary-settings.mjs';
import { ActionField } from '../fields/actionField.mjs'; import { ActionField } from '../fields/actionField.mjs';
import BaseDataActor, { commonActorRules } from './base.mjs'; import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs';
import { resourceField, bonusField } from '../fields/actorField.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 { export default class DhpAdversary extends DhCreature {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary']; static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Adversary'];
static get metadata() { static get metadata() {
@ -40,7 +43,14 @@ export default class DhpAdversary extends BaseDataActor {
integer: true, integer: true,
label: 'DAGGERHEART.GENERAL.hordeHp' label: 'DAGGERHEART.GENERAL.hordeHp'
}), }),
criticalThreshold: new fields.NumberField({ required: true, integer: true, min: 1, max: 20, initial: 20 }), criticalThreshold: new fields.NumberField({
required: true,
integer: true,
min: 1,
max: 20,
initial: 20,
label: 'DAGGERHEART.ACTIONS.Settings.criticalThreshold'
}),
damageThresholds: new fields.SchemaField({ damageThresholds: new fields.SchemaField({
major: new fields.NumberField({ major: new fields.NumberField({
required: true, required: true,
@ -180,6 +190,10 @@ export default class DhpAdversary extends BaseDataActor {
} }
} }
prepareDerivedData() {
this.attack.roll.isStandardAttack = true;
}
_getTags() { _getTags() {
const tags = [ const tags = [
game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`), game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`),
@ -188,4 +202,211 @@ export default class DhpAdversary extends BaseDataActor {
]; ];
return tags; 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

@ -29,17 +29,40 @@ const resistanceField = (resistanceLabel, immunityLabel, reductionLabel) =>
/* Common rules applying to Characters and Adversaries */ /* Common rules applying to Characters and Adversaries */
export const commonActorRules = (extendedData = { damageReduction: {}, attack: { damage: {} } }) => ({ export const commonActorRules = (extendedData = { damageReduction: {}, attack: { damage: {} } }) => ({
conditionImmunities: new fields.SchemaField({ conditionImmunities: new fields.SchemaField({
hidden: new fields.BooleanField({ initial: false }), hidden: new fields.BooleanField({
restrained: new fields.BooleanField({ initial: false }), initial: false,
vulnerable: new fields.BooleanField({ initial: false }) label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.hidden'
}),
restrained: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.restrained'
}),
vulnerable: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.vulnerable'
})
}), }),
damageReduction: new fields.SchemaField({ damageReduction: new fields.SchemaField({
thresholdImmunities: new fields.SchemaField({ thresholdImmunities: new fields.SchemaField({
minor: new fields.BooleanField({ initial: false }) minor: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.thresholdImmunities.minor.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.thresholdImmunities.minor.hint'
})
}), }),
reduceSeverity: new fields.SchemaField({ reduceSeverity: new fields.SchemaField({
magical: new fields.NumberField({ initial: 0, min: 0 }), magical: new fields.NumberField({
physical: new fields.NumberField({ initial: 0, min: 0 }) initial: 0,
min: 0,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.magical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.magical.hint'
}),
physical: new fields.NumberField({
initial: 0,
min: 0,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.physical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.reduceSeverity.physical.hint'
})
}), }),
...(extendedData.damageReduction ?? {}) ...(extendedData.damageReduction ?? {})
}), }),
@ -49,12 +72,16 @@ export const commonActorRules = (extendedData = { damageReduction: {}, attack: {
hpDamageMultiplier: new fields.NumberField({ hpDamageMultiplier: new fields.NumberField({
required: true, required: true,
nullable: false, nullable: false,
initial: 1 initial: 1,
label: 'DAGGERHEART.GENERAL.Attack.hpDamageMultiplier.label',
hint: 'DAGGERHEART.GENERAL.Attack.hpDamageMultiplier.hint'
}), }),
hpDamageTakenMultiplier: new fields.NumberField({ hpDamageTakenMultiplier: new fields.NumberField({
required: true, required: true,
nullable: false, nullable: false,
initial: 1 initial: 1,
label: 'DAGGERHEART.GENERAL.Attack.hpDamageTakenMultiplier.label',
hint: 'DAGGERHEART.GENERAL.Attack.hpDamageTakenMultiplier.hint'
}), }),
...(extendedData.attack?.damage ?? {}) ...(extendedData.attack?.damage ?? {})
}) })

View file

@ -1,13 +1,14 @@
import { burden } from '../../config/generalConfig.mjs'; import { burden } from '../../config/generalConfig.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import DhLevelData from '../levelData.mjs'; import DhLevelData from '../levelData.mjs';
import BaseDataActor, { commonActorRules } from './base.mjs'; import { commonActorRules } from './base.mjs';
import DhCreature from './creature.mjs';
import { attributeField, resourceField, stressDamageReductionRule, bonusField } from '../fields/actorField.mjs'; import { attributeField, resourceField, stressDamageReductionRule, bonusField } from '../fields/actorField.mjs';
import { ActionField } from '../fields/actionField.mjs'; import { ActionField } from '../fields/actionField.mjs';
import DHCharacterSettings from '../../applications/sheets-configs/character-settings.mjs'; import DHCharacterSettings from '../../applications/sheets-configs/character-settings.mjs';
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
export default class DhCharacter extends BaseDataActor { export default class DhCharacter extends DhCreature {
/**@override */ /**@override */
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Character']; static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Character'];
@ -36,14 +37,18 @@ export default class DhCharacter extends BaseDataActor {
'DAGGERHEART.ACTORS.Character.maxHPBonus' 'DAGGERHEART.ACTORS.Character.maxHPBonus'
), ),
stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true), stress: resourceField(6, 0, 'DAGGERHEART.GENERAL.stress', true),
hope: new fields.SchemaField({ hope: new fields.SchemaField(
{
value: new fields.NumberField({ value: new fields.NumberField({
initial: 2, initial: 2,
min: 0, min: 0,
integer: true, integer: true,
label: 'DAGGERHEART.GENERAL.hope' label: 'DAGGERHEART.GENERAL.hope'
}) }),
}) isReversed: new fields.BooleanField({ initial: false })
},
{ label: 'DAGGERHEART.GENERAL.hope' }
)
}), }),
traits: new fields.SchemaField({ traits: new fields.SchemaField({
agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'), agility: attributeField('DAGGERHEART.CONFIG.Traits.agility.name'),
@ -128,14 +133,6 @@ export default class DhCharacter extends BaseDataActor {
} }
} }
}), }),
advantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.advantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint'
}),
disadvantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.disadvantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.disadvantageSources.hint'
}),
levelData: new fields.EmbeddedDataField(DhLevelData), levelData: new fields.EmbeddedDataField(DhLevelData),
bonuses: new fields.SchemaField({ bonuses: new fields.SchemaField({
roll: new fields.SchemaField({ roll: new fields.SchemaField({
@ -222,8 +219,16 @@ export default class DhCharacter extends BaseDataActor {
rules: new fields.SchemaField({ rules: new fields.SchemaField({
...commonActorRules({ ...commonActorRules({
damageReduction: { damageReduction: {
magical: new fields.BooleanField({ initial: false }), magical: new fields.BooleanField({
physical: new fields.BooleanField({ initial: false }), initial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.magical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.magical.hint'
}),
physical: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.physical.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.physical.hint'
}),
maxArmorMarked: new fields.SchemaField({ maxArmorMarked: new fields.SchemaField({
value: new fields.NumberField({ value: new fields.NumberField({
required: true, required: true,
@ -253,7 +258,10 @@ export default class DhCharacter extends BaseDataActor {
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.label', label: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.label',
hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.hint' hint: 'DAGGERHEART.GENERAL.Rules.damageReduction.increasePerArmorMark.hint'
}), }),
disabledArmor: new fields.BooleanField({ intial: false }) disabledArmor: new fields.BooleanField({
intial: false,
label: 'DAGGERHEART.GENERAL.Rules.damageReduction.disabledArmor.label'
})
}, },
attack: { attack: {
damage: { damage: {
@ -301,12 +309,14 @@ export default class DhCharacter extends BaseDataActor {
label: 'DAGGERHEART.ACTORS.Character.defaultFearDice' label: 'DAGGERHEART.ACTORS.Character.defaultFearDice'
}) })
}), }),
runeWard: new fields.BooleanField({ initial: false }),
burden: new fields.SchemaField({ burden: new fields.SchemaField({
ignore: new fields.BooleanField() ignore: new fields.BooleanField({ label: 'DAGGERHEART.ACTORS.Character.burden.ignore.label' })
}), }),
roll: new fields.SchemaField({ roll: new fields.SchemaField({
guaranteedCritical: new fields.BooleanField() guaranteedCritical: new fields.BooleanField({
label: 'DAGGERHEART.ACTORS.Character.roll.guaranteedCritical.label',
hint: 'DAGGERHEART.ACTORS.Character.roll.guaranteedCritical.hint'
})
}) })
}), }),
sidebarFavorites: new ForeignDocumentUUIDArrayField({ type: 'Item' }) sidebarFavorites: new ForeignDocumentUUIDArrayField({ type: 'Item' })
@ -640,7 +650,7 @@ export default class DhCharacter extends BaseDataActor {
}; };
const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope; const globalHopeMax = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).maxHope;
this.resources.hope.max = globalHopeMax - this.scars; this.resources.hope.max = globalHopeMax;
this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0; this.resources.hitPoints.max += this.class.value?.system?.hitPoints ?? 0;
/* Companion Related Data */ /* Companion Related Data */
@ -664,6 +674,7 @@ export default class DhCharacter extends BaseDataActor {
} }
} }
this.resources.hope.max -= this.scars;
this.resources.hope.value = Math.min(baseHope, this.resources.hope.max); this.resources.hope.value = Math.min(baseHope, this.resources.hope.max);
this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait; this.attack.roll.trait = this.rules.attack.roll.trait ?? this.attack.roll.trait;

View file

@ -1,4 +1,4 @@
import BaseDataActor from './base.mjs'; import DhCreature from './creature.mjs';
import DhLevelData from '../levelData.mjs'; import DhLevelData from '../levelData.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import { ActionField } from '../fields/actionField.mjs'; import { ActionField } from '../fields/actionField.mjs';
@ -6,7 +6,7 @@ import { adjustDice, adjustRange } from '../../helpers/utils.mjs';
import DHCompanionSettings from '../../applications/sheets-configs/companion-settings.mjs'; import DHCompanionSettings from '../../applications/sheets-configs/companion-settings.mjs';
import { resourceField, bonusField } from '../fields/actorField.mjs'; import { resourceField, bonusField } from '../fields/actorField.mjs';
export default class DhCompanion extends BaseDataActor { export default class DhCompanion extends DhCreature {
static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Companion']; static LOCALIZATION_PREFIXES = ['DAGGERHEART.ACTORS.Companion'];
/**@inheritdoc */ /**@inheritdoc */
@ -53,9 +53,18 @@ export default class DhCompanion extends BaseDataActor {
), ),
rules: new fields.SchemaField({ rules: new fields.SchemaField({
conditionImmunities: new fields.SchemaField({ conditionImmunities: new fields.SchemaField({
hidden: new fields.BooleanField({ initial: false }), hidden: new fields.BooleanField({
restrained: new fields.BooleanField({ initial: false }), initial: false,
vulnerable: new fields.BooleanField({ initial: false }) label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.hidden'
}),
restrained: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.restrained'
}),
vulnerable: new fields.BooleanField({
initial: false,
label: 'DAGGERHEART.GENERAL.Rules.conditionImmunities.vulnerable'
})
}) })
}), }),
attack: new ActionField({ attack: new ActionField({

View file

@ -0,0 +1,20 @@
import BaseDataActor from './base.mjs';
export default class DhCreature extends BaseDataActor {
/**@inheritdoc */
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
advantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.advantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.advantageSources.hint'
}),
disadvantageSources: new fields.ArrayField(new fields.StringField(), {
label: 'DAGGERHEART.ACTORS.Character.disadvantageSources.label',
hint: 'DAGGERHEART.ACTORS.Character.disadvantageSources.hint'
})
};
}
}

View file

@ -31,6 +31,7 @@ export default class DHActorRoll extends foundry.abstract.TypeDataModel {
static defineSchema() { static defineSchema() {
return { return {
title: new fields.StringField(), title: new fields.StringField(),
actionDescription: new fields.HTMLField(),
roll: new fields.ObjectField(), roll: new fields.ObjectField(),
targets: targetsField(), targets: targetsField(),
hasRoll: new fields.BooleanField({ initial: false }), hasRoll: new fields.BooleanField({ initial: false }),

View file

@ -0,0 +1,36 @@
export default class CompendiumBrowserSettings extends foundry.abstract.DataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
excludedSources: new fields.TypedObjectField(
new fields.SchemaField({
excludedDocumentTypes: new fields.ArrayField(
new fields.StringField({ required: true, choices: CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES })
)
})
),
excludedPacks: new fields.TypedObjectField(
new fields.SchemaField({
excludedDocumentTypes: new fields.ArrayField(
new fields.StringField({ required: true, choices: CONST.SYSTEM_SPECIFIC_COMPENDIUM_TYPES })
)
})
)
};
}
isEntryExcluded(item) {
const pack = game.packs.get(item.pack);
if (!pack) return false;
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];
if (excludedPackData && excludedPackData.excludedDocumentTypes.includes(pack.metadata.type)) return true;
return false;
}
}

View file

@ -68,6 +68,8 @@ export default class DamageField extends fields.SchemaField {
const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig); const damageResult = await CONFIG.Dice.daggerheart.DamageRoll.build(damageConfig);
if (!damageResult) return false; if (!damageResult) return false;
if (damageResult.actionChatMessageHandled) config.actionChatMessageHandled = true;
config.damage = damageResult.damage; config.damage = damageResult.damage;
config.message ??= damageConfig.message; config.message ??= damageConfig.message;
} }
@ -107,8 +109,8 @@ export default class DamageField extends fields.SchemaField {
); );
else { else {
const configDamage = foundry.utils.deepClone(config.damage); const configDamage = foundry.utils.deepClone(config.damage);
const hpDamageMultiplier = config.actionActor?.system.rules.attack.damage.hpDamageMultiplier ?? 1; const hpDamageMultiplier = config.actionActor?.system.rules?.attack?.damage?.hpDamageMultiplier ?? 1;
const hpDamageTakenMultiplier = actor.system.rules.attack.damage.hpDamageTakenMultiplier; const hpDamageTakenMultiplier = actor.system.rules?.attack?.damage?.hpDamageTakenMultiplier;
if (configDamage.hitPoints) { if (configDamage.hitPoints) {
for (const part of configDamage.hitPoints.parts) { for (const part of configDamage.hitPoints.parts) {
part.total = Math.ceil(part.total * hpDamageMultiplier * hpDamageTakenMultiplier); part.total = Math.ceil(part.total * hpDamageMultiplier * hpDamageTakenMultiplier);
@ -163,7 +165,8 @@ export default class DamageField extends fields.SchemaField {
if (data.hasRoll && part.resultBased && data.roll.result.duality === -1) return part.valueAlt; if (data.hasRoll && part.resultBased && data.roll.result.duality === -1) return part.valueAlt;
const isAdversary = this.actor.type === 'adversary'; const isAdversary = this.actor.type === 'adversary';
if (isAdversary && this.actor.system.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id) { const isHorde = this.actor.system.type === CONFIG.DH.ACTOR.adversaryTypes.horde.id;
if (isAdversary && isHorde && this.roll?.isStandardAttack) {
const hasHordeDamage = this.actor.effects.find(x => x.type === 'horde'); const hasHordeDamage = this.actor.effects.find(x => x.type === 'horde');
if (hasHordeDamage && !hasHordeDamage.disabled) return part.valueAlt; if (hasHordeDamage && !hasHordeDamage.disabled) return part.valueAlt;
} }

View file

@ -73,7 +73,7 @@ export default class EffectsField extends fields.ArrayField {
}); });
effects.forEach(async e => { effects.forEach(async e => {
const effect = this.item.effects.get(e._id); const effect = (this.item.applyEffects ?? this.item.effects).get(e._id);
if (!token.actor || !effect) return; if (!token.actor || !effect) return;
await EffectsField.applyEffect(effect, token.actor); await EffectsField.applyEffect(effect, token.actor);
}); });
@ -96,7 +96,7 @@ export default class EffectsField extends fields.ArrayField {
content: await foundry.applications.handlebars.renderTemplate( content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/ui/chat/effectSummary.hbs', 'systems/daggerheart/templates/ui/chat/effectSummary.hbs',
{ {
effects: this.effects.map(e => this.item.effects.get(e._id)), effects: this.effects.map(e => (this.item.applyEffects ?? this.item.effects).get(e._id)),
targets: messageTargets targets: messageTargets
} }
) )
@ -123,7 +123,7 @@ export default class EffectsField extends fields.ArrayField {
// Otherwise, create a new effect on the target // Otherwise, create a new effect on the target
const effectData = foundry.utils.mergeObject({ const effectData = foundry.utils.mergeObject({
...effect.toObject(), ...(effect.toObject?.() ?? effect),
disabled: false, disabled: false,
transfer: false, transfer: false,
origin: effect.uuid origin: effect.uuid

View file

@ -152,7 +152,9 @@ export function ActionMixin(Base) {
} }
get uuid() { get uuid() {
return `${this.item.uuid}.${this.documentName}.${this.id}`; const isItem = this.item instanceof game.system.api.documents.DHItem;
const isActor = this.item instanceof game.system.api.documents.DhpActor;
return isItem || isActor ? `${this.item.uuid}.${this.documentName}.${this.id}` : null;
} }
get sheet() { get sheet() {
@ -260,6 +262,9 @@ export function ActionMixin(Base) {
} }
async toChat(origin) { async toChat(origin) {
const autoExpandDescription = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance)
.expandRollMessage?.desc;
const cls = getDocumentClass('ChatMessage'); const cls = getDocumentClass('ChatMessage');
const systemData = { const systemData = {
title: game.i18n.localize('DAGGERHEART.CONFIG.FeatureForm.action'), title: game.i18n.localize('DAGGERHEART.CONFIG.FeatureForm.action'),
@ -288,7 +293,7 @@ export function ActionMixin(Base) {
system: systemData, system: systemData,
content: await foundry.applications.handlebars.renderTemplate( content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/ui/chat/action.hbs', 'systems/daggerheart/templates/ui/chat/action.hbs',
systemData { ...systemData, open: autoExpandDescription ? 'open' : '' }
), ),
flags: { flags: {
daggerheart: { daggerheart: {

View file

@ -7,16 +7,20 @@ const attributeField = label =>
}); });
const resourceField = (max = 0, initial = 0, label, reverse = false, maxLabel) => const resourceField = (max = 0, initial = 0, label, reverse = false, maxLabel) =>
new fields.SchemaField({ new fields.SchemaField(
{
value: new fields.NumberField({ initial: initial, min: 0, integer: true, label }), value: new fields.NumberField({ initial: initial, min: 0, integer: true, label }),
max: new fields.NumberField({ max: new fields.NumberField({
initial: max, initial: max,
integer: true, integer: true,
label: label:
maxLabel ?? game.i18n.format('DAGGERHEART.GENERAL.maxWithThing', { thing: game.i18n.localize(label) }) maxLabel ??
game.i18n.format('DAGGERHEART.GENERAL.maxWithThing', { thing: game.i18n.localize(label) })
}), }),
isReversed: new fields.BooleanField({ initial: reverse }) isReversed: new fields.BooleanField({ initial: reverse })
}); },
{ label }
);
const stressDamageReductionRule = localizationPath => const stressDamageReductionRule = localizationPath =>
new fields.SchemaField({ new fields.SchemaField({

View file

@ -1,5 +1,6 @@
import BaseDataItem from './base.mjs'; import BaseDataItem from './base.mjs';
import ItemLinkFields from '../../data/fields/itemLinkFields.mjs'; import ItemLinkFields from '../../data/fields/itemLinkFields.mjs';
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
export default class DHAncestry extends BaseDataItem { export default class DHAncestry extends BaseDataItem {
/** @inheritDoc */ /** @inheritDoc */
@ -19,7 +20,6 @@ export default class DHAncestry extends BaseDataItem {
}; };
} }
/* -------------------------------------------- */ /* -------------------------------------------- */
/**@override */ /**@override */
@ -42,4 +42,18 @@ export default class DHAncestry extends BaseDataItem {
get secondaryFeature() { get secondaryFeature() {
return this.features.find(x => x.type === CONFIG.DH.ITEM.featureSubTypes.secondary)?.item; return this.features.find(x => x.type === CONFIG.DH.ITEM.featureSubTypes.secondary)?.item;
} }
/**@inheritdoc */
async getDescriptionData() {
const baseDescription = this.description;
const features = await getFeaturesHTMLData(this.features);
if (!features.length) return { prefix: null, value: baseDescription, suffix: null };
const suffix = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/sheets/items/description.hbs',
{ label: 'DAGGERHEART.ITEMS.Ancestry.featuresLabel', features }
);
return { prefix: null, value: baseDescription, suffix };
}
} }

View file

@ -59,11 +59,10 @@ export default class DHArmor extends AttachableItem {
const baseDescription = this.description; const baseDescription = this.description;
const allFeatures = CONFIG.DH.ITEM.allArmorFeatures(); const allFeatures = CONFIG.DH.ITEM.allArmorFeatures();
const features = this.armorFeatures.map(x => allFeatures[x.value]); const features = this.armorFeatures.map(x => allFeatures[x.value]);
if (!features.length) return { prefix: null, value: baseDescription, suffix: null };
const prefix = await foundry.applications.handlebars.renderTemplate( const prefix = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/sheets/items/armor/description.hbs', 'systems/daggerheart/templates/sheets/items/armor/description.hbs',
{ features } { item: this.parent, features }
); );
return { prefix, value: baseDescription, suffix: null }; return { prefix, value: baseDescription, suffix: null };

View file

@ -253,4 +253,20 @@ export default class DHBeastform extends BaseDataItem {
return false; return false;
} }
_onCreate(_data, _options, userId) {
if (!this.actor && game.user.id === userId) {
const hasBeastformEffect = this.parent.effects.some(x => x.type === 'beastform');
if (!hasBeastformEffect)
this.parent.createEmbeddedDocuments('ActiveEffect', [
{
type: 'beastform',
name: game.i18n.localize('DAGGERHEART.ITEMS.Beastform.beastformEffect'),
img: 'icons/creatures/abilities/paw-print-pair-purple.webp'
}
]);
return;
}
}
} }

View file

@ -2,7 +2,7 @@ import BaseDataItem from './base.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
import ItemLinkFields from '../fields/itemLinkFields.mjs'; import ItemLinkFields from '../fields/itemLinkFields.mjs';
import { addLinkedItemsDiff, updateLinkedItemApps } from '../../helpers/utils.mjs'; import { addLinkedItemsDiff, getFeaturesHTMLData, updateLinkedItemApps } from '../../helpers/utils.mjs';
export default class DHClass extends BaseDataItem { export default class DHClass extends BaseDataItem {
/** @inheritDoc */ /** @inheritDoc */
@ -163,4 +163,56 @@ export default class DHClass extends BaseDataItem {
updateLinkedItemApps(options, this.parent.sheet); updateLinkedItemApps(options, this.parent.sheet);
} }
/**@inheritdoc */
async getDescriptionData() {
const baseDescription = this.description;
const getDomainLabel = domain => {
const data = CONFIG.DH.DOMAIN.allDomains()[domain];
return data ? game.i18n.localize(data.label) : '';
};
let domainsLabel = '';
if (this.domains.length) {
if (this.domains.length === 1) domainsLabel = getDomainLabel(this.domains[0]);
else {
const firstDomains = this.domains
.slice(0, this.domains.length - 1)
.map(getDomainLabel)
.join(', ');
const lastDomain = getDomainLabel(this.domains[this.domains.length - 1]);
domainsLabel = game.i18n.format('DAGGERHEART.GENERAL.thingsAndThing', {
things: firstDomains,
thing: lastDomain
});
}
}
const classItems = [];
for (const itemData of this.inventory.choiceB) {
const linkData = [
undefined,
'UUID', // type
itemData.uuid // target
];
const contentLink = await foundry.applications.ux.TextEditor.implementation._createContentLink(linkData);
classItems.push(contentLink.outerHTML);
}
const hopeFeatures = await getFeaturesHTMLData(this.hopeFeatures);
const classFeatures = await getFeaturesHTMLData(this.classFeatures);
const suffix = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/sheets/items/class/description.hbs',
{
class: this.parent,
domains: domainsLabel,
classItems,
hopeFeatures,
classFeatures
}
);
return { prefix: null, value: baseDescription, suffix };
}
} }

View file

@ -1,3 +1,4 @@
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs'; import ForeignDocumentUUIDArrayField from '../fields/foreignDocumentUUIDArrayField.mjs';
import BaseDataItem from './base.mjs'; import BaseDataItem from './base.mjs';
@ -24,4 +25,17 @@ export default class DHCommunity extends BaseDataItem {
/**@override */ /**@override */
static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/items/village.svg'; static DEFAULT_ICON = 'systems/daggerheart/assets/icons/documents/items/village.svg';
/**@inheritdoc */
async getDescriptionData() {
const baseDescription = this.description;
const features = await getFeaturesHTMLData(this.features);
if (!features.length) return { prefix: null, value: baseDescription, suffix: null };
const suffix = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/sheets/items/description.hbs',
{ label: 'DAGGERHEART.ITEMS.Community.featuresLabel', features }
);
return { prefix: null, value: baseDescription, suffix };
}
} }

View file

@ -94,8 +94,10 @@ export default class DHDomainCard extends BaseDataItem {
return false; return false;
} }
if (!this.actor.system.loadoutSlot.available) { if (!this.actor.system.loadoutSlot.available && !this.loadoutIgnore) {
data.system.inVault = true; data.system.inVault = true;
await this.updateSource({ inVault: true });
ui.notifications.warn(game.i18n.localize('DAGGERHEART.UI.Notifications.loadoutMaxReached'));
} }
} }
} }

View file

@ -1,3 +1,4 @@
import { getFeaturesHTMLData } from '../../helpers/utils.mjs';
import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs'; import ForeignDocumentUUIDField from '../fields/foreignDocumentUUIDField.mjs';
import ItemLinkFields from '../fields/itemLinkFields.mjs'; import ItemLinkFields from '../fields/itemLinkFields.mjs';
import BaseDataItem from './base.mjs'; import BaseDataItem from './base.mjs';
@ -89,4 +90,28 @@ export default class DHSubclass extends BaseDataItem {
const allowed = await super._preCreate(data, options, user); const allowed = await super._preCreate(data, options, user);
if (allowed === false) return; if (allowed === false) return;
} }
/**@inheritdoc */
async getDescriptionData() {
const baseDescription = this.description;
const spellcastTrait = this.spellcastingTrait
? game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.spellcastingTrait].label)
: null;
const foundationFeatures = await getFeaturesHTMLData(this.foundationFeatures);
const specializationFeatures = await getFeaturesHTMLData(this.specializationFeatures);
const masteryFeatures = await getFeaturesHTMLData(this.masteryFeatures);
const suffix = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/sheets/items/subclass/description.hbs',
{
spellcastTrait,
foundationFeatures,
specializationFeatures,
masteryFeatures
}
);
return { prefix: null, value: baseDescription, suffix };
}
} }

View file

@ -113,13 +113,26 @@ export default class DHWeapon extends AttachableItem {
/**@inheritdoc */ /**@inheritdoc */
async getDescriptionData() { async getDescriptionData() {
const baseDescription = this.description; const baseDescription = this.description;
const tier = game.i18n.localize(`DAGGERHEART.GENERAL.Tiers.${this.tier}`);
const trait = game.i18n.localize(CONFIG.DH.ACTOR.abilities[this.attack.roll.trait].label);
const range = game.i18n.localize(`DAGGERHEART.CONFIG.Range.${this.attack.range}.name`);
const damage = Roll.replaceFormulaData(this.attack.damageFormula, this.parent.parent ?? this.parent);
const burden = game.i18n.localize(CONFIG.DH.GENERAL.burden[this.burden].label);
const allFeatures = CONFIG.DH.ITEM.allWeaponFeatures(); const allFeatures = CONFIG.DH.ITEM.allWeaponFeatures();
const features = this.weaponFeatures.map(x => allFeatures[x.value]); const features = this.weaponFeatures.map(x => allFeatures[x.value]);
if (!features.length) return { prefix: null, value: baseDescription, suffix: null };
const prefix = await foundry.applications.handlebars.renderTemplate( const prefix = await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/sheets/items/weapon/description.hbs', 'systems/daggerheart/templates/sheets/items/weapon/description.hbs',
{ features } {
features,
tier,
trait,
range,
damage,
burden
}
); );
return { prefix, value: baseDescription, suffix: null }; return { prefix, value: baseDescription, suffix: null };

View file

@ -6,7 +6,12 @@ export default class DhLevelData extends foundry.abstract.DataModel {
return { return {
level: new fields.SchemaField({ level: new fields.SchemaField({
current: new fields.NumberField({ required: true, integer: true, initial: 1 }), current: new fields.NumberField({
required: true,
integer: true,
initial: 1,
label: 'DAGGERHEART.GENERAL.currentLevel'
}),
changed: new fields.NumberField({ required: true, integer: true, initial: 1 }), changed: new fields.NumberField({ required: true, integer: true, initial: 1 }),
bonuses: new fields.TypedObjectField(new fields.NumberField({ integer: true, nullable: false })) bonuses: new fields.TypedObjectField(new fields.NumberField({ integer: true, nullable: false }))
}), }),

View file

@ -37,11 +37,30 @@ export default class DhAppearance extends foundry.abstract.DataModel {
extendEnvironmentDescriptions: new BooleanField(), extendEnvironmentDescriptions: new BooleanField(),
extendItemDescriptions: new BooleanField(), extendItemDescriptions: new BooleanField(),
expandRollMessage: new SchemaField({ expandRollMessage: new SchemaField({
desc: new BooleanField(), desc: new BooleanField({ initial: true }),
roll: new BooleanField(), roll: new BooleanField(),
damage: new BooleanField(), damage: new BooleanField(),
target: new BooleanField() target: new BooleanField()
}), }),
showTokenDistance: new StringField({
required: true,
choices: {
always: {
value: 'always',
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.showTokenDistance.choices.always'
},
encounters: {
value: 'encounters',
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.showTokenDistance.choices.encounters'
},
never: {
value: 'never',
label: 'DAGGERHEART.SETTINGS.Appearance.FIELDS.showTokenDistance.choices.never'
}
},
nullable: false,
initial: 'always'
}),
hideAttribution: new BooleanField(), hideAttribution: new BooleanField(),
showGenericStatusEffects: new BooleanField({ initial: true }) showGenericStatusEffects: new BooleanField({ initial: true })
}; };

View file

@ -55,6 +55,23 @@ export default class DhAutomation extends foundry.abstract.DataModel {
initial: true, initial: true,
label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.resourceScrollTexts.label' label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.resourceScrollTexts.label'
}), }),
deathMoveAutomation: new fields.SchemaField({
avoidDeath: new fields.BooleanField({
required: true,
initial: true,
label: 'DAGGERHEART.CONFIG.DeathMoves.avoidDeath.name'
}),
riskItAll: new fields.BooleanField({
required: true,
initial: true,
label: 'DAGGERHEART.CONFIG.DeathMoves.riskItAll.name'
}),
blazeOfGlory: new fields.BooleanField({
required: true,
initial: true,
label: 'DAGGERHEART.CONFIG.DeathMoves.blazeOfGlory.name'
})
}),
defeated: new fields.SchemaField({ defeated: new fields.SchemaField({
enabled: new fields.BooleanField({ enabled: new fields.BooleanField({
required: true, required: true,

View file

@ -12,6 +12,20 @@ const currencyField = (initial, label, icon) =>
icon: new foundry.data.fields.StringField({ required: true, nullable: false, blank: true, initial: icon }) icon: new foundry.data.fields.StringField({ required: true, nullable: false, blank: true, initial: icon })
}); });
const restMoveField = () =>
new foundry.data.fields.SchemaField({
name: new foundry.data.fields.StringField({ required: true }),
icon: new foundry.data.fields.StringField({ required: true }),
img: new foundry.data.fields.FilePathField({
initial: 'icons/magic/life/cross-worn-green.webp',
categories: ['IMAGE'],
base64: false
}),
description: new foundry.data.fields.HTMLField(),
actions: new ActionsField(),
effects: new foundry.data.fields.ArrayField(new foundry.data.fields.ObjectField())
});
export default class DhHomebrew extends foundry.abstract.DataModel { export default class DhHomebrew extends foundry.abstract.DataModel {
static defineSchema() { static defineSchema() {
const fields = foundry.data.fields; const fields = foundry.data.fields;
@ -105,37 +119,11 @@ export default class DhHomebrew extends foundry.abstract.DataModel {
restMoves: new fields.SchemaField({ restMoves: new fields.SchemaField({
longRest: new fields.SchemaField({ longRest: new fields.SchemaField({
nrChoices: new fields.NumberField({ required: true, integer: true, min: 1, initial: 2 }), nrChoices: new fields.NumberField({ required: true, integer: true, min: 1, initial: 2 }),
moves: new fields.TypedObjectField( moves: new fields.TypedObjectField(restMoveField(), { initial: defaultRestOptions.longRest() })
new fields.SchemaField({
name: new fields.StringField({ required: true }),
icon: new fields.StringField({ required: true }),
img: new fields.FilePathField({
initial: 'icons/magic/life/cross-worn-green.webp',
categories: ['IMAGE'],
base64: false
}),
description: new fields.HTMLField(),
actions: new ActionsField()
}),
{ initial: defaultRestOptions.longRest() }
)
}), }),
shortRest: new fields.SchemaField({ shortRest: new fields.SchemaField({
nrChoices: new fields.NumberField({ required: true, integer: true, min: 1, initial: 2 }), nrChoices: new fields.NumberField({ required: true, integer: true, min: 1, initial: 2 }),
moves: new fields.TypedObjectField( moves: new fields.TypedObjectField(restMoveField(), { initial: defaultRestOptions.shortRest() })
new fields.SchemaField({
name: new fields.StringField({ required: true }),
icon: new fields.StringField({ required: true }),
img: new fields.FilePathField({
initial: 'icons/magic/life/cross-worn-green.webp',
categories: ['IMAGE'],
base64: false
}),
description: new fields.HTMLField(),
actions: new ActionsField()
}),
{ initial: defaultRestOptions.shortRest() }
)
}) })
}), }),
domains: new fields.TypedObjectField( domains: new fields.TypedObjectField(

View file

@ -96,6 +96,19 @@ export default class DHRoll extends Roll {
} }
static async toMessage(roll, config) { static async toMessage(roll, config) {
const item = config.data.parent?.items?.get?.(config.source.item) ?? null;
const action = item ? item.system.actions.get(config.source.action) : null;
let actionDescription = null;
if (action?.chatDisplay) {
actionDescription = action
? await foundry.applications.ux.TextEditor.implementation.enrichHTML(action.description, {
relativeTo: config.data,
rollData: config.data.getRollData?.() ?? {}
})
: null;
config.actionChatMessageHandled = true;
}
const cls = getDocumentClass('ChatMessage'), const cls = getDocumentClass('ChatMessage'),
msgData = { msgData = {
type: this.messageType, type: this.messageType,
@ -103,7 +116,7 @@ export default class DHRoll extends Roll {
title: roll.title, title: roll.title,
speaker: cls.getSpeaker({ actor: roll.data?.parent }), speaker: cls.getSpeaker({ actor: roll.data?.parent }),
sound: config.mute ? null : CONFIG.sounds.dice, sound: config.mute ? null : CONFIG.sounds.dice,
system: config, system: { ...config, actionDescription },
rolls: [roll] rolls: [roll]
}; };

View file

@ -409,7 +409,9 @@ export default class DualityRoll extends D20Roll {
difficulty: message.system.roll.difficulty ? Number(message.system.roll.difficulty) : null difficulty: message.system.roll.difficulty ? Number(message.system.roll.difficulty) : null
} }
}); });
newRoll.extra = newRoll.extra.slice(2);
const extraIndex = newRoll.advantage ? 3 : 2;
newRoll.extra = newRoll.extra.slice(extraIndex);
const tagTeamSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll); const tagTeamSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.TagTeamRoll);

View file

@ -61,14 +61,15 @@ export default class DhActiveEffect extends foundry.documents.ActiveEffect {
update.img = 'icons/magic/life/heart-cross-blue.webp'; update.img = 'icons/magic/life/heart-cross-blue.webp';
} }
const statuses = Object.keys(data.statuses ?? {});
const immuneStatuses = const immuneStatuses =
data.statuses?.filter( statuses.filter(
status => status =>
this.parent.system.rules?.conditionImmunities && this.parent.system.rules?.conditionImmunities &&
this.parent.system.rules.conditionImmunities[status] this.parent.system.rules.conditionImmunities[status]
) ?? []; ) ?? [];
if (immuneStatuses.length > 0) { if (immuneStatuses.length > 0) {
update.statuses = data.statuses.filter(x => !immuneStatuses.includes(x)); update.statuses = statuses.filter(x => !immuneStatuses.includes(x));
const conditions = CONFIG.DH.GENERAL.conditions(); const conditions = CONFIG.DH.GENERAL.conditions();
const scrollingTexts = immuneStatuses.map(status => ({ const scrollingTexts = immuneStatuses.map(status => ({
text: game.i18n.format('DAGGERHEART.ACTIVEEFFECT.immuneStatusText', { text: game.i18n.format('DAGGERHEART.ACTIVEEFFECT.immuneStatusText', {
@ -113,6 +114,11 @@ export default class DhActiveEffect extends foundry.documents.ActiveEffect {
super.applyField(model, change, field); super.applyField(model, change, field);
} }
_applyLegacy(actor, change, changes) {
change.value = DhActiveEffect.getChangeValue(actor, change, change.effect);
super._applyLegacy(actor, change, changes);
}
/** */ /** */
static getChangeValue(model, change, effect) { static getChangeValue(model, change, effect) {
let value = change.value; let value = change.value;

View file

@ -110,6 +110,8 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
} else if (s.classList.contains('damage-section')) } else if (s.classList.contains('damage-section'))
s.classList.toggle('expanded', autoExpandRoll.damage); s.classList.toggle('expanded', autoExpandRoll.damage);
else if (s.classList.contains('target-section')) s.classList.toggle('expanded', autoExpandRoll.target); else if (s.classList.contains('target-section')) s.classList.toggle('expanded', autoExpandRoll.target);
else if (s.classList.contains('description-section'))
s.classList.toggle('expanded', autoExpandRoll.desc);
}); });
if (itemDesc && autoExpandRoll.desc) itemDesc.setAttribute('open', ''); if (itemDesc && autoExpandRoll.desc) itemDesc.setAttribute('open', '');
} }

View file

@ -185,7 +185,10 @@ export default class DHItem extends foundry.documents.Item {
tags: this._getTags() tags: this._getTags()
}, },
actions: item.system.actionsList, actions: item.system.actionsList,
description: this.system.description description: await foundry.applications.ux.TextEditor.implementation.enrichHTML(this.system.description, {
relativeTo: this.parent,
rollData: this.parent?.getRollData() ?? {}
})
}; };
const msg = { const msg = {

View file

@ -1,78 +1,30 @@
export default class DHToken extends CONFIG.Token.documentClass { export default class DHToken extends CONFIG.Token.documentClass {
/** /**@inheritdoc */
* Inspect the Actor data model and identify the set of attributes which could be used for a Token Bar. static getTrackedAttributeChoices(attributes, typeKey) {
* @param {object} attributes The tracked attributes which can be chosen from
* @returns {object} A nested object of attribute choices to display
*/
static getTrackedAttributeChoices(attributes, model) {
attributes = attributes || this.getTrackedAttributes(); attributes = attributes || this.getTrackedAttributes();
const barGroup = game.i18n.localize('TOKEN.BarAttributes'); const barGroup = game.i18n.localize('TOKEN.BarAttributes');
const valueGroup = game.i18n.localize('TOKEN.BarValues'); 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;
return label ? game.i18n.localize(label) : path;
};
const bars = attributes.bar.map(v => { const bars = attributes.bar.map(v => {
const a = v.join('.'); const a = v.join('.');
const modelLabel = model ? game.i18n.localize(model.schema.getField(`${a}.value`).label) : null; return { group: barGroup, value: a, label: getLabel(a) };
return { group: barGroup, value: a, label: modelLabel ? modelLabel : a };
}); });
bars.sort((a, b) => a.label.compare(b.label)); bars.sort((a, b) => a.value.compare(b.value));
const invalidAttributes = [ const values = attributes.value.map(v => {
'gold',
'levelData',
'actions',
'biography',
'class',
'multiclass',
'companion',
'notes',
'partner',
'description',
'impulses',
'tier',
'type'
];
const values = attributes.value.reduce((acc, v) => {
const a = v.join('.'); const a = v.join('.');
if (invalidAttributes.some(x => a.startsWith(x))) return acc; return { group: valueGroup, value: a, label: getLabel(a) };
});
const field = model ? model.schema.getField(a) : null;
const modelLabel = field ? game.i18n.localize(field.label) : null;
const hint = field ? game.i18n.localize(field.hint) : null;
acc.push({ group: valueGroup, value: a, label: modelLabel ? modelLabel : a, hint: hint });
return acc;
}, []);
values.sort((a, b) => a.label.compare(b.label));
values.sort((a, b) => a.value.compare(b.value));
return bars.concat(values); return bars.concat(values);
} }
static _getTrackedAttributesFromSchema(schema, _path = []) {
const attributes = { bar: [], value: [] };
for (const [name, field] of Object.entries(schema.fields)) {
const p = _path.concat([name]);
if (field instanceof foundry.data.fields.NumberField) attributes.value.push(p);
if (field instanceof foundry.data.fields.BooleanField && field.options.isAttributeChoice)
attributes.value.push(p);
if (field instanceof foundry.data.fields.StringField) attributes.value.push(p);
if (field instanceof foundry.data.fields.ArrayField) attributes.value.push(p);
const isSchema = field instanceof foundry.data.fields.SchemaField;
const isModel = field instanceof foundry.data.fields.EmbeddedDataField;
if (isSchema || isModel) {
const schema = isModel ? field.model.schema : field;
const isBar = schema.has && schema.has('value') && schema.has('max');
if (isBar) attributes.bar.push(p);
else {
const inner = this.getTrackedAttributes(schema, p);
attributes.bar.push(...inner.bar);
attributes.value.push(...inner.value);
}
}
}
return attributes;
}
_shouldRecordMovementHistory() { _shouldRecordMovementHistory() {
return false; return false;
} }
@ -269,7 +221,7 @@ export default class DHToken extends CONFIG.Token.documentClass {
// Hexagon symmetry // Hexagon symmetry
if (columns) { if (columns) {
const rowData = BaseToken.#getHexagonalShape(height, width, shape, false); const rowData = DHToken.#getHexagonalShape(height, width, shape, false);
if (!rowData) return null; if (!rowData) return null;
// Transpose the offsets/points of the shape in row orientation // Transpose the offsets/points of the shape in row orientation

View file

@ -86,19 +86,22 @@ export const enrichedDualityRoll = async (
{ reaction, traitValue, target, difficulty, title, label, advantage, grantResources, customConfig }, { reaction, traitValue, target, difficulty, title, label, advantage, grantResources, customConfig },
event event
) => { ) => {
const shouldGrantResources = grantResources === undefined ? true : grantResources;
const config = { const config = {
event: event ?? {}, event: event ?? {},
title: title, title: title,
headerTitle: label, headerTitle: label,
actionType: reaction ? 'reaction' : null,
roll: { roll: {
trait: traitValue && target ? traitValue : null, trait: traitValue && target ? traitValue : null,
difficulty: difficulty, difficulty: difficulty,
advantage, advantage
type: reaction ? 'reaction' : null // type: reaction ? 'reaction' : null //not needed really but keeping it for troubleshooting
}, },
skips: { skips: {
resources: !grantResources, resources: !shouldGrantResources,
triggers: !grantResources triggers: !shouldGrantResources
}, },
type: 'trait', type: 'trait',
hasRoll: true, hasRoll: true,

View file

@ -4,6 +4,7 @@ export default function DhTemplateEnricher(match, _options) {
const params = parseInlineParams(match[1]); const params = parseInlineParams(match[1]);
const { type, angle = CONFIG.MeasuredTemplate.defaults.angle, inline = false } = params; const { type, angle = CONFIG.MeasuredTemplate.defaults.angle, inline = false } = params;
const direction = Number(params.direction) || 0; const direction = Number(params.direction) || 0;
params.range = params.range?.toLowerCase();
const range = const range =
params.range && Number.isNaN(Number(params.range)) params.range && Number.isNaN(Number(params.range))
? Object.values(CONFIG.DH.GENERAL.templateRanges).find( ? Object.values(CONFIG.DH.GENERAL.templateRanges).find(

View file

@ -12,7 +12,7 @@ export function rollCommandToJSON(text) {
const flavor = flavorMatch ? flavorMatch[1] : null; const flavor = flavorMatch ? flavorMatch[1] : null;
// Match key="quoted string" OR key=unquotedValue // Match key="quoted string" OR key=unquotedValue
const PAIR_RE = /(\w+)=("(?:[^"\\]|\\.)*"|\S+)/g; const PAIR_RE = /(\w+)\s*=\s*("(?:[^"\\]|\\.)*"|[^\]\}\s]+)/g; //updated regex to allow escaped quotes in quoted strings and avoid matching closing brackets/braces
const result = {}; const result = {};
for (const [, key, raw] of text.matchAll(PAIR_RE)) { for (const [, key, raw] of text.matchAll(PAIR_RE)) {
let value; let value;
@ -495,3 +495,65 @@ export function htmlToText(html) {
return tempDivElement.textContent || tempDivElement.innerText || ''; return tempDivElement.textContent || tempDivElement.innerText || '';
} }
export async function getFeaturesHTMLData(features) {
const result = [];
for (const feature of features) {
if (feature) {
const base = feature.item ?? feature;
const item = base.system ? base : await foundry.utils.fromUuid(base.uuid);
const itemDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
item.system.description
);
result.push({ label: item.name, description: itemDescription });
}
}
return result;
}
/**
* 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

@ -39,6 +39,7 @@ export const preloadHandlebarsTemplates = async function () {
'systems/daggerheart/templates/dialogs/downtime/activities.hbs', 'systems/daggerheart/templates/dialogs/downtime/activities.hbs',
'systems/daggerheart/templates/dialogs/dice-roll/costSelection.hbs', 'systems/daggerheart/templates/dialogs/dice-roll/costSelection.hbs',
'systems/daggerheart/templates/ui/chat/parts/roll-part.hbs', 'systems/daggerheart/templates/ui/chat/parts/roll-part.hbs',
'systems/daggerheart/templates/ui/chat/parts/description-part.hbs',
'systems/daggerheart/templates/ui/chat/parts/damage-part.hbs', 'systems/daggerheart/templates/ui/chat/parts/damage-part.hbs',
'systems/daggerheart/templates/ui/chat/parts/target-part.hbs', 'systems/daggerheart/templates/ui/chat/parts/target-part.hbs',
'systems/daggerheart/templates/ui/chat/parts/button-part.hbs', 'systems/daggerheart/templates/ui/chat/parts/button-part.hbs',

View file

@ -7,7 +7,7 @@ import {
DhHomebrewSettings, DhHomebrewSettings,
DhVariantRuleSettings DhVariantRuleSettings
} from '../applications/settings/_module.mjs'; } from '../applications/settings/_module.mjs';
import { DhTagTeamRoll } from '../data/_module.mjs'; import { CompendiumBrowserSettings, DhTagTeamRoll } from '../data/_module.mjs';
export const registerDHSettings = () => { export const registerDHSettings = () => {
registerMenuSettings(); registerMenuSettings();
@ -126,7 +126,7 @@ const registerNonConfigSettings = () => {
type: Number, type: Number,
default: 0, default: 0,
onChange: () => { onChange: () => {
if (ui.resources) ui.resources.render({ force: true }); if (ui.resources) ui.resources.render();
ui.combat.render({ force: true }); ui.combat.render({ force: true });
} }
}); });
@ -142,6 +142,12 @@ const registerNonConfigSettings = () => {
config: false, config: false,
type: DhTagTeamRoll type: DhTagTeamRoll
}); });
game.settings.register(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.CompendiumBrowserSettings, {
scope: 'client',
config: false,
type: CompendiumBrowserSettings
});
}; };
/** /**

View file

@ -38,7 +38,8 @@ export const RefreshType = {
Countdown: 'DhCoundownRefresh', Countdown: 'DhCoundownRefresh',
TagTeamRoll: 'DhTagTeamRollRefresh', TagTeamRoll: 'DhTagTeamRollRefresh',
EffectsDisplay: 'DhEffectsDisplayRefresh', EffectsDisplay: 'DhEffectsDisplayRefresh',
Scene: 'DhSceneRefresh' Scene: 'DhSceneRefresh',
CompendiumBrowser: 'DhCompendiumBrowserRefresh'
}; };
export const registerSocketHooks = () => { export const registerSocketHooks = () => {

View file

@ -82,7 +82,7 @@
"enabled": false "enabled": false
}, },
"flatMultiplier": 3, "flatMultiplier": 3,
"dice": "d10", "dice": "d20",
"bonus": null, "bonus": null,
"multiplier": "flat" "multiplier": "flat"
}, },

View file

@ -246,7 +246,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"vgguNWz8vG8aoLXR": { "vgguNWz8vG8aoLXR": {

View file

@ -218,10 +218,10 @@
}, },
"items": [ "items": [
{ {
"name": "Horde (1d6+3)", "name": "Horde",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -388,7 +388,7 @@
"name": "Fumigation", "name": "Fumigation",
"type": "feature", "type": "feature",
"system": { "system": {
"description": "<p>Drop a smoke bomb that fi lls the air within Close range with smoke, Dizzying all targets in this area. Dizzied targets have disadvantage on their next action roll, then clear the condition.</p><p>@Template[type:emanation|range:c]</p>", "description": "<p>Drop a smoke bomb that fills the air within Close range with smoke, Dizzying all targets in this area. Dizzied targets have disadvantage on their next action roll, then clear the condition.</p><p>@Template[type:emanation|range:c]</p>",
"resource": null, "resource": null,
"actions": { "actions": {
"sp7RfJRQJsEUm09m": { "sp7RfJRQJsEUm09m": {

View file

@ -239,7 +239,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"cbAvPSIhwBMBTI3D": { "cbAvPSIhwBMBTI3D": {

View file

@ -239,7 +239,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"EH1preaTWBD4rOvx": { "EH1preaTWBD4rOvx": {

View file

@ -224,10 +224,10 @@
}, },
"items": [ "items": [
{ {
"name": "Horde (2d4+1)", "name": "Horde",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -218,10 +218,10 @@
}, },
"items": [ "items": [
{ {
"name": "Horde (2d4+1)", "name": "Horde",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -239,7 +239,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"vXHZVb0Y7Hqu3uso": { "vXHZVb0Y7Hqu3uso": {

View file

@ -317,7 +317,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"QHNRSEQmqOcaoXq4": { "QHNRSEQmqOcaoXq4": {

View file

@ -229,7 +229,7 @@
"_id": "9RduwBLYcBaiouYk", "_id": "9RduwBLYcBaiouYk",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -248,7 +248,7 @@
"_id": "fsaBlCjTdq1jM23G", "_id": "fsaBlCjTdq1jM23G",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"q8chow47nQLR9qeF": { "q8chow47nQLR9qeF": {

View file

@ -55,7 +55,7 @@
"max": 1 "max": 1
}, },
"stress": { "stress": {
"max": 1 "max": 2
} }
}, },
"attack": { "attack": {
@ -239,7 +239,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"DjbPQowW1OdBD9Zn": { "DjbPQowW1OdBD9Zn": {

View file

@ -55,7 +55,7 @@
"max": 1 "max": 1
}, },
"stress": { "stress": {
"max": 1 "max": 2
} }
}, },
"attack": { "attack": {
@ -294,7 +294,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"eo7J0v1B5zPHul1M": { "eo7J0v1B5zPHul1M": {

View file

@ -248,7 +248,7 @@
"_id": "1k5TmQIAunM7Bv32", "_id": "1k5TmQIAunM7Bv32",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"aoQDb2m32NDxE6ZP": { "aoQDb2m32NDxE6ZP": {

View file

@ -33,7 +33,7 @@
"reduction": 0 "reduction": 0
} }
}, },
"type": "standard", "type": "ranged",
"notes": "", "notes": "",
"hordeHp": 1, "hordeHp": 1,
"experiences": { "experiences": {

View file

@ -104,6 +104,7 @@
] ]
}, },
"type": "attack", "type": "attack",
"range": "melee",
"chatDisplay": false "chatDisplay": false
}, },
"attribution": { "attribution": {

View file

@ -242,7 +242,7 @@
"_id": "K08WlZwGqzEo4idT", "_id": "K08WlZwGqzEo4idT",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"xTMNAHcoErKuR6TZ": { "xTMNAHcoErKuR6TZ": {

View file

@ -33,7 +33,7 @@
"reduction": 0 "reduction": 0
} }
}, },
"type": "standard", "type": "bruiser",
"notes": "", "notes": "",
"hordeHp": 1, "hordeHp": 1,
"experiences": {}, "experiences": {},
@ -66,12 +66,12 @@
"tier": 3, "tier": 3,
"description": "<p>A sturdy animate old-growth tree.</p>", "description": "<p>A sturdy animate old-growth tree.</p>",
"attack": { "attack": {
"name": "Attack", "name": "Branch",
"roll": { "roll": {
"type": "attack", "type": "attack",
"bonus": 2 "bonus": 2
}, },
"range": "close", "range": "veryClose",
"damage": { "damage": {
"parts": [ "parts": [
{ {

View file

@ -97,7 +97,7 @@
}, },
"img": "icons/creatures/claws/claw-talons-yellow-red.webp", "img": "icons/creatures/claws/claw-talons-yellow-red.webp",
"type": "attack", "type": "attack",
"range": "melee", "range": "veryClose",
"chatDisplay": false "chatDisplay": false
}, },
"attribution": { "attribution": {
@ -239,7 +239,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"tvQetauskZoHDR5y": { "tvQetauskZoHDR5y": {

View file

@ -229,7 +229,7 @@
"_id": "Q7DRbWjHl64CNwag", "_id": "Q7DRbWjHl64CNwag",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -242,7 +242,7 @@
"_id": "R9vrwFNl5BD1YXJo", "_id": "R9vrwFNl5BD1YXJo",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"DJBNtd3hWjwsjPwq": { "DJBNtd3hWjwsjPwq": {

View file

@ -242,7 +242,7 @@
"_id": "CQZQiEiRH70Br5Ge", "_id": "CQZQiEiRH70Br5Ge",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"ghgFZskDiizJDjcn": { "ghgFZskDiizJDjcn": {

View file

@ -242,7 +242,7 @@
"_id": "wl9KKEpVWDBu62hU", "_id": "wl9KKEpVWDBu62hU",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"Sz55uB8xkoNytLwJ": { "Sz55uB8xkoNytLwJ": {

View file

@ -223,7 +223,7 @@
"_id": "9Zuu892SO5NmtI4w", "_id": "9Zuu892SO5NmtI4w",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -254,12 +254,12 @@
}, },
"items": [ "items": [
{ {
"name": "Horde (1d4+2)", "name": "Horde",
"type": "feature", "type": "feature",
"_id": "4dSzqtYvH385r9Ng", "_id": "4dSzqtYvH385r9Ng",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -281,7 +281,7 @@
"_id": "WiobzuyvJ46zfsOv", "_id": "WiobzuyvJ46zfsOv",
"img": "icons/creatures/abilities/tail-strike-bone-orange.webp", "img": "icons/creatures/abilities/tail-strike-bone-orange.webp",
"system": { "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, "resource": null,
"actions": { "actions": {
"ZC5pKIb9N82vgMWu": { "ZC5pKIb9N82vgMWu": {

View file

@ -97,8 +97,8 @@
] ]
}, },
"type": "attack", "type": "attack",
"chatDisplay": false, "range": "melee",
"range": "" "chatDisplay": false
}, },
"attribution": { "attribution": {
"source": "Daggerheart SRD", "source": "Daggerheart SRD",
@ -239,7 +239,7 @@
"name": "Group Attack", "name": "Group Attack",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": { "actions": {
"euP8VA4wvfsCpwN1": { "euP8VA4wvfsCpwN1": {

View file

@ -110,7 +110,7 @@
}, },
"img": "icons/creatures/claws/claw-scaled-red.webp", "img": "icons/creatures/claws/claw-scaled-red.webp",
"type": "attack", "type": "attack",
"range": "melee", "range": "close",
"chatDisplay": false "chatDisplay": false
}, },
"attribution": { "attribution": {

View file

@ -97,7 +97,7 @@
} }
] ]
}, },
"name": "Tentacles", "name": "Undead Hands",
"roll": { "roll": {
"bonus": 2, "bonus": 2,
"type": "attack" "type": "attack"
@ -218,10 +218,10 @@
}, },
"items": [ "items": [
{ {
"name": "Horde (2d6+5)", "name": "Horde",
"type": "feature", "type": "feature",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -218,12 +218,12 @@
}, },
"items": [ "items": [
{ {
"name": "Horde (1d4+2)", "name": "Horde",
"type": "feature", "type": "feature",
"_id": "nNJGAhWu0IuS2ybn", "_id": "nNJGAhWu0IuS2ybn",
"img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp", "img": "icons/creatures/magical/humanoid-silhouette-aliens-green.webp",
"system": { "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, "resource": null,
"actions": {}, "actions": {},
"originItemType": null, "originItemType": null,

View file

@ -4,7 +4,7 @@
"type": "ancestry", "type": "ancestry",
"folder": null, "folder": null,
"system": { "system": {
"description": "<p>Clanks are sentient mechanical beings built from a variety of materials, including metal, wood, and stone. They can resemble humanoids, animals, or even inanimate objects. Like organic beings, their bodies come in a wide array of sizes. Because of their bespoke construction, many clanks have highly specialized physical configurations. Examples include clawed hands for grasping, wheels for movement, or built-in weaponry.</p><p class=\"green Body-Styles_Body\">Many clanks embrace body modifications for style as well as function, and members of other ancestries often turn to clank artisans to construct customized mobility aids and physical adornments. Other ancestries can create clanks, even using their own physical characteristics as inspiration, but its also common for clanks to build one another. A clanks lifespan extends as long as theyre able to acquire or craft new parts, making their physical form effectively immortal. That said, their minds are subject to the effects of time, and deteriorate as the magic that powers them loses potency.</p><p></p><h4>ANCESTRY FEATURES</h4><p><em><strong>Purposeful Design:</strong></em> Decide who made you and for what purpose. At character creation, choose one of your Experiences that best aligns with this purpose and gain a permanent +1 bonus to it.</p><p><em><strong>Efficient:</strong></em> When you take a short rest, you can choose a long rest move instead of a short rest move.</p><p></p>", "description": "<p>Clanks are sentient mechanical beings built from a variety of materials, including metal, wood, and stone. They can resemble humanoids, animals, or even inanimate objects. Like organic beings, their bodies come in a wide array of sizes. Because of their bespoke construction, many clanks have highly specialized physical configurations. Examples include clawed hands for grasping, wheels for movement, or built-in weaponry.</p><p class=\"green Body-Styles_Body\">Many clanks embrace body modifications for style as well as function, and members of other ancestries often turn to clank artisans to construct customized mobility aids and physical adornments. Other ancestries can create clanks, even using their own physical characteristics as inspiration, but its also common for clanks to build one another. A clanks lifespan extends as long as theyre able to acquire or craft new parts, making their physical form effectively immortal. That said, their minds are subject to the effects of time, and deteriorate as the magic that powers them loses potency.</p>",
"features": [ "features": [
{ {
"type": "primary", "type": "primary",

View file

@ -4,7 +4,7 @@
"type": "ancestry", "type": "ancestry",
"folder": null, "folder": null,
"system": { "system": {
"description": "<p>Drakona resemble wingless dragons in humanoid form and possess a powerful elemental breath. All drakona have thick scales that provide excellent natural armor against both attacks and the forces of nature. They are large in size, ranging from 5 feet to 7 feet on average, with long sharp teeth. New teeth grow throughout a Drakonas approximately 350-year lifespan, so they are never in danger of permanently losing an incisor. Unlike their dragon ancestors, drakona dont have wings and cant fly without magical aid. Members of this ancestry pass down the element of their breath through generations, though in rare cases, a drakonas elemental power will differ from the rest of their familys.</p><p></p><h4>ANCESTRY FEATURES</h4><p><em><strong>Scales:</strong></em> Your scales act as natural protection. When you would take Severe damage, you can <strong>mark a Stress</strong> to mark 1 fewer Hit Points.</p><p><em><strong>Elemental Breath:</strong></em> Choose an element for your breath (such as electricity, fire, or ice). You can use this breath against a target or group of targets within Very Close range, treating it as an Instinct weapon that deals <strong>d8</strong> magic damage using your Proficiency.</p><p></p>", "description": "<p>Drakona resemble wingless dragons in humanoid form and possess a powerful elemental breath. All drakona have thick scales that provide excellent natural armor against both attacks and the forces of nature. They are large in size, ranging from 5 feet to 7 feet on average, with long sharp teeth. New teeth grow throughout a Drakonas approximately 350-year lifespan, so they are never in danger of permanently losing an incisor. Unlike their dragon ancestors, drakona dont have wings and cant fly without magical aid. Members of this ancestry pass down the element of their breath through generations, though in rare cases, a drakonas elemental power will differ from the rest of their familys.</p>",
"features": [ "features": [
{ {
"type": "primary", "type": "primary",

View file

@ -4,7 +4,7 @@
"type": "ancestry", "type": "ancestry",
"folder": null, "folder": null,
"system": { "system": {
"description": "<p>Dwarves are most easily recognized as short humanoids with square frames, dense musculature, and thick hair. Their average height ranges from 4 to 5 ½ feet, and they are often broad in proportion to their stature. Their skin and nails contain a high amount of keratin, making them naturally resilient. This allows dwarves to embed gemstones into their bodies and decorate themselves with tattoos or piercings. Their hair grows thickly—usually on their heads, but some dwarves have thick hair across their bodies as well. Dwarves of all genders can grow facial hair, which they often style in elaborate arrangements. Typically, dwarves live up to 250 years of age, maintaining their muscle mass well into later life.</p><p></p><h4>ANCESTRY FEATURES</h4><p><em><strong>Thick Skin:</strong></em> When you take Minor damage, you can <strong>mark 2 Stress</strong> instead of marking a Hit Point.</p><p><em><strong>Increased Fortitude:</strong></em> <strong>Spend 3 Hope</strong> to halve incoming physical damage.</p>", "description": "<p>Dwarves are most easily recognized as short humanoids with square frames, dense musculature, and thick hair. Their average height ranges from 4 to 5 ½ feet, and they are often broad in proportion to their stature. Their skin and nails contain a high amount of keratin, making them naturally resilient. This allows dwarves to embed gemstones into their bodies and decorate themselves with tattoos or piercings. Their hair grows thickly—usually on their heads, but some dwarves have thick hair across their bodies as well. Dwarves of all genders can grow facial hair, which they often style in elaborate arrangements. Typically, dwarves live up to 250 years of age, maintaining their muscle mass well into later life.</p>",
"features": [ "features": [
{ {
"type": "primary", "type": "primary",

View file

@ -4,7 +4,7 @@
"type": "ancestry", "type": "ancestry",
"folder": null, "folder": null,
"system": { "system": {
"description": "<p>Elves are typically tall humanoids with pointed ears and acutely attuned senses. Their ears vary in size and pointed shape, and as they age, the tips begin to droop. While elves come in a wide range of body types, they are all fairly tall, with heights ranging from about 6 to 6 ½ feet. All elves have the ability to drop into a celestial trance, rather than sleep. This allows them to rest effectively in a short amount of time.</p><p class=\"green Body-Styles_Body\">Some elves possess what is known as a “mystic form,” which occurs when an elf has dedicated themself to the study or protection of the natural world so deeply that their physical form changes. These characteristics can include celestial freckles, the presence of leaves, vines, or flowers in their hair, eyes that flicker like fire, and more. Sometimes these traits are inherited from parents, but if an elf changes their environment or magical focus, their appearance changes over time. Because elves live for about 350 years, these traits can shift more than once throughout their lifespan.</p><p></p><h4>ANCESTRY FEATURES</h4><p><em><strong>Quick Reactions:</strong></em> <strong>Mark a Stress</strong> to gain advantage on a reaction roll.</p><p><em><strong>Celestial Trance:</strong></em> During a rest, you can drop into a trance to choose an additional downtime move.</p><p></p>", "description": "<p>Elves are typically tall humanoids with pointed ears and acutely attuned senses. Their ears vary in size and pointed shape, and as they age, the tips begin to droop. While elves come in a wide range of body types, they are all fairly tall, with heights ranging from about 6 to 6 ½ feet. All elves have the ability to drop into a celestial trance, rather than sleep. This allows them to rest effectively in a short amount of time.</p><p class=\"green Body-Styles_Body\">Some elves possess what is known as a “mystic form,” which occurs when an elf has dedicated themself to the study or protection of the natural world so deeply that their physical form changes. These characteristics can include celestial freckles, the presence of leaves, vines, or flowers in their hair, eyes that flicker like fire, and more. Sometimes these traits are inherited from parents, but if an elf changes their environment or magical focus, their appearance changes over time. Because elves live for about 350 years, these traits can shift more than once throughout their lifespan.</p>",
"features": [ "features": [
{ {
"type": "primary", "type": "primary",

Some files were not shown because too many files have changed in this diff Show more