Skip to content

Commit

Permalink
[#4898] Add support for multiple skills/tools in single enricher
Browse files Browse the repository at this point in the history
A single check enricher can now specify multiple skills or toools:

```
[[/check skill=acr/ath dc=15]]
[[/check acrobatics athletics sleightOfHand 15]]
[[/check ability=str skill=dec/per dc=15]]
[[/check tool=thief skill=slt]]
```

The `passive` option doesn't work if multiple skills or tools are
provided, nor do custom labels.

Closes #4898
  • Loading branch information
arbron committed Jan 3, 2025
1 parent 24ede9c commit bf17bb0
Showing 1 changed file with 146 additions and 30 deletions.
176 changes: 146 additions & 30 deletions module/enrichers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ async function enrichAward(config, label, options) {
* becomes
* ```html
* <a class="roll-action" data-type="check" data-ability="dex">
* <i class="fa-solid fa-dice-d20"></i> Dexterity check
* <i class="fa-solid fa-dice-d20" inert></i> Dexterity check
* </a>
* ```
*
Expand All @@ -300,7 +300,7 @@ async function enrichAward(config, label, options) {
* becomes
* ```html
* <a class="roll-action" data-type="check" data-skill="acr" data-dc="20">
* <i class="fa-solid fa-dice-d20"></i> DC 20 Dexterity (Acrobatics) check
* <i class="fa-solid fa-dice-d20" inert></i> DC 20 Dexterity (Acrobatics) check
* </a>
* ```
*
Expand All @@ -309,7 +309,7 @@ async function enrichAward(config, label, options) {
* becomes
* ```html
* <a class="roll-action" data-type="check" data-ability="str" data-skill="acr">
* <i class="fa-solid fa-dice-d20"></i> Strength (Acrobatics) check
* <i class="fa-solid fa-dice-d20" inert></i> Strength (Acrobatics) check
* </a>
* ```
*
Expand All @@ -318,7 +318,7 @@ async function enrichAward(config, label, options) {
* becomes
* ```html
* <a class="roll-action" data-type="check" data-ability="int" data-tool="thief">
* <i class="fa-solid fa-dice-d20"></i> Intelligence (Thieves' Tools) check
* <i class="fa-solid fa-dice-d20" inert></i> Intelligence (Thieves' Tools) check
* </a>
* ```
*
Expand All @@ -327,63 +327,178 @@ async function enrichAward(config, label, options) {
* becomes
* ```html
* <a class="roll-action" data-type="check" data-ability="cha" data-dc="15">
* <i class="fa-solid fa-dice-d20"></i> DC 15 Charisma check
* <i class="fa-solid fa-dice-d20" inert></i> DC 15 Charisma check
* </a>
* ```
*
* @example Use multiple skills in a check using default abilities:
* ```[[/check skill=acr/ath dc=15]]```
* ```[[/check acrobatics athletics 15]]```
* becomes
* ```html
* <span class="roll-link-group" data-type="check" data-skill="acr|ath" data-dc="15">
* DC 15
* <a class="roll-action" data-ability="dex" data-skill="acr">
* <i class="fa-solid fa-dice-d20" inert></i> Dexterity (Acrobatics)
* </a> or
* <a class="roll-action" data-ability="dex">
* <i class="fa-solid fa-dice-d20" inert></i> Strength (Athletics)
* </a>
* <a class="enricher-action" data-action="request" ...><!-- request link --></a>
* </span>
* ```
*
* @example Use multiple skills with a fixed ability:
* ```[[/check ability=str skill=dec/per dc=15]]```
* ```[[/check strength deception persuasion 15]]```
* becomes
* ```html
* <span class="roll-link-group" data-type="check" data-ability="str" data-skill="dec|per" data-dc="15">
* DC 15 Strength
* (<a class="roll-action" data-skill="dec"><i class="fa-solid fa-dice-d20" inert></i> Deception</a> or
* <a class="roll-action" data-ability="per"><i class="fa-solid fa-dice-d20" inert></i> Persuasion</a>)
* <a class="enricher-action" data-action="request" ...><!-- request link --></a>
* </span>
* ```
*/
async function enrichCheck(config, label, options) {
config.skill = config.skill?.replaceAll("/", "|").split("|") ?? [];
config.tool = config.tool?.replaceAll("/", "|").split("|") ?? [];
for ( let value of config.values ) {
const slug = foundry.utils.getType(value) === "string" ? slugify(value) : value;
if ( slug in CONFIG.DND5E.enrichmentLookup.abilities ) config.ability = slug;
else if ( slug in CONFIG.DND5E.enrichmentLookup.skills ) config.skill = slug;
else if ( slug in CONFIG.DND5E.enrichmentLookup.tools ) config.tool = slug;
else if ( slug in CONFIG.DND5E.enrichmentLookup.skills ) config.skill.push(slug);
else if ( slug in CONFIG.DND5E.enrichmentLookup.tools ) config.tool.push(slug);
else if ( Number.isNumeric(value) ) config.dc = Number(value);
else config[value] = true;
}
delete config.values;

const groups = new Map();
let invalid = false;

const skillConfig = CONFIG.DND5E.enrichmentLookup.skills[slugify(config.skill)];
if ( config.skill && !skillConfig ) {
console.warn(`Skill ${config.skill} not found while enriching ${config._input}.`);
// TODO: Support "spellcasting" ability
let abilityConfig = CONFIG.DND5E.enrichmentLookup.abilities[slugify(config.ability)];
if ( config.ability && !abilityConfig ) {
console.warn(`Ability "${config.ability}" not found while enriching ${config._input}.`);
invalid = true;
} else if ( config.skill && !config.ability ) {
config.ability = skillConfig.ability;
} else if ( abilityConfig?.key ) config.ability = abilityConfig.key;

for ( let [index, skill] of config.skill.entries() ) {
const skillConfig = CONFIG.DND5E.enrichmentLookup.skills[slugify(skill)];
if ( skillConfig ) {
if ( skillConfig.key ) skill = config.skill[index] = skillConfig.key;
const ability = config.ability || skillConfig.ability;
if ( !groups.has(ability) ) groups.set(ability, []);
groups.get(ability).push({ key: skill, type: "skill", label: skillConfig.label });
} else {
console.warn(`Skill "${skill}" not found while enriching ${config._input}.`);
invalid = true;
}
}
if ( skillConfig?.key ) config.skill = skillConfig.key;

const toolConfig = CONFIG.DND5E.tools[slugify(config.tool)];
const toolUUID = CONFIG.DND5E.enrichmentLookup.tools[slugify(config.tool)];
const toolIndex = toolUUID ? Trait.getBaseItem(toolUUID, { indexOnly: true }) : null;
if ( config.tool && !toolIndex ) {
console.warn(`Tool ${config.tool} not found while enriching ${config._input}.`);
for ( const tool of config.tool ) {
const toolConfig = CONFIG.DND5E.tools[slugify(tool)];
const toolUUID = CONFIG.DND5E.enrichmentLookup.tools[slugify(tool)];
const toolIndex = toolUUID ? Trait.getBaseItem(toolUUID, { indexOnly: true }) : null;
if ( toolIndex ) {
const ability = config.ability || toolConfig?.ability;
if ( ability ) {
if ( !groups.has(ability) ) groups.set(ability, []);
groups.get(ability).push({ key: tool, type: "tool", label: toolIndex.name });
} else {
console.warn(`Tool "${tool}" found without specified or default ability while enriching ${config._input}.`);
invalid = true;
}
} else {
console.warn(`Tool "${tool}" not found while enriching ${config._input}.`);
invalid = true;
}
}

if ( !abilityConfig && !groups.size ) {
console.warn(`No ability provided while enriching ${config._input}.`);
invalid = true;
} else if ( config.tool && !config.ability && toolConfig ) {
config.ability = toolConfig.ability;
}

let abilityConfig = CONFIG.DND5E.enrichmentLookup.abilities[slugify(config.ability)];
if ( config.ability && !abilityConfig ) {
console.warn(`Ability ${config.ability} not found while enriching ${config._input}.`);
const complex = (config.skill.length + config.tool.length) > 1;
if ( config.passive && complex ) {
console.warn(`Multiple skills or tools and passive flag found while enriching ${config._input}, which aren't supported together.`);
invalid = true;
} else if ( !abilityConfig ) {
console.warn(`No ability provided while enriching check ${config._input}.`);
}
if ( label && complex ) {
console.warn(`Multiple skills or tools and a custom label found while enriching ${config._input}, which aren't supported together.`);
invalid = true;
}
if ( abilityConfig?.key ) config.ability = abilityConfig.key;

if ( config.dc && !Number.isNumeric(config.dc) ) config.dc = simplifyBonus(config.dc, options.rollData);

if ( invalid ) return null;

const type = config.skill ? "skill" : config.tool ? "tool" : "check";
config = { type, ...config };
if ( complex ) {
const formatter = game.i18n.getListFormatter({ type: "disjunction" });
const parts = [];
for ( const [ability, associated] of groups.entries() ) {
const makeConfig = ({ key, type }) => ({ type, [type]: key, ability: groups.size > 1 ? ability : undefined });

// Multiple associated proficiencies, link each individually
if ( associated.length > 1 ) parts.push(
game.i18n.format("EDITOR.DND5E.Inline.SpecificCheck", {
ability: CONFIG.DND5E.enrichmentLookup.abilities[ability].label,
type: formatter.format(associated.map(a => createRollLink(a.label, makeConfig(a)).outerHTML ))
})
);

// Only single associated proficiency, wrap whole thing in roll link
else {
const associatedConfig = makeConfig(associated[0]);
parts.push(createRollLink(createRollLabel({ ...associatedConfig, ability }), associatedConfig).outerHTML);
}
}
label = formatter.format(parts);
if ( config.dc && !config.hideDC ) {
label = game.i18n.format("EDITOR.DND5E.Inline.DC", { dc: config.dc, check: label });
}
label = game.i18n.format(`EDITOR.DND5E.Inline.Check${config.format === "long" ? "Long" : "Short"}`, { check: label });
const template = document.createElement("template");
template.innerHTML = label;
return createRequestLink(template, {
type: "check", ...config, skill: config.skill.join("|"), tool: config.tool.join("|")
});
}

const type = config.skill.length ? "skill" : config.tool.length ? "tool" : "check";
config = { type, ability: Array.from(groups.keys())[0], ...config, skill: config.skill[0], tool: config.tool[0] };
if ( !label ) label = createRollLabel(config);
return config.passive ? createPassiveTag(label, config) : createRequestLink(createRollLink(label), config);
}

/* -------------------------------------------- */

/**
* Create the buttons for a check requested in chat.
* @param {object} dataset
* @returns {object[]}
*/
function createCheckRequestButtons(dataset) {
const skills = dataset.skill?.split("|") ?? [];
const tools = dataset.tool?.split("|") ?? [];
if ( (skills.length + tools.length) <= 1 ) return [createRequestButton(dataset)];
const baseDataset = { ...dataset };
delete baseDataset.skill;
delete baseDataset.tool;
return [
...skills.map(skill => createRequestButton({
ability: CONFIG.DND5E.skills[skill].ability, ...baseDataset, format: "short", skill, type: "skill"
})),
...tools.map(tool => createRequestButton({
ability: CONFIG.DND5E.tools[tool]?.ability, ...baseDataset, format: "short", tool, type: "tool"
}))
];
}

/* -------------------------------------------- */

/**
* Enrich a saving throw link.
* @param {object} config Configuration data.
Expand Down Expand Up @@ -413,7 +528,7 @@ async function enrichCheck(config, label, options) {
*
* @example Specify multiple abilities:
* ```[[/save ability=str/dex dc=20]]```
* ```[[/save strength dexterity dc=20]]```
* ```[[/save strength dexterity 20]]```
* becomes
* ```html
* <span class="roll-link-group" data-type="save" data-ability="str|dex" data-dc="20">
Expand Down Expand Up @@ -1202,7 +1317,8 @@ async function rollAction(event) {
const MessageClass = getDocumentClass("ChatMessage");

let buttons;
if ( dataset.type === "save" ) buttons = createSaveRequestButtons(dataset);
if ( dataset.type === "check" ) buttons = createCheckRequestButtons(dataset);
else if ( dataset.type === "save" ) buttons = createSaveRequestButtons(dataset);
else buttons = [createRequestButton({ ...dataset, format: "short" })];

const chatData = {
Expand Down

0 comments on commit bf17bb0

Please sign in to comment.