Compare commits

..

56 commits
1.6.2 ... main

Author SHA1 Message Date
WBHarry
f1f5102af1 Raised version 2026-03-07 01:33:45 +01:00
WBHarry
a4f8c67707
Added vulnerable condition automation (#1704) 2026-03-07 01:32:36 +01:00
WBHarry
83c3da0130
Fixed so that Rally dice works as a single active effect for bard (#1708) 2026-03-07 00:08:03 +01:00
WBHarry
92d8c2ca18 Moved RefreshFeatures to utils and added a proper description 2026-03-07 00:06:12 +01:00
WBHarry
17aa0680d2
[Fix] 1697 - DamageRolls DiceSoNice (#1706)
* .

* .

* .
2026-03-06 13:07:55 +01:00
WBHarry
5732639391
. (#1705) 2026-03-06 11:07:52 +01:00
WBHarry
9bfe3505bf
[Fix] 1696 - Homebrew Fixes (#1707)
* SettingsActiveEffectConfig was out of date, making it error

* Other two fixes

* .

* .
2026-03-06 11:03:55 +01:00
WBHarry
9cba77ec11
[Fix] 1687 - Item Transfer Without Permissions (#1691)
* Fixed so that transfering items completly aborts if lacking permissions

* .
2026-03-05 22:14:07 +01:00
Carlos Fernandez
0675e1f019
Add recall cost to domain cards in grid view (#1700) 2026-03-05 21:31:49 +01:00
Carlos Fernandez
1212bd01f8
Increase the click area of sidebar and inventory control buttons (#1703) 2026-03-05 21:30:31 +01:00
Psitacus
3267f3f531
fix instances of rolls being called checks (#1702)
Co-authored-by: Psitacus <walther.johnson@ucalgary.ca>
2026-03-05 09:56:35 +01:00
WBHarry
986544a653
[Fix] 1689 - Missing Feature Errors (#1690)
* Fixed so that weaponfeatures and armorFeatures are tolerant of features having been removed

* .
2026-03-04 22:16:11 +10:00
WBHarry
5459581f7f
Fixed styling in firefox (#1692) 2026-03-04 01:10:40 +01:00
WBHarry
0d0b5125ba
[Fix] 1683 - Strange Patterns Explanation (#1693)
* Added an explanation text to Strange Patterns trigger dialog

* Update lang/en.json

Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com>

---------

Co-authored-by: Chris Ryan <73275196+chrisryan10@users.noreply.github.com>
2026-03-02 09:37:33 +01:00
WBHarry
c48842dd2d Fixed error on deleting a sceneEnvironment item 2026-02-26 20:04:59 +01:00
WBHarry
e79ccd34e9
[Fix] 1671 - Compendium Context Menues (#1677)
* Fixed

* .
2026-02-26 11:42:42 +01:00
Carlos Fernandez
4324c3abf2
[Fix] Support elevation in token distance hovering and fix error when overlapping (#1675)
* Support elevation in token distance hovering

* Reduce diffs

* Refine elevation check to handle stacked tokens

* Fix issue with overlapping tokens

* Fix tooltip reporting very close for adjacent diagonal tokens
2026-02-26 11:37:40 +01:00
WBHarry
1b09b44d6c
[Fix] 1676 - Horde Damage Fix (#1678)
* Fixed so that horde damage reduction is only applied to the standard attack

* Changed to just adding 'isStandardAttack' in adversary data prep

* .
2026-02-26 11:32:05 +01:00
Cipher
340abbc98c
Fix incorrect adversary data (ranges, stats, types, names) (#1680)
- Adult Flickerfly: damage dice d10 → d20
- Giant Recruit: stress max 1 → 2
- Hallowed Soldier: stress max 1 → 2
- Jagged Knife Sniper: type standard → ranged
- Minor Demon: add missing melee range
- Oak Treant: type standard → bruiser, attack name → Branch, range → veryClose
- Outer Realms Thrall: range melee → veryClose
- Treant Sapling: add missing melee range
- Young Ice Dragon: range melee → close
- Zombie Legion: attack name Tentacles → Undead Hands

Co-authored-by: Sebastian Will <sebastian.h.will@gmail.com>
2026-02-23 16:34:06 +01:00
WBHarry
56cc16b39a
[Feature] Item Description Enrichment (#1666)
* Added enrichment for Ancestries and Communities

* Fixed remainder

* Bit of padding

* Increased left padding
2026-02-22 21:32:35 +01:00
WBHarry
267de9a8cf
Fixed so that saving custom scene measurements work (#1664) 2026-02-22 14:12:26 +01:00
WBHarry
9296b8fcc2
Fixed so that scars are applied to hope.max during derived data prep (#1673) 2026-02-22 14:10:06 +01:00
WBHarry
ca434d33f1
Fixed so that advantage/disadvantage dice are properly considered when rerolling (#1662) 2026-02-14 13:09:13 +01:00
WBHarry
b64a9002ea
Fixed advantage/disadvantage sources for adversaries and companions (#1659) 2026-02-14 13:07:47 +01:00
WBHarry
472f876ea3 Merge branch 'development' 2026-02-12 22:27:59 +01:00
Carlos Fernandez
7022630316
[PR][Feature] Add support for changing the tier of an adversary (#1503)
* Add support for changing the tier of an adversary

* Move scaling data to actorConfig

* Use a new algorithm using the median average deviation

* Fine tune damage conversion for actions

* Use standard deviation instead and change dialog type

* Use daggerheart style for dialog

* Formatting

* Improve handling of minions and hordes

* Changed to using lookup for Group Attack damage

* Added lookup for Horde feature

* Remove spaces in damage formulas

---------

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

* Raised version
2026-02-11 23:59:27 +01:00
WBHarry
95d4003045
Fixed so that messages auto expand the description (#1650) 2026-02-11 23:56:35 +01:00
WBHarry
fa19339868 Fixed better sceneNavigation compatability 2026-02-11 23:34:10 +01:00
WBHarry
17ec77a349 Fixed not being able to open the tokenConfig of actor-less tokens 2026-02-11 00:31:45 +01:00
WBHarry
a65514b1c1 Improved Downtime Prepare translation 2026-02-09 14:25:56 +01:00
WBHarry
b23b6c75fb
[Feature] Browser Compendium Handling (#1648)
* Initial version

* .

* Fixed so that CompendiumSetting saving refreshes the CompendiumBrowser for all users

* .

* Improved design

* Fixed max height

* Fixed local reload

* Added GM restriction

* Raised version

* Fixed tooltip

* Raised verison to 1.7.0
2026-02-09 12:42:00 +01:00
WBHarry
7c86417752 Added _applyLegacy parse logic for ActiveEffect 2026-02-09 12:41:13 +01:00
WBHarry
c7431d16a7
Improved the Reaction toggle in dice rolls (#1643) 2026-02-09 01:02:59 +01:00
WBHarry
5413730108 Corrected experience value display to handle negative values aswell 2026-02-09 00:25:18 +01:00
WBHarry
d96e72505a Fixed Armor/Weapon sheet showing double 'Configure Attribution' options 2026-02-08 22:21:36 +01:00
WBHarry
f9f252c7a6
Fixed so that node start can accept escaped spaces in the path (#1649) 2026-02-08 19:22:48 +01:00
WBHarry
78012be6e4 Added delete confirmation to homebrew items 2026-02-08 19:14:04 +01:00
alterNERDtive
4ad8b960b5
fix: adds actions to prepare downtime actions (#1646) 2026-02-08 18:30:32 +01:00
WBHarry
f7e4c5346e
[Fix] ActiveEffect Autocomplete (#1641)
* Added rules and bonuses to ActiveEffect-autocomplete

* .
2026-02-08 18:03:35 +01:00
WBHarry
44131d21a6
[Fix] Beastform Effects (#1635)
* Fixed so that beastform items always have a beastformEffect on them that can't be removed

* Fixed so that you can drag an active effect onto a character
2026-02-08 18:01:30 +01:00
WBHarry
202e624a06
Fixed so that SecondWind has a decreasing resource (#1642) 2026-02-08 18:00:09 +01:00
WBHarry
5e7201bfe9
[Fix]Environment Attack Error (#1647)
* Fixed so that environment attacks don't error

* Fixed for companion aswell
2026-02-08 17:59:08 +01:00
alterNERDtive
cad3f533ad
fix: restricts target amount for downtime actions (#1645) 2026-02-07 23:14:17 +01:00
alterNERDtive
c3653e1b30
feat(dev): adds editorconfig (#1644) 2026-02-07 20:02:28 +01:00
WBHarry
c1f7866594
[Fix] 1633 - ActiveEffect Autocomplete (#1636)
* Improved the autocomplete typing experience

* Made it work. But I hate it.

* Revert "Made it work. But I hate it."

This reverts commit d2fc9fd648.

* Actually nice solution instead O_O
2026-02-06 11:32:33 +01:00
WBHarry
0d2495c143
Fixed fumigation and bold presence (#1638) 2026-02-06 00:36:52 +01:00
WBHarry
cab185df66
Made gridless distances lean towards being in the lower range (#1639) 2026-02-05 16:36:49 -05:00
WBHarry
735ed4c214
[Fix] RollMessage Order (#1626)
* Fixed so that the description message always comes first with the action workflow

* Changed to instead render the description in the roll message

* Made the action config title not get changed in d20rolldialog if it's not a trait roll

* Initial chat message description design change

* Revert "Initial chat message description design change"

This reverts commit f4f5fd6c24.

* .
2026-02-04 07:11:18 +01:00
WBHarry
c8d1ea1460
[Fix] TriggerConfig Expand/Collapse (#1630)
* Fixed so that the angle icon indicating expand/collapse for a trigger config actually flips when clicked

* Fixed styles
2026-02-03 23:31:18 +01:00
WBHarry
c1924534da Fixed a case where SceneConfig could fail to save because flags were not submitted 2026-02-03 23:26:29 +01:00
WBHarry
31c70469ef
[Fix] ActionField UUID for Actor Actions (#1622)
* Fixed so that actionFields correctly grab a uuid for actions that are directly on an actor

* Corrected to logical or

* Update module/data/fields/actionField.mjs

Co-authored-by: Carlos Fernandez <CarlosFdez@users.noreply.github.com>

---------

Co-authored-by: Carlos Fernandez <CarlosFdez@users.noreply.github.com>
2026-02-02 00:57:17 +01:00
WBHarry
491d921a9b
Made sure item.toChat enriches descriptions (#1625) 2026-02-02 00:40:09 +01:00
177 changed files with 2721 additions and 793 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 () => {
@ -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",
@ -343,6 +352,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",
@ -443,9 +458,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"
@ -476,7 +495,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": {
@ -1008,7 +1031,8 @@
}, },
"vulnerable": { "vulnerable": {
"name": "Vulnerable", "name": "Vulnerable",
"description": "While a creature is Vulnerable, all rolls targeting them have advantage.\nA creature who is already Vulnerable cant be made to take the condition again." "description": "While a creature is Vulnerable, all rolls targeting them have advantage.\nA creature who is already Vulnerable cant be made to take the condition again.",
"autoAppliedByLabel": "Max Stress"
} }
}, },
"CountdownType": { "CountdownType": {
@ -1143,12 +1167,12 @@
}, },
"far": { "far": {
"name": "Far", "name": "Far",
"description": "means a distance where one can see the appearance of a person or object, but probably not in great detail-- across a small battlefield or down a large corridor. This is usually about 30-100 feet away. While under danger, a PC will likely have to make an Agility check to get here safely. Anything on a battle map that is within the length of a standard piece of paper (~10-11 inches) can usually be considered far.", "description": "means a distance where one can see the appearance of a person or object, but probably not in great detail-- across a small battlefield or down a large corridor. This is usually about 30-100 feet away. While under danger, a PC will likely have to make an Agility roll to get here safely. Anything on a battle map that is within the length of a standard piece of paper (~10-11 inches) can usually be considered far.",
"short": "Far" "short": "Far"
}, },
"veryFar": { "veryFar": {
"name": "Very Far", "name": "Very Far",
"description": "means a distance where you can see the shape of a person or object, but probably not make outany details-- across a large battlefield or down a long street, generally about 100-300 feet away. While under danger, a PC likely has to make an Agility check to get here safely. Anything on a battle map that is beyond far distance, but still within sight of the characters can usually be considered very far.", "description": "means a distance where you can see the shape of a person or object, but probably not make outany details-- across a large battlefield or down a long street, generally about 100-300 feet away. While under danger, a PC likely has to make an Agility roll to get here safely. Anything on a battle map that is beyond far distance, but still within sight of the characters can usually be considered very far.",
"short": "V. Far" "short": "V. Far"
} }
}, },
@ -1271,6 +1295,7 @@
"triggerTexts": { "triggerTexts": {
"strangePatternsContentTitle": "Matched {nr} times.", "strangePatternsContentTitle": "Matched {nr} times.",
"strangePatternsContentSubTitle": "Increase hope and stress to a total of {nr}.", "strangePatternsContentSubTitle": "Increase hope and stress to a total of {nr}.",
"strangePatternsActionExplanation": "Left click to increase, right click to decrease",
"ferocityContent": "Spend 2 Hope to gain {bonus} bonus Evasion until after the next attack against you?", "ferocityContent": "Spend 2 Hope to gain {bonus} bonus Evasion until after the next attack against you?",
"ferocityEffectDescription": "Your evasion is increased by {bonus}. This bonus lasts until after the next attack made against you." "ferocityEffectDescription": "Your evasion is increased by {bonus}. This bonus lasts until after the next attack made against you."
}, },
@ -1840,6 +1865,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",
@ -2024,16 +2059,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",
@ -2051,6 +2110,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": {
@ -2120,7 +2185,9 @@
"configuration": "Configuration", "configuration": "Configuration",
"base": "Base", "base": "Base",
"triggers": "Triggers", "triggers": "Triggers",
"deathMoves": "Deathmoves" "deathMoves": "Deathmoves",
"sources": "Sources",
"packs": "Packs"
}, },
"Tiers": { "Tiers": {
"singular": "Tier", "singular": "Tier",
@ -2152,6 +2219,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",
@ -2257,6 +2325,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",
@ -2295,7 +2364,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",
@ -2348,7 +2418,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",
@ -2361,6 +2436,9 @@
} }
} }
}, },
"Community": {
"featuresLabel": "Community Feature"
},
"Consumable": { "Consumable": {
"consumeOnUse": "Consume On Use", "consumeOnUse": "Consume On Use",
"destroyOnEmpty": "Destroy On Empty" "destroyOnEmpty": "Destroy On Empty"
@ -2376,7 +2454,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",
@ -2475,6 +2557,10 @@
"gm": { "label": "GM" }, "gm": { "label": "GM" },
"players": { "label": "Players" } "players": { "label": "Players" }
}, },
"vulnerableAutomation": {
"label": "Vulnerable Automation",
"hint": "Automatically apply the Vulnerable condition when a actor reaches max stress"
},
"countdownAutomation": { "countdownAutomation": {
"label": "Countdown Automation", "label": "Countdown Automation",
"hint": "Automatically progress countdowns based on their progression settings" "hint": "Automatically progress countdowns based on their progression settings"
@ -2555,6 +2641,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" },
@ -2723,7 +2811,7 @@
"title": "Domain Card" "title": "Domain Card"
}, },
"dualityRoll": { "dualityRoll": {
"abilityCheckTitle": "{ability} Check" "abilityCheckTitle": "{ability} Roll"
}, },
"effectSummary": { "effectSummary": {
"title": "Effects Applied", "title": "Effects Applied",
@ -2738,7 +2826,7 @@
"selectLeader": "Select a Leader", "selectLeader": "Select a Leader",
"selectMember": "Select a Member", "selectMember": "Select a Member",
"rerollTitle": "Reroll Group Roll", "rerollTitle": "Reroll Group Roll",
"rerollContent": "Are you sure you want to reroll your {trait} check?", "rerollContent": "Are you sure you want to reroll your {trait} roll?",
"rerollTooltip": "Reroll", "rerollTooltip": "Reroll",
"wholePartySelected": "The whole party is selected" "wholePartySelected": "The whole party is selected"
}, },
@ -2786,6 +2874,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",
@ -2903,14 +2992,17 @@
"tokenActorMissing": "{name} is missing an Actor", "tokenActorMissing": "{name} is missing an Actor",
"tokenActorsMissing": "[{names}] missing Actors", "tokenActorsMissing": "[{names}] missing Actors",
"domainTouchRequirement": "This domain card requires {nr} {domain} cards in the loadout to be used", "domainTouchRequirement": "This domain card requires {nr} {domain} cards in the loadout to be used",
"knowTheTide": "Know The Tide gained a token" "knowTheTide": "Know The Tide gained a token",
"lackingItemTransferPermission": "User {user} lacks owner permission needed to transfer items to {target}"
}, },
"Sidebar": { "Sidebar": {
"actorDirectory": { "actorDirectory": {
"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",
@ -2942,7 +3034,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,9 +165,10 @@ 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;
this.config.title = game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', { if (!this.config.source.item)
ability: game.i18n.localize(abilities[this.config.roll.trait]?.label) this.config.title = game.i18n.format('DAGGERHEART.UI.Chat.dualityRoll.abilityCheckTitle', {
}); ability: game.i18n.localize(abilities[this.config.roll.trait]?.label)
});
} }
this.config.extraFormula = rest.extraFormula; this.config.extraFormula = rest.extraFormula;
this.render(); this.render();

View file

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

@ -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;
@ -181,6 +187,7 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
}); });
} }
game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject());
this.render(); this.render();
} }
@ -221,16 +228,28 @@ export default class DhHomebrewSettings extends HandlebarsApplicationMixin(Appli
} }
}); });
game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject());
this.render(); this.render();
} }
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}`;
await this.settings.updateSource({ await this.settings.updateSource({
[`${path}.-=${id}`]: null [`${path}.-=${id}`]: null
}); });
game.settings.set(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew, this.settings.toObject());
this.render(); this.render();
} }

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,22 +4,7 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
constructor(options) { constructor(options) {
super(options); super(options);
const ignoredActorKeys = ['config', 'DhEnvironment']; this.changeChoices = DhActiveEffectConfig.getChangeChoices();
this.changeChoices = Object.keys(game.system.api.models.actors).reduce((acc, key) => {
if (!ignoredActorKeys.includes(key)) {
const model = game.system.api.models.actors[key];
const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model);
// As per DHToken._getTrackedAttributesFromSchema, attributes.bar have a max version as well.
const maxAttributes = attributes.bar.map(x => [...x, 'max']);
attributes.value.push(...maxAttributes);
const group = game.i18n.localize(model.metadata.label);
const choices = CONFIG.Token.documentClass
.getTrackedAttributeChoices(attributes, model)
.map(x => ({ ...x, group: group }));
acc.push(...choices);
}
return acc;
}, []);
} }
static DEFAULT_OPTIONS = { static DEFAULT_OPTIONS = {
@ -50,6 +35,69 @@ export default class DhActiveEffectConfig extends foundry.applications.sheets.Ac
} }
}; };
/**
* Get ChangeChoices for the changes autocomplete. Static for use in this class aswell as in settings-active-effect-config.mjs
* @returns {ChangeChoice { value: string, label: string, hint: string, group: string }[]}
*/
static getChangeChoices() {
const ignoredActorKeys = ['config', 'DhEnvironment', 'DhParty'];
const getAllLeaves = (root, group, parentPath = '') => {
const leaves = [];
const rootKey = `${parentPath ? `${parentPath}.` : ''}${root.name}`;
for (const field of Object.values(root.fields)) {
if (field instanceof foundry.data.fields.SchemaField)
leaves.push(...getAllLeaves(field, group, rootKey));
else
leaves.push({
value: `${rootKey}.${field.name}`,
label: game.i18n.localize(field.label),
hint: game.i18n.localize(field.hint),
group
});
}
return leaves;
};
return 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;
}, []);
}
_attachPartListeners(partId, htmlElement, options) { _attachPartListeners(partId, htmlElement, options) {
super._attachPartListeners(partId, htmlElement, options); super._attachPartListeners(partId, htmlElement, options);
const changeChoices = this.changeChoices; const changeChoices = this.changeChoices;
@ -68,14 +116,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

@ -7,19 +7,7 @@ export default class SettingActiveEffectConfig extends HandlebarsApplicationMixi
super({}); super({});
this.effect = foundry.utils.deepClone(effect); this.effect = foundry.utils.deepClone(effect);
const ignoredActorKeys = ['config', 'DhEnvironment']; this.changeChoices = game.system.api.applications.sheetConfigs.ActiveEffectConfig.getChangeChoices();
this.changeChoices = Object.keys(game.system.api.models.actors).reduce((acc, key) => {
if (!ignoredActorKeys.includes(key)) {
const model = game.system.api.models.actors[key];
const attributes = CONFIG.Token.documentClass.getTrackedAttributes(model);
const group = game.i18n.localize(model.metadata.label);
const choices = CONFIG.Token.documentClass
.getTrackedAttributeChoices(attributes, model)
.map(x => ({ ...x, group: group }));
acc.push(...choices);
}
return acc;
}, []);
} }
static DEFAULT_OPTIONS = { static DEFAULT_OPTIONS = {
@ -103,7 +91,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

@ -73,9 +73,11 @@ export default class SettingFeatureConfig extends HandlebarsApplicationMixin(App
return context; return context;
} }
static async updateData(event, element, formData) { static async updateData(_event, _element, formData) {
const data = foundry.utils.expandObject(formData.object); const data = foundry.utils.expandObject(formData.object);
foundry.utils.mergeObject(this.move, data); await this.updateMove({
[`${this.movePath}`]: data
});
this.render(); this.render();
} }
@ -135,9 +137,7 @@ export default class SettingFeatureConfig extends HandlebarsApplicationMixin(App
} }
); );
await this.settings.updateSource({ [`${this.actionsPath}.${action.id}`]: action }); await this.updateMove({ [`${this.actionsPath}.${action.id}`]: action });
this.move = foundry.utils.getProperty(this.settings, this.movePath);
this.render(); this.render();
} }
@ -150,13 +150,12 @@ export default class SettingFeatureConfig extends HandlebarsApplicationMixin(App
await game.system.api.applications.sheetConfigs.SettingActiveEffectConfig.configure(effect); await game.system.api.applications.sheetConfigs.SettingActiveEffectConfig.configure(effect);
if (!updatedEffect) return; if (!updatedEffect) return;
await this.settings.updateSource({ await this.updateMove({
[`${this.movePath}.effects`]: this.move.effects.reduce((acc, effect, index) => { [`${this.movePath}.effects`]: this.move.effects.reduce((acc, effect, index) => {
acc.push(index === effectIndex ? { ...updatedEffect, id: effect.id } : effect); acc.push(index === effectIndex ? { ...updatedEffect, id: effect.id } : effect);
return acc; return acc;
}, []) }, [])
}); });
this.move = foundry.utils.getProperty(this.settings, this.movePath);
this.render(); this.render();
} else { } else {
const action = this.move.actions.get(id); const action = this.move.actions.get(id);
@ -171,13 +170,13 @@ export default class SettingFeatureConfig extends HandlebarsApplicationMixin(App
: existingEffectIndex === -1 : existingEffectIndex === -1
? [...currentEffects, effectData] ? [...currentEffects, effectData]
: currentEffects.with(existingEffectIndex, effectData); : currentEffects.with(existingEffectIndex, effectData);
await this.settings.updateSource({ await this.updateMove({
[`${this.movePath}.effects`]: updatedEffects [`${this.movePath}.effects`]: updatedEffects
}); });
} }
await this.settings.updateSource({ [`${this.actionsPath}.${id}`]: updatedMove }); await this.updateMove({ [`${this.actionsPath}.${id}`]: updatedMove });
this.move = foundry.utils.getProperty(this.settings, this.movePath);
this.render(); this.render();
return updatedEffects; return updatedEffects;
}).render(true); }).render(true);
@ -199,33 +198,36 @@ export default class SettingFeatureConfig extends HandlebarsApplicationMixin(App
}); });
} }
} }
await this.settings.updateSource({ await this.updateMove({
[this.movePath]: { [this.movePath]: {
effects: move.effects.filter(x => x.id !== id), effects: move.effects.filter(x => x.id !== id),
actions: move.actions actions: move.actions
} }
}); });
} else { } else {
await this.settings.updateSource({ [`${this.actionsPath}.-=${target.dataset.id}`]: null }); await this.updateMove({ [`${this.actionsPath}.-=${target.dataset.id}`]: null });
} }
this.move = foundry.utils.getProperty(this.settings, this.movePath);
this.render(); this.render();
} }
static async addEffect(_, target) { static async addEffect() {
const currentEffects = foundry.utils.getProperty(this.settings, `${this.movePath}.effects`); const currentEffects = foundry.utils.getProperty(this.settings, `${this.movePath}.effects`);
await this.settings.updateSource({
await this.updateMove({
[`${this.movePath}.effects`]: [ [`${this.movePath}.effects`]: [
...currentEffects, ...currentEffects,
game.system.api.data.activeEffects.BaseEffect.getDefaultObject() game.system.api.data.activeEffects.BaseEffect.getDefaultObject()
] ]
}); });
this.move = foundry.utils.getProperty(this.settings, this.movePath);
this.render(); this.render();
} }
async updateMove(update) {
await this.settings.updateSource(update);
this.move = foundry.utils.getProperty(this.settings, this.movePath);
}
static resetMoves() {} static resetMoves() {}
_filterTabs(tabs) { _filterTabs(tabs) {

View file

@ -6,7 +6,6 @@ import DaggerheartMenu from '../../sidebar/tabs/daggerheartMenu.mjs';
import { socketEvent } from '../../../systemRegistration/socket.mjs'; import { socketEvent } from '../../../systemRegistration/socket.mjs';
import GroupRollDialog from '../../dialogs/group-roll-dialog.mjs'; import GroupRollDialog from '../../dialogs/group-roll-dialog.mjs';
import DhpActor from '../../../documents/actor.mjs'; import DhpActor from '../../../documents/actor.mjs';
import DHItem from '../../../documents/item.mjs';
export default class Party extends DHBaseActorSheet { export default class Party extends DHBaseActorSheet {
constructor(options) { constructor(options) {
@ -269,15 +268,6 @@ export default class Party extends DHBaseActorSheet {
).render({ force: true }); ).render({ force: true });
} }
/**
* Get the set of ContextMenu options for Consumable and Loot.
* @returns {import('@client/applications/ux/context-menu.mjs').ContextMenuEntry[]} - The Array of context options passed to the ContextMenu instance
* @this {CharacterSheet}
* @protected
*/
static #getItemContextOptions() {
return this._getContextMenuCommonOptions.call(this, { usable: true, toChat: true });
}
/* -------------------------------------------- */ /* -------------------------------------------- */
/* Filter Tracking */ /* Filter Tracking */
/* -------------------------------------------- */ /* -------------------------------------------- */

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 })
} }
@ -536,6 +536,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

@ -36,7 +36,7 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
], ],
dragDrop: [ dragDrop: [
{ dragSelector: '.inventory-item[data-type="attack"]', dropSelector: null }, { dragSelector: '.inventory-item[data-type="attack"]', dropSelector: null },
{ dragSelector: ".currency[data-currency] .drag-handle", dropSelector: null } { dragSelector: '.currency[data-currency] .drag-handle', dropSelector: null }
] ]
}; };
@ -92,7 +92,7 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
value: context.source.system.gold[key] value: context.source.system.gold[key]
}; };
} }
context.inventory.hasCurrency = Object.values(context.inventory.currencies).some((c) => c.enabled); context.inventory.hasCurrency = Object.values(context.inventory.currencies).some(c => c.enabled);
} }
return context; return context;
@ -270,7 +270,9 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
currency currency
}); });
if (quantity) { if (quantity) {
originActor.update({ [`system.gold.${currency}`]: Math.max(0, originActor.system.gold[currency] - quantity) }); originActor.update({
[`system.gold.${currency}`]: Math.max(0, originActor.system.gold[currency] - quantity)
});
this.document.update({ [`system.gold.${currency}`]: this.document.system.gold[currency] + quantity }); this.document.update({ [`system.gold.${currency}`]: this.document.system.gold[currency] + quantity });
} }
return; return;
@ -292,6 +294,15 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
/* Handling transfer of inventoryItems */ /* Handling transfer of inventoryItems */
if (item.system.metadata.isInventoryItem) { if (item.system.metadata.isInventoryItem) {
if (!this.document.testUserPermission(game.user, 'OWNER', { exact: true })) {
return ui.notifications.error(
game.i18n.format('DAGGERHEART.UI.Notifications.lackingItemTransferPermission', {
user: game.user.name,
target: this.document.name
})
);
}
if (item.system.metadata.isQuantifiable) { if (item.system.metadata.isQuantifiable) {
const actorItem = originActor.items.get(data.originId); const actorItem = originActor.items.get(data.originId);
const quantityTransfered = await game.system.api.applications.dialogs.ItemTransferDialog.configure({ const quantityTransfered = await game.system.api.applications.dialogs.ItemTransferDialog.configure({
@ -300,14 +311,6 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
}); });
if (quantityTransfered) { if (quantityTransfered) {
if (quantityTransfered === actorItem.system.quantity) {
await originActor.deleteEmbeddedDocuments('Item', [data.originId]);
} else {
await actorItem.update({
'system.quantity': actorItem.system.quantity - quantityTransfered
});
}
const existingItem = this.document.items.find(x => itemIsIdentical(x, item)); const existingItem = this.document.items.find(x => itemIsIdentical(x, item));
if (existingItem) { if (existingItem) {
await existingItem.update({ await existingItem.update({
@ -325,10 +328,18 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
} }
]); ]);
} }
if (quantityTransfered === actorItem.system.quantity) {
await originActor.deleteEmbeddedDocuments('Item', [data.originId]);
} else {
await actorItem.update({
'system.quantity': actorItem.system.quantity - quantityTransfered
});
}
} }
} else { } else {
await originActor.deleteEmbeddedDocuments('Item', [data.originId]);
await this.document.createEmbeddedDocuments('Item', [item.toObject()]); await this.document.createEmbeddedDocuments('Item', [item.toObject()]);
await originActor.deleteEmbeddedDocuments('Item', [data.originId]);
} }
} }
} }
@ -339,7 +350,7 @@ export default class DHBaseActorSheet extends DHApplicationMixin(ActorSheetV2) {
*/ */
async _onDragStart(event) { async _onDragStart(event) {
// Handle drag/dropping currencies // Handle drag/dropping currencies
const currencyEl = event.currentTarget.closest(".currency[data-currency]"); const currencyEl = event.currentTarget.closest('.currency[data-currency]');
if (currencyEl) { if (currencyEl) {
const currency = currencyEl.dataset.currency; const currency = currencyEl.dataset.currency;
const data = { type: 'Currency', currency, originActor: this.document.uuid }; const data = { type: 'Currency', currency, originActor: this.document.uuid };

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

@ -1,4 +1,4 @@
import { refreshIsAllowed } from '../../../helpers/utils.mjs'; import { RefreshFeatures } from '../../../helpers/utils.mjs';
const { HandlebarsApplicationMixin } = foundry.applications.api; const { HandlebarsApplicationMixin } = foundry.applications.api;
const { AbstractSidebarTab } = foundry.applications.sidebar; const { AbstractSidebarTab } = foundry.applications.sidebar;
@ -54,73 +54,6 @@ export default class DaggerheartMenu extends HandlebarsApplicationMixin(Abstract
return context; return context;
} }
async getRefreshables(types) {
const refreshedActors = {};
for (let actor of game.actors) {
if (['character', 'adversary'].includes(actor.type) && actor.prototypeToken.actorLink) {
const updates = {};
for (let item of actor.items) {
if (item.system.metadata?.hasResource && refreshIsAllowed(types, item.system.resource?.recovery)) {
if (!refreshedActors[actor.id])
refreshedActors[actor.id] = { name: actor.name, img: actor.img, refreshed: new Set() };
refreshedActors[actor.id].refreshed.add(
game.i18n.localize(CONFIG.DH.GENERAL.refreshTypes[item.system.resource.recovery].label)
);
if (!updates[item.id]?.system) updates[item.id] = { system: {} };
const increasing =
item.system.resource.progression === CONFIG.DH.ITEM.itemResourceProgression.increasing.id;
updates[item.id].system = {
...updates[item.id].system,
'resource.value': increasing
? 0
: Roll.replaceFormulaData(item.system.resource.max, actor.getRollData())
};
}
if (item.system.metadata?.hasActions) {
const refreshTypes = new Set();
const actions = item.system.actions.filter(action => {
if (refreshIsAllowed(types, action.uses.recovery)) {
refreshTypes.add(action.uses.recovery);
return true;
}
return false;
});
if (actions.length === 0) continue;
if (!refreshedActors[actor.id])
refreshedActors[actor.id] = { name: actor.name, img: actor.img, refreshed: new Set() };
refreshedActors[actor.id].refreshed.add(
...refreshTypes.map(type => game.i18n.localize(CONFIG.DH.GENERAL.refreshTypes[type].label))
);
if (!updates[item.id]?.system) updates[item.id] = { system: {} };
updates[item.id].system = {
...updates[item.id].system,
...actions.reduce(
(acc, action) => {
acc.actions[action.id] = { 'uses.value': 0 };
return acc;
},
{ actions: updates[item.id].system.actions ?? {} }
)
};
}
}
for (let key in updates) {
const update = updates[key];
await actor.items.get(key).update(update);
}
}
}
return refreshedActors;
}
/* -------------------------------------------- */ /* -------------------------------------------- */
/* Application Clicks Actions */ /* Application Clicks Actions */
/* -------------------------------------------- */ /* -------------------------------------------- */
@ -133,30 +66,9 @@ export default class DaggerheartMenu extends HandlebarsApplicationMixin(Abstract
static async #refreshActors() { static async #refreshActors() {
const refreshKeys = Object.keys(this.refreshSelections).filter(key => this.refreshSelections[key].selected); const refreshKeys = Object.keys(this.refreshSelections).filter(key => this.refreshSelections[key].selected);
await this.getRefreshables(refreshKeys); await RefreshFeatures(refreshKeys);
const types = refreshKeys.map(x => this.refreshSelections[x].label).join(', ');
ui.notifications.info(
game.i18n.format('DAGGERHEART.UI.Notifications.gmMenuRefresh', {
types: `[${types}]`
})
);
this.refreshSelections = DaggerheartMenu.defaultRefreshSelections(); this.refreshSelections = DaggerheartMenu.defaultRefreshSelections();
const cls = getDocumentClass('ChatMessage');
const msg = {
user: game.user.id,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/ui/chat/refreshMessage.hbs',
{
types: types
}
),
title: game.i18n.localize('DAGGERHEART.UI.Chat.refreshMessage.title'),
speaker: cls.getSpeaker()
};
cls.create(msg);
this.render(); this.render();
} }
} }

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

@ -1,4 +1,4 @@
import DhMeasuredTemplate from "./measuredTemplate.mjs"; 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 */
@ -54,30 +54,58 @@ 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
x: originEdge.x + Math.sign(originPoint.x - originEdge.x), ? canvas.grid.getTopLeftPoint({
y: originEdge.y + Math.sign(originPoint.y - originEdge.y) x: originEdge.x + Math.sign(originPoint.x - originEdge.x),
}); y: originEdge.y + Math.sign(originPoint.y - originEdge.y)
const adjustDestinationPoint = canvas.grid.getTopLeftPoint({ })
x: targetEdge.x + Math.sign(destinationPoint.x - targetEdge.x), : originPoint;
y: targetEdge.y + Math.sign(destinationPoint.y - targetEdge.y) const adjustDestinationPoint = targetEdge
}); ? canvas.grid.getTopLeftPoint({
return canvas.grid.measurePath([adjustedOriginPoint, adjustDestinationPoint]).distance; x: targetEdge.x + Math.sign(targetPoint.x - targetEdge.x),
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) { _onHoverIn(event, options) {
@ -85,7 +113,7 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
// Check if the setting is enabled // Check if the setting is enabled
const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance).showTokenDistance; const setting = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.appearance).showTokenDistance;
if (setting === "never" || (setting === "encounters" && !game.combat?.started)) return; if (setting === 'never' || (setting === 'encounters' && !game.combat?.started)) return;
// Check if this token isn't invisible and is actually being hovered // Check if this token isn't invisible and is actually being hovered
const isTokenValid = const isTokenValid =
@ -103,8 +131,7 @@ export default class DhTokenPlaceable extends foundry.canvas.placeables.Token {
// Determine the actual range // Determine the actual range
const ranges = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).rangeMeasurement; const ranges = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.variantRules).rangeMeasurement;
const distanceNum = originToken.distanceTo(this); const distanceResult = DhMeasuredTemplate.getRangeLabels(originToken.distanceTo(this), ranges);
const distanceResult = DhMeasuredTemplate.getRangeLabels(distanceNum, ranges);
const distanceLabel = `${distanceResult.distance} ${distanceResult.units}`.trim(); const distanceLabel = `${distanceResult.distance} ${distanceResult.units}`.trim();
// Create the element // Create the element
@ -156,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

@ -202,7 +202,8 @@ export const conditions = () => ({
id: 'vulnerable', id: 'vulnerable',
name: 'DAGGERHEART.CONFIG.Condition.vulnerable.name', name: 'DAGGERHEART.CONFIG.Condition.vulnerable.name',
img: 'icons/magic/control/silhouette-fall-slip-prone.webp', img: 'icons/magic/control/silhouette-fall-slip-prone.webp',
description: 'DAGGERHEART.CONFIG.Condition.vulnerable.description' description: 'DAGGERHEART.CONFIG.Condition.vulnerable.description',
autoApplyFlagId: 'auto-vulnerable'
}, },
hidden: { hidden: {
id: 'hidden', id: 'hidden',
@ -236,6 +237,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -304,6 +306,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -329,7 +332,56 @@ 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: [] effects: []
} }
}), }),
@ -349,6 +401,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -417,6 +470,7 @@ export const defaultRestOptions = {
actionType: 'action', actionType: 'action',
chatDisplay: false, chatDisplay: false,
target: { target: {
amount: 1,
type: 'friendly' type: 'friendly'
}, },
damage: { damage: {
@ -442,7 +496,56 @@ 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: [] effects: []
}, },
workOnAProject: { workOnAProject: {

View file

@ -467,9 +467,7 @@ export const allArmorFeatures = () => {
}; };
export const orderedArmorFeatures = () => { export const orderedArmorFeatures = () => {
const homebrewFeatures = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).itemFeatures const allFeatures = allArmorFeatures();
.armorFeatures;
const allFeatures = { ...armorFeatures, ...homebrewFeatures };
const all = Object.keys(allFeatures).map(key => { const all = Object.keys(allFeatures).map(key => {
const feature = allFeatures[key]; const feature = allFeatures[key];
return { return {
@ -1404,9 +1402,7 @@ export const allWeaponFeatures = () => {
}; };
export const orderedWeaponFeatures = () => { export const orderedWeaponFeatures = () => {
const homebrewFeatures = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Homebrew).itemFeatures const allFeatures = allWeaponFeatures();
.weaponFeatures;
const allFeatures = { ...weaponFeatures, ...homebrewFeatures };
const all = Object.keys(allFeatures).map(key => { const all = Object.keys(allFeatures).map(key => {
const feature = allFeatures[key]; const feature = allFeatures[key];
return { return {

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

@ -229,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;
} }
@ -240,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,12 +1,13 @@
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';
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'];
@ -35,15 +36,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({ {
initial: 2, value: new fields.NumberField({
min: 0, initial: 2,
integer: true, min: 0,
label: 'DAGGERHEART.GENERAL.hope' integer: true,
}), label: 'DAGGERHEART.GENERAL.hope'
isReversed: new fields.BooleanField({ initial: false }) }),
}) 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 +132,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 +218,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 +257,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 +308,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'
})
}) })
}) })
}; };
@ -661,7 +670,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 */
@ -685,6 +694,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,61 @@
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'
})
};
}
get isAutoVulnerableActive() {
const vulnerableAppliedByOther = this.parent.effects.some(
x => x.statuses.has('vulnerable') && !x.flags.daggerheart?.autoApplyFlagId
);
return !vulnerableAppliedByOther;
}
async _preUpdate(changes, options, userId) {
const allowed = await super._preUpdate(changes, options, userId);
if (allowed === false) return;
const automationSettings = game.settings.get(CONFIG.DH.id, CONFIG.DH.SETTINGS.gameSettings.Automation);
if (
automationSettings.vulnerableAutomation &&
this.parent.type !== 'companion' &&
changes.system?.resources?.stress?.value
) {
const { name, description, img, autoApplyFlagId } = CONFIG.DH.GENERAL.conditions().vulnerable;
const autoEffects = this.parent.effects.filter(
x => x.flags.daggerheart?.autoApplyFlagId === autoApplyFlagId
);
if (changes.system.resources.stress.value >= this.resources.stress.max) {
if (!autoEffects.length)
this.parent.createEmbeddedDocuments('ActiveEffect', [
{
name: game.i18n.localize(name),
description: game.i18n.localize(description),
img: img,
statuses: ['vulnerable'],
flags: { daggerheart: { autoApplyFlagId } }
}
]);
} else if (this.resources.stress.value >= this.resources.stress.max) {
this.parent.deleteEmbeddedDocuments(
'ActiveEffect',
autoEffects.map(x => x.id)
);
}
}
}
}

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

@ -152,8 +152,9 @@ export function ActionMixin(Base) {
} }
get uuid() { get uuid() {
if (!(this.item instanceof game.system.api.documents.DHItem)) return null; const isItem = this.item instanceof game.system.api.documents.DHItem;
return `${this.item.uuid}.${this.documentName}.${this.id}`; 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() {
@ -261,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'),
@ -289,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 }), {
max: new fields.NumberField({ value: new fields.NumberField({ initial: initial, min: 0, integer: true, label }),
initial: max, max: new fields.NumberField({
integer: true, initial: max,
label: integer: true,
maxLabel ?? game.i18n.format('DAGGERHEART.GENERAL.maxWithThing', { thing: game.i18n.localize(label) }) label:
}), maxLabel ??
isReversed: new fields.BooleanField({ initial: reverse }) game.i18n.format('DAGGERHEART.GENERAL.maxWithThing', { thing: game.i18n.localize(label) })
}); }),
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

@ -23,9 +23,7 @@ export default class DHArmor extends AttachableItem {
armorFeatures: new fields.ArrayField( armorFeatures: new fields.ArrayField(
new fields.SchemaField({ new fields.SchemaField({
value: new fields.StringField({ value: new fields.StringField({
required: true, required: true
choices: CONFIG.DH.ITEM.allArmorFeatures,
blank: true
}), }),
effectIds: new fields.ArrayField(new fields.StringField({ required: true })), effectIds: new fields.ArrayField(new fields.StringField({ required: true })),
actionIds: new fields.ArrayField(new fields.StringField({ required: true })) actionIds: new fields.ArrayField(new fields.StringField({ required: true }))
@ -58,12 +56,11 @@ export default class DHArmor extends AttachableItem {
async getDescriptionData() { async getDescriptionData() {
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]).filter(x => x);
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

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

@ -38,9 +38,7 @@ export default class DHWeapon extends AttachableItem {
weaponFeatures: new fields.ArrayField( weaponFeatures: new fields.ArrayField(
new fields.SchemaField({ new fields.SchemaField({
value: new fields.StringField({ value: new fields.StringField({
required: true, required: true
choices: CONFIG.DH.ITEM.allWeaponFeatures,
blank: true
}), }),
effectIds: new fields.ArrayField(new fields.StringField({ required: true })), effectIds: new fields.ArrayField(new fields.StringField({ required: true })),
actionIds: new fields.ArrayField(new fields.StringField({ required: true })) actionIds: new fields.ArrayField(new fields.StringField({ required: true }))
@ -113,13 +111,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]).filter(x => x);
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

@ -75,7 +75,7 @@ export default class RegisteredTriggers extends Map {
unregisterSceneEnvironmentTriggers(flagSystemData) { unregisterSceneEnvironmentTriggers(flagSystemData) {
const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData); const sceneData = new game.system.api.data.scenes.DHScene(flagSystemData);
for (const environment of sceneData.sceneEnvironments) { for (const environment of sceneData.sceneEnvironments) {
if (environment.pack) continue; if (!environment || environment.pack) continue;
this.unregisterItemTriggers(environment.system.features); this.unregisterItemTriggers(environment.system.features);
} }
} }

View file

@ -37,7 +37,7 @@ 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()

View file

@ -18,6 +18,10 @@ export default class DhAutomation extends foundry.abstract.DataModel {
label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.hopeFear.players.label' label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.hopeFear.players.label'
}) })
}), }),
vulnerableAutomation: new fields.BooleanField({
initial: true,
label: 'DAGGERHEART.SETTINGS.Automation.FIELDS.vulnerableAutomation.label'
}),
countdownAutomation: new fields.BooleanField({ countdownAutomation: new fields.BooleanField({
required: true, required: true,
initial: true, initial: true,

View file

@ -1,4 +1,5 @@
import DamageDialog from '../applications/dialogs/damageDialog.mjs'; import DamageDialog from '../applications/dialogs/damageDialog.mjs';
import { parseRallyDice } from '../helpers/utils.mjs';
import { RefreshType, socketEvent } from '../systemRegistration/socket.mjs'; import { RefreshType, socketEvent } from '../systemRegistration/socket.mjs';
import DHRoll from './dhRoll.mjs'; import DHRoll from './dhRoll.mjs';
@ -33,7 +34,7 @@ export default class DamageRoll extends DHRoll {
static async buildPost(roll, config, message) { static async buildPost(roll, config, message) {
const chatMessage = config.source?.message const chatMessage = config.source?.message
? ui.chat.collection.get(config.source.message) ? ui.chat.collection.get(config.source.message)
: getDocumentClass('ChatMessage').applyRollMode({}, config.rollMode); : getDocumentClass('ChatMessage').applyRollMode({}, config.rollMode ?? CONST.DICE_ROLL_MODES.PUBLIC);
if (game.modules.get('dice-so-nice')?.active) { if (game.modules.get('dice-so-nice')?.active) {
const pool = foundry.dice.terms.PoolTerm.fromRolls( const pool = foundry.dice.terms.PoolTerm.fromRolls(
Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll)) Object.values(config.damage).flatMap(r => r.parts.map(p => p.roll))
@ -46,9 +47,14 @@ export default class DamageRoll extends DHRoll {
chatMessage.whisper?.length > 0 ? chatMessage.whisper : null, chatMessage.whisper?.length > 0 ? chatMessage.whisper : null,
chatMessage.blind chatMessage.blind
); );
config.mute = true;
} }
await super.buildPost(roll, config, message); await super.buildPost(roll, config, message);
if (config.source?.message) chatMessage.update({ 'system.damage': config.damage }); if (config.source?.message) {
chatMessage.update({ 'system.damage': config.damage });
if (!game.modules.get('dice-so-nice')?.active) foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice });
}
} }
static unifyDamageRoll(rolls) { static unifyDamageRoll(rolls) {
@ -192,7 +198,7 @@ export default class DamageRoll extends DHRoll {
// Bardic Rally // Bardic Rally
const rallyChoices = config.data?.parent?.appliedEffects.reduce((a, c) => { const rallyChoices = config.data?.parent?.appliedEffects.reduce((a, c) => {
const change = c.changes.find(ch => ch.key === 'system.bonuses.rally'); const change = c.changes.find(ch => ch.key === 'system.bonuses.rally');
if (change) a.push({ value: c.id, label: change.value }); if (change) a.push({ value: c.id, label: parseRallyDice(change.value, c) });
return a; return a;
}, []); }, []);
if (rallyChoices.length) { if (rallyChoices.length) {

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

@ -1,6 +1,6 @@
import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs'; import D20RollDialog from '../applications/dialogs/d20RollDialog.mjs';
import D20Roll from './d20Roll.mjs'; import D20Roll from './d20Roll.mjs';
import { setDiceSoNiceForDualityRoll } from '../helpers/utils.mjs'; import { parseRallyDice, setDiceSoNiceForDualityRoll } from '../helpers/utils.mjs';
import { getDiceSoNicePresets } from '../config/generalConfig.mjs'; import { getDiceSoNicePresets } from '../config/generalConfig.mjs';
import { ResourceUpdateMap } from '../data/action/baseAction.mjs'; import { ResourceUpdateMap } from '../data/action/baseAction.mjs';
@ -68,7 +68,7 @@ export default class DualityRoll extends D20Roll {
setRallyChoices() { setRallyChoices() {
return this.data?.parent?.appliedEffects.reduce((a, c) => { return this.data?.parent?.appliedEffects.reduce((a, c) => {
const change = c.changes.find(ch => ch.key === 'system.bonuses.rally'); const change = c.changes.find(ch => ch.key === 'system.bonuses.rally');
if (change) a.push({ value: c.id, label: change.value }); if (change) a.push({ value: c.id, label: parseRallyDice(change.value, c) });
return a; return a;
}, []); }, []);
} }
@ -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

@ -934,10 +934,23 @@ export default class DhpActor extends Actor {
/** Get active effects */ /** Get active effects */
getActiveEffects() { getActiveEffects() {
const conditions = CONFIG.DH.GENERAL.conditions();
const statusMap = new Map(foundry.CONFIG.statusEffects.map(status => [status.id, status])); const statusMap = new Map(foundry.CONFIG.statusEffects.map(status => [status.id, status]));
const autoVulnerableActive = this.system.isAutoVulnerableActive;
return this.effects return this.effects
.filter(x => !x.disabled) .filter(x => !x.disabled)
.reduce((acc, effect) => { .reduce((acc, effect) => {
/* Could be generalized if needed. Currently just related to Vulnerable */
const isAutoVulnerableEffect =
effect.flags.daggerheart?.autoApplyFlagId === conditions.vulnerable.autoApplyFlagId;
if (isAutoVulnerableEffect) {
if (!autoVulnerableActive) return acc;
effect.appliedBy = game.i18n.localize('DAGGERHEART.CONFIG.Condition.vulnerable.autoAppliedByLabel');
effect.isLockedCondition = true;
effect.condition = 'vulnerable';
}
acc.push(effect); acc.push(effect);
const currentStatusActiveEffects = acc.filter( const currentStatusActiveEffects = acc.filter(

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

View file

@ -119,8 +119,8 @@ export const tagifyElement = (element, baseOptions, onChange, tagifyOptions = {}
}), }),
maxTags: typeof maxTags === 'function' ? maxTags() : maxTags, maxTags: typeof maxTags === 'function' ? maxTags() : maxTags,
dropdown: { dropdown: {
searchKeys: ['value', 'name'],
mapValueTo: 'name', mapValueTo: 'name',
searchKeys: ['value'],
enabled: 0, enabled: 0,
maxItems: 100, maxItems: 100,
closeOnSelect: true, closeOnSelect: true,
@ -472,7 +472,7 @@ export function refreshIsAllowed(allowedTypes, typeToCheck) {
case CONFIG.DH.GENERAL.refreshTypes.scene.id: case CONFIG.DH.GENERAL.refreshTypes.scene.id:
case CONFIG.DH.GENERAL.refreshTypes.session.id: case CONFIG.DH.GENERAL.refreshTypes.session.id:
case CONFIG.DH.GENERAL.refreshTypes.longRest.id: case CONFIG.DH.GENERAL.refreshTypes.longRest.id:
return allowedTypes.includes(typeToCheck); return allowedTypes.includes?.(typeToCheck) ?? allowedTypes.has(typeToCheck);
case CONFIG.DH.GENERAL.refreshTypes.shortRest.id: case CONFIG.DH.GENERAL.refreshTypes.shortRest.id:
return allowedTypes.some( return allowedTypes.some(
x => x =>
@ -495,3 +495,183 @@ 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);
}
export function parseRallyDice(value, effect) {
const legacyStartsWithPrefix = value.toLowerCase().startsWith('d');
const workingValue = legacyStartsWithPrefix ? value.slice(1) : value;
const dataParsedValue = itemAbleRollParse(workingValue, effect.parent);
return `d${game.system.api.documents.DhActiveEffect.effectSafeEval(dataParsedValue)}`;
}
/**
* Refreshes character and/or adversary resources.
* @param { string[] } refreshTypes Which type of features to refresh using IDs from CONFIG.DH.GENERAL.refreshTypes
* @param { string[] = ['character', 'adversary'] } actorTypes Which actor types should refresh their features. Defaults to character and adversary.
* @param { boolean = true } sendRefreshMessage If a chat message should be created detailing the refresh
* @return { Actor[] } The actors that had their features refreshed
*/
export async function RefreshFeatures(
refreshTypes = [],
actorTypes = ['character', 'adversary'],
sendNotificationMessage = true,
sendRefreshMessage = true
) {
const refreshedActors = {};
for (let actor of game.actors) {
if (actorTypes.includes(actor.type) && actor.prototypeToken.actorLink) {
const updates = {};
for (let item of actor.items) {
if (
item.system.metadata?.hasResource &&
refreshIsAllowed(refreshTypes, item.system.resource?.recovery)
) {
if (!refreshedActors[actor.id])
refreshedActors[actor.id] = { name: actor.name, img: actor.img, refreshed: new Set() };
refreshedActors[actor.id].refreshed.add(
game.i18n.localize(CONFIG.DH.GENERAL.refreshTypes[item.system.resource.recovery].label)
);
if (!updates[item.id]?.system) updates[item.id] = { system: {} };
const increasing =
item.system.resource.progression === CONFIG.DH.ITEM.itemResourceProgression.increasing.id;
updates[item.id].system = {
...updates[item.id].system,
'resource.value': increasing
? 0
: game.system.api.documents.DhActiveEffect.effectSafeEval(
Roll.replaceFormulaData(item.system.resource.max, actor.getRollData())
)
};
}
if (item.system.metadata?.hasActions) {
const usedTypes = new Set();
const actions = item.system.actions.filter(action => {
if (refreshIsAllowed(refreshTypes, action.uses.recovery)) {
usedTypes.add(action.uses.recovery);
return true;
}
return false;
});
if (actions.length === 0) continue;
if (!refreshedActors[actor.id])
refreshedActors[actor.id] = { name: actor.name, img: actor.img, refreshed: new Set() };
refreshedActors[actor.id].refreshed.add(
...usedTypes.map(type => game.i18n.localize(CONFIG.DH.GENERAL.refreshTypes[type].label))
);
if (!updates[item.id]?.system) updates[item.id] = { system: {} };
updates[item.id].system = {
...updates[item.id].system,
...actions.reduce(
(acc, action) => {
acc.actions[action.id] = { 'uses.value': 0 };
return acc;
},
{ actions: updates[item.id].system.actions ?? {} }
)
};
}
}
for (let key in updates) {
const update = updates[key];
await actor.items.get(key).update(update);
}
}
}
const types = refreshTypes.map(x => game.i18n.localize(CONFIG.DH.GENERAL.refreshTypes[x].label)).join(', ');
if (sendNotificationMessage) {
ui.notifications.info(
game.i18n.format('DAGGERHEART.UI.Notifications.gmMenuRefresh', {
types: `[${types}]`
})
);
}
if (sendRefreshMessage) {
const cls = getDocumentClass('ChatMessage');
const msg = {
user: game.user.id,
content: await foundry.applications.handlebars.renderTemplate(
'systems/daggerheart/templates/ui/chat/refreshMessage.hbs',
{
types: types
}
),
title: game.i18n.localize('DAGGERHEART.UI.Chat.refreshMessage.title'),
speaker: cls.getSpeaker()
};
cls.create(msg);
}
return refreshedActors;
}

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",

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