Restyled character creation dialog.
Added dialog factory.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { refreshDerivedAfterActiveEffects } from "../utils/active-effect-derived.js";
|
||||
import { prepareAgentStatSanityTooltips } from "../utils/roll-target-tooltip.js";
|
||||
import {
|
||||
syncExhaustionEffect,
|
||||
updateTouchesExhaustionPhysical,
|
||||
@@ -15,6 +16,7 @@ export default class DeltaGreenActor extends Actor {
|
||||
prepareData() {
|
||||
super.prepareData();
|
||||
refreshDerivedAfterActiveEffects(this);
|
||||
if (this.type === "agent") prepareAgentStatSanityTooltips(this);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config.js";
|
||||
import {
|
||||
bindDialogTabs,
|
||||
getDialogContentRoot,
|
||||
showDgDialog,
|
||||
} from "./dg-dialog.js";
|
||||
import {
|
||||
BONUS_SKILL_COUNT,
|
||||
buildSkillCatalog,
|
||||
buildBonusSkillCatalog,
|
||||
buildSortedFixedSkillRows,
|
||||
buildSortedTypedSkillRows,
|
||||
computeSkillValues,
|
||||
@@ -11,10 +16,17 @@ import {
|
||||
prepareProfessionSkillRows,
|
||||
splitProfessionSkillMap,
|
||||
} from "../utils/profession-skills.js";
|
||||
import { reorderForColumnSorting } from "../utils/skill-layout.js";
|
||||
import {
|
||||
reorderForColumnSorting,
|
||||
splitIntoColumns,
|
||||
} from "../utils/skill-layout.js";
|
||||
import {
|
||||
applySkillTooltipDisplayMode,
|
||||
buildSkillTooltip,
|
||||
} from "../utils/skill-tooltip.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const ADD_PROFESSION_SKILL_COLUMNS = 4;
|
||||
const CHARACTER_CREATION_OPTION_COLUMNS = 2;
|
||||
const ADD_PROFESSION_SKILL_COLUMNS = 2;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/**
|
||||
@@ -29,8 +41,8 @@ export async function showPickSkillsDialog(professionItem, actor) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean }[]} rows
|
||||
* @returns {{ rowId: string, label: string, value: number, isModified: boolean }[]}
|
||||
* @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean, tooltip?: string }[]} rows
|
||||
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]}
|
||||
*/
|
||||
function orderSkillRowsForDisplay(rows) {
|
||||
const sorted = [...rows].sort((a, b) =>
|
||||
@@ -41,11 +53,12 @@ function orderSkillRowsForDisplay(rows) {
|
||||
? reorderForColumnSorting(sorted, ADD_PROFESSION_SKILL_COLUMNS)
|
||||
: sorted;
|
||||
|
||||
return ordered.map(({ rowId, label, value, isModified }) => ({
|
||||
return ordered.map(({ rowId, label, value, isModified, tooltip }) => ({
|
||||
rowId,
|
||||
label,
|
||||
value,
|
||||
isModified: Boolean(isModified),
|
||||
tooltip: tooltip ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -82,7 +95,7 @@ class AddProfessionDialogController {
|
||||
/** @type {string[]} */
|
||||
this.bondRelationships = [];
|
||||
|
||||
/** @type {DialogV2 | null} */
|
||||
/** @type {import("foundry.applications.api.DialogV2").default | null} */
|
||||
this.dialog = null;
|
||||
this.submitted = false;
|
||||
}
|
||||
@@ -94,16 +107,21 @@ class AddProfessionDialogController {
|
||||
const content = await this.#buildTemplate();
|
||||
this.submitted = false;
|
||||
|
||||
return DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "character-creation",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Profession.Dialog.Title"),
|
||||
},
|
||||
position: { width: 960 },
|
||||
classes: ["add-profession-dialog-app"],
|
||||
position: { width: 1080, height: 780 },
|
||||
form: { closeOnSubmit: false },
|
||||
render: async (_event, dialog) => {
|
||||
onRender: async (dialog) => {
|
||||
this.dialog = dialog;
|
||||
const root = this.#contentRoot();
|
||||
if (root) {
|
||||
bindDialogTabs(root);
|
||||
applySkillTooltipDisplayMode(root);
|
||||
}
|
||||
this.#bindListeners();
|
||||
await this.#refreshUi();
|
||||
},
|
||||
@@ -144,7 +162,10 @@ class AddProfessionDialogController {
|
||||
}
|
||||
|
||||
#bondCount() {
|
||||
return Number(this.professionItem.system.bonds) || 0;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.trunc(Number(this.professionItem.system.bonds) || 1),
|
||||
);
|
||||
}
|
||||
|
||||
#computeContext() {
|
||||
@@ -181,7 +202,7 @@ class AddProfessionDialogController {
|
||||
|
||||
/**
|
||||
* @param {ReturnType<computeSkillValues>} computed
|
||||
* @returns {{ rowId: string, label: string, value: number }[]}
|
||||
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string, sortLabel: string }[]}
|
||||
*/
|
||||
#buildSkillDisplayRows(computed) {
|
||||
const modifiedFixed = new Set(computed.modifiedFixedKeys ?? []);
|
||||
@@ -195,14 +216,26 @@ class AddProfessionDialogController {
|
||||
value: r.value,
|
||||
sortLabel: r.sortLabel,
|
||||
isModified: modifiedFixed.has(r.key),
|
||||
tooltip: buildSkillTooltip("dialog", null, {
|
||||
kind: "fixed",
|
||||
key: r.key,
|
||||
}),
|
||||
})),
|
||||
...typedRows.map((r) => ({
|
||||
rowId: r.storageKey,
|
||||
label: r.label,
|
||||
value: r.value,
|
||||
sortLabel: r.sortLabel,
|
||||
isModified: modifiedTyped.has(r.storageKey),
|
||||
})),
|
||||
...typedRows.map((r) => {
|
||||
const data = computed.typedValues[r.storageKey];
|
||||
return {
|
||||
rowId: r.storageKey,
|
||||
label: r.label,
|
||||
value: r.value,
|
||||
sortLabel: r.sortLabel,
|
||||
isModified: modifiedTyped.has(r.storageKey),
|
||||
tooltip: buildSkillTooltip("dialog", null, {
|
||||
kind: "typed",
|
||||
group: data?.group ?? "",
|
||||
label: r.label,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -221,18 +254,14 @@ class AddProfessionDialogController {
|
||||
...row,
|
||||
isChooseOne: Boolean(this.optionSkillMeta[row.key]?.chooseOne),
|
||||
}));
|
||||
const optionColumns = splitIntoColumns(
|
||||
optionRows,
|
||||
CHARACTER_CREATION_OPTION_COLUMNS,
|
||||
);
|
||||
|
||||
const automaticChooseOneRows = this.#buildAutomaticChooseOneRows();
|
||||
|
||||
const capWarnings = computed.capWarnings.map((w) => ({
|
||||
warning: game.i18n.format("DG.Profession.Dialog.CapWarning", {
|
||||
skill: w.label,
|
||||
attempted: w.attempted,
|
||||
waste: w.waste,
|
||||
}),
|
||||
}));
|
||||
|
||||
const bondCount = Number(this.professionItem.system.bonds) || 0;
|
||||
const bondCount = this.#bondCount();
|
||||
const bondRows = Array.from({ length: bondCount }, (_, index) => ({
|
||||
index,
|
||||
number: index + 1,
|
||||
@@ -246,26 +275,25 @@ class AddProfessionDialogController {
|
||||
}),
|
||||
);
|
||||
|
||||
const showSkillOptionsTab = this.optionPicks > 0;
|
||||
|
||||
return renderTemplate(`${BASE_TEMPLATE_PATH}/dialog/add-profession.html`, {
|
||||
skillRows,
|
||||
showOptionSkills: this.optionPicks > 0,
|
||||
showSkillOptionsTab,
|
||||
defaultTabSkillOptions: showSkillOptionsTab,
|
||||
defaultTabBonus: !showSkillOptionsTab,
|
||||
optionPicks: this.optionPicks,
|
||||
optionRows,
|
||||
optionColumns,
|
||||
automaticChooseOneRows,
|
||||
catalog: buildSkillCatalog(),
|
||||
catalog: buildBonusSkillCatalog(),
|
||||
bonusSlots,
|
||||
validationMessages: formatProfessionValidationMessages(
|
||||
computed.validationErrors,
|
||||
{ optionPicks: this.optionPicks },
|
||||
),
|
||||
capWarnings,
|
||||
bondCount,
|
||||
...this.#getDisplayMessages(computed),
|
||||
bondRows,
|
||||
});
|
||||
}
|
||||
|
||||
#contentRoot() {
|
||||
return this.dialog?.element?.querySelector(".dialog-content");
|
||||
return this.dialog ? getDialogContentRoot(this.dialog) : null;
|
||||
}
|
||||
|
||||
#bindListeners() {
|
||||
@@ -304,7 +332,6 @@ class AddProfessionDialogController {
|
||||
#onOptionCheckChange(checkbox) {
|
||||
const key = checkbox.value;
|
||||
const isChooseOne = checkbox.dataset.isChooseOne === "true";
|
||||
const root = this.#contentRoot();
|
||||
|
||||
if (checkbox.checked) {
|
||||
if (this.checkedOptionKeys.size >= this.optionPicks) {
|
||||
@@ -312,18 +339,14 @@ class AddProfessionDialogController {
|
||||
return;
|
||||
}
|
||||
this.checkedOptionKeys.add(key);
|
||||
if (isChooseOne && root) {
|
||||
const nameInput = root.querySelector(
|
||||
`[data-choose-one-key="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (isChooseOne) {
|
||||
const nameInput = this.#getOptionChooseOneInput(key);
|
||||
if (nameInput) nameInput.disabled = false;
|
||||
}
|
||||
} else {
|
||||
this.checkedOptionKeys.delete(key);
|
||||
if (isChooseOne && root) {
|
||||
const nameInput = root.querySelector(
|
||||
`[data-choose-one-key="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (isChooseOne) {
|
||||
const nameInput = this.#getOptionChooseOneInput(key);
|
||||
if (nameInput) {
|
||||
nameInput.disabled = true;
|
||||
nameInput.value = "";
|
||||
@@ -334,6 +357,20 @@ class AddProfessionDialogController {
|
||||
this.#refreshUi();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {HTMLInputElement | null}
|
||||
*/
|
||||
#getOptionChooseOneInput(key) {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return null;
|
||||
return root.querySelector(
|
||||
`[data-option-choose-one="true"][data-choose-one-key="${CSS.escape(
|
||||
key,
|
||||
)}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLSelectElement} select
|
||||
*/
|
||||
@@ -392,9 +429,7 @@ class AddProfessionDialogController {
|
||||
);
|
||||
if (cb) cb.checked = true;
|
||||
if (!this.optionSkillMeta[key]?.chooseOne) continue;
|
||||
const nameInput = root.querySelector(
|
||||
`[data-choose-one-key="${CSS.escape(key)}"]`,
|
||||
);
|
||||
const nameInput = this.#getOptionChooseOneInput(key);
|
||||
if (nameInput) {
|
||||
nameInput.disabled = false;
|
||||
nameInput.value = this.chooseOneLabels[key] ?? "";
|
||||
@@ -403,7 +438,9 @@ class AddProfessionDialogController {
|
||||
|
||||
for (const row of this.#buildAutomaticChooseOneRows()) {
|
||||
const nameInput = root.querySelector(
|
||||
`[data-choose-one-key="${CSS.escape(row.key)}"]`,
|
||||
`.character-creation-choose-one-section [data-choose-one-key="${CSS.escape(
|
||||
row.key,
|
||||
)}"]`,
|
||||
);
|
||||
if (nameInput) nameInput.value = this.chooseOneLabels[row.key] ?? "";
|
||||
}
|
||||
@@ -420,43 +457,87 @@ class AddProfessionDialogController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<computeSkillValues>} computed
|
||||
* @returns {{
|
||||
* validationMessages: string[],
|
||||
* capWarnings: { warning: string }[],
|
||||
* atCapMessages: string[],
|
||||
* }}
|
||||
*/
|
||||
#getDisplayMessages(computed) {
|
||||
const atCapPrefix = "bonusAtCap:";
|
||||
const mainErrors = computed.validationErrors.filter(
|
||||
(code) => !code.startsWith(atCapPrefix),
|
||||
);
|
||||
const atCapErrors = computed.validationErrors.filter((code) =>
|
||||
code.startsWith(atCapPrefix),
|
||||
);
|
||||
|
||||
return {
|
||||
validationMessages: formatProfessionValidationMessages(mainErrors, {
|
||||
optionPicks: this.optionPicks,
|
||||
}),
|
||||
capWarnings: computed.capWarnings.map((w) => ({
|
||||
warning: game.i18n.format("DG.Profession.Dialog.CapWarning", {
|
||||
skill: w.label,
|
||||
attempted: w.attempted,
|
||||
waste: w.waste,
|
||||
}),
|
||||
})),
|
||||
atCapMessages: formatProfessionValidationMessages(atCapErrors, {
|
||||
optionPicks: this.optionPicks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<computeSkillValues>} computed
|
||||
* @returns {string}
|
||||
*/
|
||||
#buildMessagesHtml(computed) {
|
||||
const validationHtml = formatProfessionValidationMessages(
|
||||
computed.validationErrors,
|
||||
{ optionPicks: this.optionPicks },
|
||||
)
|
||||
.map(
|
||||
(message) =>
|
||||
`<p class="validation-error">${foundry.utils.escapeHTML(
|
||||
message,
|
||||
)}</p>`,
|
||||
)
|
||||
.join("");
|
||||
const { validationMessages, capWarnings, atCapMessages } =
|
||||
this.#getDisplayMessages(computed);
|
||||
|
||||
const capHtml = computed.capWarnings
|
||||
const toErrorHtml = (messages) =>
|
||||
messages
|
||||
.map(
|
||||
(message) =>
|
||||
`<p class="dg-dialog__message--error">${foundry.utils.escapeHTML(
|
||||
message,
|
||||
)}</p>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const capHtml = capWarnings
|
||||
.map(
|
||||
(w) =>
|
||||
`<p class="cap-warning">${foundry.utils.escapeHTML(
|
||||
game.i18n.format("DG.Profession.Dialog.CapWarning", {
|
||||
skill: w.label,
|
||||
attempted: w.attempted,
|
||||
waste: w.waste,
|
||||
}),
|
||||
`<p class="dg-dialog__message--warning">${foundry.utils.escapeHTML(
|
||||
w.warning,
|
||||
)}</p>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return validationHtml + capHtml;
|
||||
return (
|
||||
toErrorHtml(validationMessages) + capHtml + toErrorHtml(atCapMessages)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tooltip
|
||||
* @returns {string}
|
||||
*/
|
||||
#tooltipAttr(tooltip) {
|
||||
if (!tooltip) return "";
|
||||
return ` data-tooltip="${foundry.utils.escapeHTML(tooltip)}"`;
|
||||
}
|
||||
|
||||
async #refreshUi() {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return;
|
||||
|
||||
this.#syncFormStateFromDom();
|
||||
|
||||
const computed = this.#compute();
|
||||
|
||||
const rows = this.#buildSkillDisplayRows(computed);
|
||||
@@ -466,15 +547,18 @@ class AddProfessionDialogController {
|
||||
body.innerHTML = rows
|
||||
.map((r) => {
|
||||
const modifiedClass = r.isModified ? " is-modified" : "";
|
||||
return `<div class="add-profession-skill-entry${modifiedClass}" data-skill-row="${foundry.utils.escapeHTML(
|
||||
return `<div class="dg-dialog__skill-entry${modifiedClass}" data-skill-row="${foundry.utils.escapeHTML(
|
||||
r.rowId,
|
||||
)}"><span class="add-profession-skill-label">${foundry.utils.escapeHTML(
|
||||
)}"><span class="dg-dialog__skill-label"${this.#tooltipAttr(
|
||||
r.tooltip,
|
||||
)}>${foundry.utils.escapeHTML(
|
||||
r.label,
|
||||
)}</span><span class="add-profession-skill-value" data-skill-value>${
|
||||
)}</span><span class="dg-dialog__skill-value" data-skill-value>${
|
||||
r.value
|
||||
}%</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
applySkillTooltipDisplayMode(body);
|
||||
}
|
||||
|
||||
const messagesEl = root.querySelector("[data-profession-messages]");
|
||||
@@ -537,13 +621,9 @@ class AddProfessionDialogController {
|
||||
});
|
||||
}
|
||||
|
||||
if (bondDocs.length) {
|
||||
await this.actor.createEmbeddedDocuments("Item", bondDocs);
|
||||
}
|
||||
await this.actor.createEmbeddedDocuments("Item", bondDocs);
|
||||
|
||||
this.submitted = true;
|
||||
await this.dialog?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default { showPickSkillsDialog };
|
||||
|
||||
@@ -8,26 +8,18 @@ import {
|
||||
getDefaultPointBuyValues,
|
||||
validatePointBuyValues,
|
||||
} from "../utils/profession-stat-setup.js";
|
||||
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @typedef {'submitted' | 'cancelled'} AssignStatsResult */
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function contentRoot(dialog) {
|
||||
return dialog.element?.querySelector(".dialog-content") ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @param {Record<string, number>} values
|
||||
*/
|
||||
function syncValuesFromDom(dialog, values) {
|
||||
const root = contentRoot(dialog);
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
|
||||
for (const key of STAT_KEYS) {
|
||||
@@ -47,7 +39,7 @@ function refreshAssignStatsUi(dialog, values) {
|
||||
syncValuesFromDom(dialog, values);
|
||||
const { isValid, remaining } = validatePointBuyValues(values);
|
||||
|
||||
const root = contentRoot(dialog);
|
||||
const root = getDialogContentRoot(dialog);
|
||||
const remainingEl = root?.querySelector("[data-points-remaining]");
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = game.i18n.format(
|
||||
@@ -68,7 +60,7 @@ function refreshAssignStatsUi(dialog, values) {
|
||||
* @param {Record<string, number>} values
|
||||
*/
|
||||
function bindAssignStatsListeners(dialog, values) {
|
||||
const root = contentRoot(dialog);
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
|
||||
root.querySelectorAll("[data-stat-key]").forEach((input) => {
|
||||
@@ -111,15 +103,15 @@ export async function showAssignStatsDialog(actor) {
|
||||
},
|
||||
);
|
||||
|
||||
return DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "assign-stats",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ProfessionSetup.AssignStats.Title"),
|
||||
},
|
||||
position: { width: 420 },
|
||||
classes: ["assign-stats-dialog-app"],
|
||||
form: { closeOnSubmit: false },
|
||||
render: (_event, dialog) => {
|
||||
onRender: (dialog) => {
|
||||
bindAssignStatsListeners(dialog, values);
|
||||
refreshAssignStatsUi(dialog, values);
|
||||
},
|
||||
@@ -152,5 +144,3 @@ export async function showAssignStatsDialog(actor) {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export default { showAssignStatsDialog };
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import DG from "../config.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
|
||||
/** @type {readonly string[]} */
|
||||
export const DG_DIALOG_APP_CLASSES = ["deltagreen", "dg-dialog-app"];
|
||||
|
||||
/**
|
||||
* @returns {"program-style" | "cowboy-style" | "outlaw-style"}
|
||||
*/
|
||||
export function getCharacterSheetThemeClass() {
|
||||
const theme = game.settings.get(DG.ID, "characterSheetStyle");
|
||||
return `${theme}-style`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {HTMLElement | null}
|
||||
*/
|
||||
export function getDialogContentRoot(dialog) {
|
||||
return (
|
||||
dialog.element?.querySelector(".dialog-content") ??
|
||||
dialog.element?.querySelector(".dialog-form") ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applyDialogTheme(dialog) {
|
||||
const windowContent = dialog.element?.querySelector("section.window-content");
|
||||
if (!windowContent) return;
|
||||
windowContent.classList.add(getCharacterSheetThemeClass());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [modifier]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildDialogAppClasses(modifier) {
|
||||
const classes = [...DG_DIALOG_APP_CLASSES];
|
||||
if (modifier) classes.push(`dg-dialog-app--${modifier}`);
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {string} tabId
|
||||
* @returns {void}
|
||||
*/
|
||||
export function activateDialogTab(root, tabId) {
|
||||
root.querySelectorAll(".tabs [data-action='tab']").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tab === tabId);
|
||||
});
|
||||
root.querySelectorAll(".tab[data-tab]").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tab === tabId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @returns {void}
|
||||
*/
|
||||
export function bindDialogTabs(root) {
|
||||
root.querySelectorAll(".tabs [data-action='tab']").forEach((tab) => {
|
||||
tab.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const tabId = tab.dataset.tab;
|
||||
if (tabId) activateDialogTab(root, tabId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options — DialogV2.wait options plus DG extensions
|
||||
* @param {string} [options.modifier] — app class modifier: dg-dialog-app--{modifier}
|
||||
* @param {(dialog: DialogV2, event: Event) => void | Promise<void>} [options.onRender]
|
||||
* @param {string[]} [options.classes] — extra app classes merged after base
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export function showDgDialog(options) {
|
||||
const {
|
||||
modifier,
|
||||
onRender,
|
||||
classes: extraClasses = [],
|
||||
render: userRender,
|
||||
...dialogOptions
|
||||
} = options;
|
||||
|
||||
const classes = [...buildDialogAppClasses(modifier), ...extraClasses];
|
||||
|
||||
return DialogV2.wait({
|
||||
...dialogOptions,
|
||||
classes,
|
||||
render: async (event, dialog) => {
|
||||
applyDialogTheme(dialog);
|
||||
if (userRender) await userRender(event, dialog);
|
||||
if (onRender) await onRender(dialog, event);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config.js";
|
||||
import { showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @typedef {'roll' | 'assign' | 'skip'} PickStatisticsChoice */
|
||||
@@ -14,13 +14,13 @@ export async function showPickStatisticsDialog() {
|
||||
{},
|
||||
);
|
||||
|
||||
return DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "pick-statistics",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ProfessionSetup.PickStatistics.Title"),
|
||||
},
|
||||
position: { width: 480 },
|
||||
classes: ["pick-statistics-dialog-app"],
|
||||
close: () => null,
|
||||
buttons: [
|
||||
{
|
||||
@@ -51,5 +51,3 @@ export async function showPickStatisticsDialog() {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export default { showPickStatisticsDialog };
|
||||
|
||||
@@ -16,13 +16,13 @@ function createProfessionStub(actor, itemData) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full profession setup flow: Pick Statistics → optional stat setup → Pick Skills.
|
||||
* Run the full profession setup flow: Pick Statistics → optional stat setup → Character Creation.
|
||||
*
|
||||
* @param {Item} professionItem Profession item or unsaved stub on the actor
|
||||
* @param {Actor} actor
|
||||
* @param {object} [options]
|
||||
* @param {TokenDocument|null} [options.token]
|
||||
* @returns {Promise<boolean>} True if Pick Skills was submitted successfully.
|
||||
* @returns {Promise<boolean>} True if Character Creation was submitted successfully.
|
||||
*/
|
||||
export async function runProfessionSetupFlow(
|
||||
professionItem,
|
||||
@@ -79,5 +79,3 @@ export async function assignProfessionToAgent(
|
||||
const created = await actor.createEmbeddedDocuments("Item", [createData]);
|
||||
return created[0] ?? null;
|
||||
}
|
||||
|
||||
export default { runProfessionSetupFlow, assignProfessionToAgent };
|
||||
|
||||
@@ -7,20 +7,12 @@ import {
|
||||
buildStatisticRows,
|
||||
rollStatisticScores,
|
||||
} from "../utils/profession-stat-setup.js";
|
||||
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @typedef {'submitted' | 'cancelled'} RollStatsResult */
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function contentRoot(dialog) {
|
||||
return dialog.element?.querySelector(".dialog-content") ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} state
|
||||
* @param {DialogV2} dialog
|
||||
@@ -39,7 +31,7 @@ function findStatKeyForRollIndex(state, rollIndex) {
|
||||
* @param {DialogV2} dialog
|
||||
*/
|
||||
function syncDomFromAssignments(state, dialog) {
|
||||
const root = contentRoot(dialog);
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
|
||||
const pool = root.querySelector("[data-roll-stats-pool]");
|
||||
@@ -81,8 +73,10 @@ function refreshRollStatsUi(state, dialog) {
|
||||
* @param {DialogV2} dialog
|
||||
*/
|
||||
function bindRollStatsListeners(state, dialog) {
|
||||
const root = contentRoot(dialog);
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
if (root.dataset.rollStatsListenersBound === "true") return;
|
||||
root.dataset.rollStatsListenersBound = "true";
|
||||
|
||||
root.querySelectorAll(".roll-stat-token").forEach((token) => {
|
||||
token.addEventListener("dragstart", (event) => {
|
||||
@@ -219,15 +213,15 @@ export async function showRollStatsDialog(actor, { token = null } = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
return DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "roll-stats",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ProfessionSetup.RollStats.Title"),
|
||||
},
|
||||
position: { width: 520 },
|
||||
classes: ["roll-stats-dialog-app"],
|
||||
form: { closeOnSubmit: false },
|
||||
render: (_event, dialog) => {
|
||||
onRender: (dialog) => {
|
||||
bindRollStatsListeners(state, dialog);
|
||||
refreshRollStatsUi(state, dialog);
|
||||
},
|
||||
@@ -270,5 +264,3 @@ export async function showRollStatsDialog(actor, { token = null } = {}) {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export default { showRollStatsDialog };
|
||||
|
||||
@@ -114,6 +114,29 @@ export async function renderDGChatCard({ speakerName, label = "", content }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether HTML already includes an inline dice roll block.
|
||||
* @param {string} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function contentIncludesRollDisplay(content) {
|
||||
return typeof content === "string" && content.includes("dice-roll");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render roll HTML for embedding in a chat card body.
|
||||
* @param {Roll[]} rolls
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function renderRollsHTML(rolls) {
|
||||
let html = "";
|
||||
for (const roll of rolls) {
|
||||
if (!roll._evaluated) await roll.evaluate();
|
||||
html += await roll.render();
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap message content in the Delta Green chat card shell.
|
||||
* @param {object} messageData
|
||||
@@ -176,10 +199,18 @@ export async function prepareDGRollChatMessageData({
|
||||
scene,
|
||||
});
|
||||
|
||||
let bodyContent = content ?? String(roll.total);
|
||||
|
||||
// Foundry only auto-injects roll HTML when content has no child elements.
|
||||
// DG chat cards always wrap content in a <section>, so embed rolls in the body.
|
||||
if (label && !contentIncludesRollDisplay(bodyContent)) {
|
||||
bodyContent = `${bodyContent}${await renderRollsHTML([roll])}`;
|
||||
}
|
||||
|
||||
let messageData = {
|
||||
author: game.user.id,
|
||||
speaker,
|
||||
content: content ?? String(roll.total),
|
||||
content: bodyContent,
|
||||
sound: CONFIG.sounds.dice,
|
||||
rolls: [roll],
|
||||
flags: foundry.utils.mergeObject(
|
||||
|
||||
@@ -6,10 +6,17 @@ const { NumberField, ObjectField } = foundry.data.fields;
|
||||
export const PROFESSION_OPTION_PICKS_KEY = "optionPicks";
|
||||
|
||||
export default class ProfessionItemData extends foundry.abstract.TypeDataModel {
|
||||
/** @inheritdoc */
|
||||
static migrateData(source, options, _state) {
|
||||
const bonds = Number(source.bonds);
|
||||
if (!Number.isFinite(bonds) || bonds < 1) source.bonds = 1;
|
||||
return super.migrateData(source, options, _state);
|
||||
}
|
||||
|
||||
static defineSchema() {
|
||||
return {
|
||||
...defineBaseItemSystemFields(),
|
||||
bonds: new NumberField({ initial: 0, min: 0, max: 5, integer: true }),
|
||||
bonds: new NumberField({ initial: 1, min: 1, max: 5, integer: true }),
|
||||
automaticSkills: new ObjectField({ initial: {} }),
|
||||
automaticSkillMeta: new ObjectField({ initial: {} }),
|
||||
optionSkills: new ObjectField({ initial: { [PROFESSION_OPTION_PICKS_KEY]: 0 } }),
|
||||
|
||||
@@ -17,7 +17,7 @@ function resolveTransferItem(effect) {
|
||||
* @param {Item} item
|
||||
* @returns {{ inactive: boolean, reasonKey: string|null }}
|
||||
*/
|
||||
export function getTransferSuppressionState(item) {
|
||||
function getTransferSuppressionState(item) {
|
||||
switch (item.type) {
|
||||
case "weapon":
|
||||
case "armor":
|
||||
@@ -54,7 +54,7 @@ export function getTransferSuppressionState(item) {
|
||||
* @param {Item} item
|
||||
* @returns {boolean} True when a transfer effect on this item should not apply.
|
||||
*/
|
||||
export function isTransferEffectInactive(item) {
|
||||
function isTransferEffectInactive(item) {
|
||||
return getTransferSuppressionState(item).inactive;
|
||||
}
|
||||
|
||||
|
||||
+26
-10
@@ -13,6 +13,10 @@ import {
|
||||
isDiceSoNiceAvailable,
|
||||
waitForDiceSoNiceMessageAnimation,
|
||||
} from "../utils/dice-so-nice.js";
|
||||
import {
|
||||
clampPercentileRollTarget,
|
||||
getRollTargetDisplayClassFromModifier,
|
||||
} from "../utils/active-effect-derived.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
@@ -187,9 +191,14 @@ export class DGPercentileRoll extends DGRoll {
|
||||
}
|
||||
}
|
||||
|
||||
const rollTargetModifier = this.rollTargetModifier;
|
||||
const targetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(rollTargetModifier);
|
||||
|
||||
return showPercentileRollModifyDialog({
|
||||
label: this.localizedKey,
|
||||
target: this.target,
|
||||
targetDisplayClass,
|
||||
hideTarget: hideSanTarget,
|
||||
defaultModifier: customModifierTarget,
|
||||
actor: this.actor,
|
||||
@@ -342,6 +351,7 @@ export class DGPercentileRoll extends DGRoll {
|
||||
*/
|
||||
createLabel() {
|
||||
const startOfLabel = `<b>${this.localizedKey}`;
|
||||
const rollTargetModifier = this.rollTargetModifier;
|
||||
const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${
|
||||
this.effectiveTarget
|
||||
}`;
|
||||
@@ -353,8 +363,6 @@ export class DGPercentileRoll extends DGRoll {
|
||||
.toUpperCase()}]</b> ${endOfLabel}`
|
||||
: `${startOfLabel}</b><br> ${endOfLabel}%`;
|
||||
|
||||
const rollTargetModifier = this.rollTargetModifier;
|
||||
|
||||
if (this.modifier || rollTargetModifier) {
|
||||
label += ` (${this.target}%`;
|
||||
|
||||
@@ -391,13 +399,19 @@ export class DGPercentileRoll extends DGRoll {
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use rollTargetModifier via Active Effects instead. */
|
||||
get exhausted() {
|
||||
const modifier = this.rollTargetModifier;
|
||||
return {
|
||||
isExhausted: modifier !== 0,
|
||||
exhaustedCheckPenalty: modifier,
|
||||
};
|
||||
/**
|
||||
* Target after roll-target Active Effects, before dialog modifier.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
get rollTargetEffectiveTarget() {
|
||||
if (!this.target || Number.isNaN(this.target)) return null;
|
||||
|
||||
const rollTargetModifier = this.rollTargetModifier;
|
||||
if (!rollTargetModifier) return parseInt(this.target);
|
||||
|
||||
return clampPercentileRollTarget(this.target, rollTargetModifier, {
|
||||
allowOver99: this.target > 99 && this.type === "stat",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -487,7 +501,9 @@ export class DGPercentileRoll extends DGRoll {
|
||||
target = parseInt(this.target);
|
||||
|
||||
if (rollTargetModifier) {
|
||||
target += rollTargetModifier;
|
||||
target = clampPercentileRollTarget(target, rollTargetModifier, {
|
||||
allowOver99: this.target > 99 && this.type === "stat",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.modifier && !Number.isNaN(this.modifier)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { showDgDialog } from "../applications/dg-dialog.js";
|
||||
import DGUtils from "../utils/utility-functions.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
|
||||
const PERCENTILE_ROLL_DIALOG_TEMPLATE =
|
||||
"systems/deltagreen/templates/dialog/modify-percentile-roll.html";
|
||||
@@ -55,6 +55,7 @@ function readMessageMode(dialog) {
|
||||
export async function showPercentileRollModifyDialog({
|
||||
label,
|
||||
target,
|
||||
targetDisplayClass = "",
|
||||
hideTarget = false,
|
||||
defaultModifier = 20,
|
||||
actor = null,
|
||||
@@ -68,6 +69,7 @@ export async function showPercentileRollModifyDialog({
|
||||
data: {
|
||||
label,
|
||||
originalTarget: target,
|
||||
targetDisplayClass,
|
||||
targetModifier: defaultModifier,
|
||||
hideTarget,
|
||||
},
|
||||
@@ -101,9 +103,9 @@ export async function showPercentileRollModifyDialog({
|
||||
}),
|
||||
}));
|
||||
|
||||
return DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "modify-roll",
|
||||
content,
|
||||
classes: ["modify-roll-dialog-app"],
|
||||
form: { closeOnSubmit: false },
|
||||
position: { width: 420 },
|
||||
window: {
|
||||
@@ -170,7 +172,8 @@ export async function showDamageRollModifyDialog({ itemName, formula }) {
|
||||
},
|
||||
});
|
||||
|
||||
return DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "modify-damage-roll",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"),
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
hasActiveStimulantEffect,
|
||||
} from "../utils/stimulant-effect.js";
|
||||
import { assignProfessionToAgent } from "../applications/profession-setup-flow.js";
|
||||
import { showDgDialog } from "../applications/dg-dialog.js";
|
||||
import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
const AgentSheetBase = EffectsTabMixin(DGActorSheet);
|
||||
@@ -362,7 +361,8 @@ export default class DGAgentSheet extends AgentSheetBase {
|
||||
);
|
||||
|
||||
let penalty = null;
|
||||
const confirmed = await DialogV2.wait({
|
||||
const confirmed = await showDgDialog({
|
||||
modifier: "exhaust-agent",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Physical.ExhaustDialogTitle"),
|
||||
@@ -401,6 +401,7 @@ export default class DGAgentSheet extends AgentSheetBase {
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
roll: wpRoll,
|
||||
contentKey: "DG.Physical.Chat.Exhausted",
|
||||
labelKey: "DG.Physical.Chat.ExhaustedLabel",
|
||||
i18nData: {
|
||||
@@ -424,6 +425,7 @@ export default class DGAgentSheet extends AgentSheetBase {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasExhausted = DGAgentSheet._isActorExhausted(actor);
|
||||
const wpRoll = await new Roll("1d6").evaluate();
|
||||
const gain = wpRoll.total;
|
||||
const currentWp = Number(actor.system.wp.value) || 0;
|
||||
@@ -441,8 +443,12 @@ export default class DGAgentSheet extends AgentSheetBase {
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
roll: wpRoll,
|
||||
contentKey: "DG.Physical.Chat.Rested",
|
||||
labelKey: "DG.Physical.Chat.RestedLabel",
|
||||
extraContentKey: wasExhausted
|
||||
? "DG.Physical.Chat.RestedNoLongerExhausted"
|
||||
: null,
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
gain,
|
||||
@@ -477,12 +483,12 @@ export default class DGAgentSheet extends AgentSheetBase {
|
||||
{},
|
||||
);
|
||||
|
||||
const choice = await DialogV2.wait({
|
||||
const choice = await showDgDialog({
|
||||
modifier: "stimulants",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Physical.StimulantsDialogTitle"),
|
||||
},
|
||||
classes: ["stimulants-dialog-app"],
|
||||
buttons: [
|
||||
{
|
||||
action: "regular",
|
||||
@@ -639,7 +645,8 @@ export default class DGAgentSheet extends AgentSheetBase {
|
||||
},
|
||||
);
|
||||
|
||||
return foundry.applications.api.DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "apply-skill-improvements",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Skills.ApplySkillImprovements.Title"),
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
prepareProfessionSkillRows,
|
||||
splitProfessionSkillMap,
|
||||
} from "../../utils/profession-skills.js";
|
||||
import {
|
||||
getDialogContentRoot,
|
||||
showDgDialog,
|
||||
} from "../../applications/dg-dialog.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { ForcedDeletion } = foundry.data.operators;
|
||||
|
||||
@@ -69,16 +72,16 @@ export default function ProfessionItemMixin(Base) {
|
||||
{ catalog, isTyped: false },
|
||||
);
|
||||
|
||||
const confirmed = await DialogV2.wait({
|
||||
const confirmed = await showDgDialog({
|
||||
modifier: "add-profession-skill",
|
||||
content,
|
||||
position: { width: 300 },
|
||||
classes: ["add-profession-skill-dialog-app"],
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ItemWindow.Profession.AddSkillTitle"),
|
||||
},
|
||||
form: { closeOnSubmit: false },
|
||||
render: (_event, dialog) => {
|
||||
const root = dialog.element?.querySelector(".dialog-content");
|
||||
onRender: (dialog) => {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
const select = root?.querySelector('[name="skillCatalogId"]');
|
||||
const typedGroup = root?.querySelector(".profession-typed-label-group");
|
||||
const typeInput = root?.querySelector('[name="typedSkillLabel"]');
|
||||
@@ -116,7 +119,7 @@ export default function ProfessionItemMixin(Base) {
|
||||
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
|
||||
default: true,
|
||||
callback: async (_event, _button, dialog) => {
|
||||
const root = dialog.element.querySelector(".dialog-content");
|
||||
const root = getDialogContentRoot(dialog);
|
||||
const select = root?.querySelector('[name="skillCatalogId"]');
|
||||
const ratingInput = root?.querySelector('[name="skillRating"]');
|
||||
const typeInput = root?.querySelector('[name="typedSkillLabel"]');
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import DG from "../../config.js";
|
||||
import { reorderForColumnSorting } from "../../utils/skill-layout.js";
|
||||
import { getRollTargetDisplayClassFromModifier } from "../../utils/active-effect-derived.js";
|
||||
import {
|
||||
applySkillTooltipDisplayMode,
|
||||
buildAgentSpecialTrainingTooltip,
|
||||
buildSkillTooltip,
|
||||
} from "../../utils/skill-tooltip.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function SkillPrepMixin(Base) {
|
||||
@@ -81,6 +87,13 @@ export default function SkillPrepMixin(Base) {
|
||||
skill.sortLabel = skill.key;
|
||||
}
|
||||
|
||||
skill.tooltip = buildSkillTooltip(
|
||||
"sheet",
|
||||
this.actor,
|
||||
{ kind: "typed", group: skill.group, label: skill.label },
|
||||
{ proficiency: Number(skill.proficiency) || 0 },
|
||||
);
|
||||
|
||||
sortedCustomSkills.push(skill);
|
||||
}
|
||||
|
||||
@@ -113,13 +126,12 @@ export default function SkillPrepMixin(Base) {
|
||||
/** @returns {void} */
|
||||
_prepareSkillTooltips() {
|
||||
for (const skill of Object.values(this.actor.system.sortedSkills)) {
|
||||
skill.tooltip = game.i18n.localize(`DG.Skills.Tooltip.${skill.key}`);
|
||||
if (!skill.proficiency) {
|
||||
skill.tooltip = skill.tooltip.concat(
|
||||
"<br><br>",
|
||||
game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"),
|
||||
);
|
||||
}
|
||||
skill.tooltip = buildSkillTooltip(
|
||||
"sheet",
|
||||
this.actor,
|
||||
{ kind: "fixed", key: skill.key },
|
||||
{ proficiency: Number(skill.proficiency) || 0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +178,37 @@ export default function SkillPrepMixin(Base) {
|
||||
|
||||
simplifiedTraining.attributeLabel = attributeLabel;
|
||||
simplifiedTraining.attributeTooltip = attributeTooltip;
|
||||
|
||||
if (this.actor.type === "agent") {
|
||||
const rollTarget = this.actor.system.rollTarget;
|
||||
let modifier = 0;
|
||||
|
||||
if (rollTarget) {
|
||||
if (DG.statistics.includes(training.attribute)) {
|
||||
modifier = Number(rollTarget.statistics) || 0;
|
||||
} else {
|
||||
modifier = Number(rollTarget.allSkills) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
simplifiedTraining.rollTargetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(modifier);
|
||||
}
|
||||
|
||||
simplifiedTraining.displayLabel = `${simplifiedTraining.name} (${attributeLabel}, ${simplifiedTraining.targetNumber}%)`;
|
||||
|
||||
if (this.actor.type === "agent" && attributeTooltip) {
|
||||
const rollTargetKey = DG.statistics.includes(training.attribute)
|
||||
? "system.rollTarget.statistics"
|
||||
: "system.rollTarget.allSkills";
|
||||
simplifiedTraining.tooltip = buildAgentSpecialTrainingTooltip(
|
||||
this.actor,
|
||||
attributeTooltip,
|
||||
rollTargetKey,
|
||||
simplifiedTraining.targetNumber,
|
||||
);
|
||||
}
|
||||
|
||||
return simplifiedTraining;
|
||||
});
|
||||
}
|
||||
@@ -201,73 +242,7 @@ export default function SkillPrepMixin(Base) {
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
_tooltipsSettings(root) {
|
||||
const mode = game.settings.get("deltagreen", "skillTooltipDisplay");
|
||||
|
||||
if (mode !== "hoverShift" && mode !== "never") return;
|
||||
|
||||
const nodes = root.querySelectorAll("[data-tooltip],[title]");
|
||||
|
||||
if (mode === "never") {
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
|
||||
let html = el.getAttribute("data-tooltip");
|
||||
let isHtml = true;
|
||||
|
||||
if (!html) {
|
||||
const title = el.getAttribute("title");
|
||||
if (title) {
|
||||
html = foundry.utils.escapeHTML(title);
|
||||
isHtml = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return;
|
||||
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
|
||||
const opts = isHtml ? { html } : { text: html };
|
||||
|
||||
const show = () => game.tooltip.activate(el, opts);
|
||||
const hide = () => game.tooltip.deactivate();
|
||||
|
||||
const onKey = (ev) => {
|
||||
if (ev.key !== "Shift") return;
|
||||
if (!document.body.contains(el)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
return;
|
||||
}
|
||||
if (ev.type === "keydown") show();
|
||||
else hide();
|
||||
};
|
||||
|
||||
const onEnter = (ev) => {
|
||||
if (ev.shiftKey) show();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
hide();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
el.addEventListener("pointerenter", onEnter, { passive: true });
|
||||
el.addEventListener("pointerleave", onLeave, { passive: true });
|
||||
});
|
||||
applySkillTooltipDisplayMode(root);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,11 +14,92 @@ import {
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
function getActiveEffectModificationClass(current, base) {
|
||||
export function getActiveEffectModificationClass(current, base) {
|
||||
if (current == null || base == null || current === base) return "";
|
||||
return current > base ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet color for roll-target modifiers (uses modifier sign, not clamped effective vs base).
|
||||
* @param {number} modifier
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getRollTargetDisplayClassFromModifier(modifier) {
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier) return "";
|
||||
return numericModifier > 0 ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a roll-target Active Effect modifier with the same clamp rules as percentile rolls.
|
||||
* @param {number} base
|
||||
* @param {number} modifier
|
||||
* @param {{ allowOver99?: boolean }} [options]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampPercentileRollTarget(
|
||||
base,
|
||||
modifier,
|
||||
{ allowOver99 = false } = {},
|
||||
) {
|
||||
const numericBase = Number(base);
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier || !Number.isFinite(numericBase)) return numericBase;
|
||||
|
||||
let target = Math.round(numericBase + numericModifier);
|
||||
target = Math.max(1, target);
|
||||
if (!allowOver99) {
|
||||
target = Math.min(target, 99);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} entry
|
||||
* @param {number} modifier
|
||||
*/
|
||||
function attachRollTargetDisplay(entry, modifier) {
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier) {
|
||||
entry.rollTargetDisplayClass = "";
|
||||
return;
|
||||
}
|
||||
|
||||
entry.rollTargetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(numericModifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet display for agent roll-target Active Effects (skills, stats x5, SAN).
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentRollTargetDisplay(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const system = actor.system;
|
||||
const rollTarget = system.rollTarget;
|
||||
if (!rollTarget) return;
|
||||
|
||||
const allSkillsMod = Number(rollTarget.allSkills) || 0;
|
||||
const sanityMod = Number(rollTarget.sanity) || 0;
|
||||
const statisticsMod = Number(rollTarget.statistics) || 0;
|
||||
|
||||
for (const skill of Object.values(system.skills ?? {})) {
|
||||
attachRollTargetDisplay(skill, allSkillsMod);
|
||||
}
|
||||
|
||||
for (const skill of Object.values(system.typedSkills ?? {})) {
|
||||
attachRollTargetDisplay(skill, allSkillsMod);
|
||||
}
|
||||
|
||||
attachRollTargetDisplay(system.sanity, sanityMod);
|
||||
|
||||
for (const stat of Object.values(system.statistics ?? {})) {
|
||||
attachRollTargetDisplay(stat, statisticsMod);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach sheet display classes comparing prepared data to persisted source (pre–AE).
|
||||
* @param {Actor} actor
|
||||
@@ -82,6 +163,7 @@ export function refreshDerivedAfterActiveEffects(actor) {
|
||||
|
||||
const sourceStatistics = actor._source?.system?.statistics;
|
||||
prepareStatisticsX5(system.statistics, sourceStatistics);
|
||||
prepareAgentRollTargetDisplay(actor);
|
||||
applyAgentResourceMaxBonuses(system, sourceStatistics);
|
||||
setSkillTargetProficiencies(system.skills);
|
||||
setSkillTargetProficiencies(system.typedSkills);
|
||||
|
||||
@@ -75,7 +75,12 @@ const AGENT_SKILL_DEFAULTS = /** @type {Record<string, number>} */ ({
|
||||
|
||||
const BONUS_SKILL_COUNT = 8;
|
||||
const BONUS_SKILL_INCREMENT = 20;
|
||||
|
||||
/** @type {ReadonlySet<string>} */
|
||||
const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]);
|
||||
const SKILL_CAP = 80;
|
||||
/** Bonus picks that would waste this many points or more are rejected (e.g. 80 + 20% at cap). */
|
||||
const MAX_ALLOWED_BONUS_WASTE = 20;
|
||||
|
||||
const TYPED_KEY_PATTERN =
|
||||
/^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i;
|
||||
@@ -126,16 +131,6 @@ export function isChooseOneProfessionSkillKey(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} mapKey
|
||||
* @param {Record<string, ProfessionSkillMeta>} automaticMeta
|
||||
* @param {Record<string, ProfessionSkillMeta>} optionMeta
|
||||
* @returns {ProfessionSkillMeta}
|
||||
*/
|
||||
export function getProfessionSkillMeta(mapKey, automaticMeta, optionMeta) {
|
||||
return automaticMeta[mapKey] ?? optionMeta[mapKey] ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.group
|
||||
@@ -393,6 +388,19 @@ export function buildSkillCatalog() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill catalog for character-creation bonus picks (excludes Unnatural).
|
||||
*
|
||||
* @returns {SkillCatalogEntry[]}
|
||||
*/
|
||||
export function buildBonusSkillCatalog() {
|
||||
return buildSkillCatalog().filter(
|
||||
(entry) =>
|
||||
entry.ref.kind !== "fixed" ||
|
||||
!BONUS_SKILL_CATALOG_EXCLUDED_FIXED.has(entry.ref.key),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} catalogId
|
||||
* @param {string} [typedLabel]
|
||||
@@ -420,6 +428,119 @@ export function catalogIdToSkillRef(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBonusTrackKey(ref) {
|
||||
if (ref.kind === "fixed") return `fixed:${ref.key}`;
|
||||
return formatProfessionSkillKey(ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} trackKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBonusTrackLabel(trackKey) {
|
||||
if (trackKey.startsWith("fixed:")) {
|
||||
const key = trackKey.slice(6);
|
||||
return formatProfessionSkillLabel(
|
||||
/** @type {FixedSkillRef} */ ({ kind: "fixed", key }),
|
||||
);
|
||||
}
|
||||
const ref = parseProfessionSkillKey(trackKey);
|
||||
if (ref) return formatProfessionSkillLabel(ref);
|
||||
return trackKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} trackKey
|
||||
* @param {Record<string, number>} baseFixed
|
||||
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
|
||||
* @param {Record<string, number>} defaults
|
||||
* @returns {number}
|
||||
*/
|
||||
function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) {
|
||||
if (trackKey.startsWith("fixed:")) {
|
||||
const key = trackKey.slice(6);
|
||||
return baseFixed[key] ?? defaults[key] ?? 0;
|
||||
}
|
||||
return baseTyped[trackKey]?.value ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bonus skill slots that would waste 20+ points or raise a skill already at the creation cap.
|
||||
*
|
||||
* @param {Record<string, number>} baseFixed
|
||||
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
|
||||
* @param {string[]} bonusCatalogIds
|
||||
* @param {string[]} bonusTypedLabels
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function collectBonusCapValidationErrors(
|
||||
baseFixed,
|
||||
baseTyped,
|
||||
bonusCatalogIds,
|
||||
bonusTypedLabels,
|
||||
) {
|
||||
const defaults = getAgentSkillDefaults();
|
||||
/** @type {Record<string, number>} */
|
||||
const counts = {};
|
||||
/** @type {(string | null)[]} */
|
||||
const slotTrackKeys = [];
|
||||
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const catalogId = bonusCatalogIds?.[i];
|
||||
if (!catalogId) {
|
||||
slotTrackKeys.push(null);
|
||||
continue;
|
||||
}
|
||||
const ref = catalogIdToSkillRef(
|
||||
catalogId,
|
||||
bonusTypedLabels?.[i] ?? "",
|
||||
);
|
||||
if (!ref) {
|
||||
slotTrackKeys.push(null);
|
||||
continue;
|
||||
}
|
||||
const trackKey = getBonusTrackKey(ref);
|
||||
slotTrackKeys.push(trackKey);
|
||||
counts[trackKey] = (counts[trackKey] ?? 0) + 1;
|
||||
}
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const violatingTrackKeys = new Set();
|
||||
|
||||
for (const [trackKey, count] of Object.entries(counts)) {
|
||||
const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults);
|
||||
const final = base + count * BONUS_SKILL_INCREMENT;
|
||||
const waste = Math.max(0, final - SKILL_CAP);
|
||||
if (base >= SKILL_CAP || waste >= MAX_ALLOWED_BONUS_WASTE) {
|
||||
violatingTrackKeys.add(trackKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const errors = [];
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const trackKey = slotTrackKeys[i];
|
||||
if (!trackKey || !violatingTrackKeys.has(trackKey)) continue;
|
||||
|
||||
const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults);
|
||||
const count = counts[trackKey] ?? 0;
|
||||
const final = base + count * BONUS_SKILL_INCREMENT;
|
||||
const waste = Math.max(0, final - SKILL_CAP);
|
||||
|
||||
if (base >= SKILL_CAP) {
|
||||
errors.push(`bonusAtCap:${i}|${trackKey}`);
|
||||
} else {
|
||||
errors.push(`bonusWaste:${i}|${waste}|${trackKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, number>} automaticSkills
|
||||
* @param {Record<string, number>} optionSkills
|
||||
@@ -634,6 +755,15 @@ export function computeSkillValues(
|
||||
bondCount,
|
||||
});
|
||||
|
||||
validationErrors.push(
|
||||
...collectBonusCapValidationErrors(
|
||||
baseFixed,
|
||||
baseTyped,
|
||||
bonusIds,
|
||||
bonusLabels,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
fixedValues,
|
||||
typedValues,
|
||||
@@ -768,6 +898,8 @@ export function formatProfessionValidationMessages(
|
||||
let needsBonusSkillType = false;
|
||||
/** @type {Set<string>} */
|
||||
const typedNameRequiredGroups = new Set();
|
||||
/** @type {string[]} */
|
||||
const bonusAtCapTrackKeys = [];
|
||||
|
||||
for (const code of errors) {
|
||||
if (code === "optionPicks") {
|
||||
@@ -782,11 +914,29 @@ export function formatProfessionValidationMessages(
|
||||
needsBondRelationship = true;
|
||||
continue;
|
||||
}
|
||||
const bonusAtCap = code.match(/^bonusAtCap:\d+\|(.+)$/);
|
||||
if (bonusAtCap) {
|
||||
bonusAtCapTrackKeys.push(bonusAtCap[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/);
|
||||
if (bonusWaste) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillWasteTooHigh", {
|
||||
skill: getBonusTrackLabel(bonusWaste[2]),
|
||||
waste: Number(bonusWaste[1]),
|
||||
maxWaste: MAX_ALLOWED_BONUS_WASTE - 1,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code.startsWith("bonusType")) {
|
||||
needsBonusSkillType = true;
|
||||
continue;
|
||||
}
|
||||
if (code.startsWith("bonus")) {
|
||||
if (/^bonus\d+$/.test(code)) {
|
||||
needsBonusSkill = true;
|
||||
continue;
|
||||
}
|
||||
@@ -816,12 +966,6 @@ export function formatProfessionValidationMessages(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (needsBondName) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired"));
|
||||
}
|
||||
if (needsBondRelationship) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired"));
|
||||
}
|
||||
if (needsBonusSkill) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", {
|
||||
@@ -839,6 +983,20 @@ export function formatProfessionValidationMessages(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (needsBondName) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired"));
|
||||
}
|
||||
if (needsBondRelationship) {
|
||||
push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired"));
|
||||
}
|
||||
|
||||
for (const trackKey of bonusAtCapTrackKeys) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillAlreadyAtCap", {
|
||||
skill: getBonusTrackLabel(trackKey),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -915,4 +1073,4 @@ export function prepareProfessionSkillRows(skillMap, skillMeta = {}) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export { BONUS_SKILL_COUNT, BONUS_SKILL_INCREMENT, SKILL_CAP };
|
||||
export { BONUS_SKILL_COUNT };
|
||||
|
||||
@@ -94,8 +94,8 @@ export async function rollStatisticScores({
|
||||
*/
|
||||
export function buildRollStatsChatContent(rolls) {
|
||||
const cells = rolls.map(
|
||||
(entry) =>
|
||||
`<td class="roll-stats-chat-value">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
|
||||
(entry, index) =>
|
||||
`<td class="roll-stats-chat-value roll-stats-chat-value--${index % 2 === 0 ? "a" : "b"}">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
|
||||
);
|
||||
const rows = [];
|
||||
for (let i = 0; i < cells.length; i += 3) {
|
||||
|
||||
@@ -16,14 +16,6 @@ export default function registerHandlebarsHelpers() {
|
||||
return outStr;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toLowerCase", (str) => {
|
||||
try {
|
||||
return str.toLowerCase();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toUpperCase", (str) => {
|
||||
try {
|
||||
return str.toUpperCase();
|
||||
@@ -39,13 +31,6 @@ export default function registerHandlebarsHelpers() {
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_not_eq", (a, b, opts) => {
|
||||
if (a !== b) {
|
||||
return opts.fn(this);
|
||||
}
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => {
|
||||
if (a > b) {
|
||||
return trueVal;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createDGChatMessage } from "../chat/dg-chat-card.js";
|
||||
import {
|
||||
createDGChatMessage,
|
||||
createDGRollChatMessage,
|
||||
} from "../chat/dg-chat-card.js";
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
@@ -7,6 +10,8 @@ import { createDGChatMessage } from "../chat/dg-chat-card.js";
|
||||
* @param {string} params.contentKey i18n key for message body.
|
||||
* @param {string} params.labelKey i18n key for card subtitle.
|
||||
* @param {Record<string, string|number>} params.i18nData
|
||||
* @param {string|null} [params.extraContentKey] Optional i18n key appended on a new line after the main body.
|
||||
* @param {Roll} [params.roll] When set, attaches the roll to the chat message for display and Dice So Nice.
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export async function createAgentResourceChatMessage({
|
||||
@@ -15,13 +20,32 @@ export async function createAgentResourceChatMessage({
|
||||
contentKey,
|
||||
labelKey,
|
||||
i18nData,
|
||||
extraContentKey = null,
|
||||
roll = null,
|
||||
}) {
|
||||
const content = game.i18n.format(contentKey, i18nData);
|
||||
let content = game.i18n.format(contentKey, i18nData);
|
||||
if (extraContentKey) {
|
||||
content = `${content}<br>${game.i18n.format(extraContentKey, i18nData)}`;
|
||||
}
|
||||
const label = game.i18n.localize(labelKey);
|
||||
const messageMode = game.settings.get("core", "messageMode");
|
||||
|
||||
if (roll) {
|
||||
return createDGRollChatMessage({
|
||||
actor,
|
||||
token,
|
||||
roll,
|
||||
label,
|
||||
content,
|
||||
messageMode,
|
||||
});
|
||||
}
|
||||
|
||||
return createDGChatMessage({
|
||||
actor,
|
||||
token,
|
||||
label: game.i18n.localize(labelKey),
|
||||
label,
|
||||
content,
|
||||
messageMode: game.settings.get("core", "messageMode"),
|
||||
messageMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import DG from "../config.js";
|
||||
import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.js";
|
||||
import { clampPercentileRollTarget } from "./active-effect-derived.js";
|
||||
import DGUtils from "./utility-functions.js";
|
||||
|
||||
/** @typedef {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} RollTargetFieldKey */
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRollTargetEffectActive(effect) {
|
||||
if (effect.disabled) return false;
|
||||
if (effect.duration?.expired) return false;
|
||||
if (effect.isSuppressed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {string}
|
||||
*/
|
||||
function getRollTargetEffectLabel(effect) {
|
||||
if (effect.getFlag(DG.ID, "exhaustion")) {
|
||||
return game.i18n.localize("DG.Physical.ExhaustionEffectName");
|
||||
}
|
||||
|
||||
if (effect.name?.trim()) return effect.name;
|
||||
|
||||
const parent = effect.parent;
|
||||
if (parent?.documentName === "Item") return parent.name ?? "";
|
||||
|
||||
return effect.name ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {RollTargetFieldKey} rollTargetFieldKey
|
||||
* @returns {Array<{ name: string, modifier: number }>}
|
||||
*/
|
||||
export function collectRollTargetContributions(actor, rollTargetFieldKey) {
|
||||
if (actor.type !== "agent" || !ROLL_TARGET_FIELD_KEYS.includes(rollTargetFieldKey)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributions = [];
|
||||
|
||||
for (const effect of actor.appliedEffects ?? []) {
|
||||
if (!isRollTargetEffectActive(effect)) continue;
|
||||
|
||||
const change = (effect.changes ?? []).find(
|
||||
(entry) => entry.key === rollTargetFieldKey && entry.type === "add",
|
||||
);
|
||||
if (!change) continue;
|
||||
|
||||
const modifier = Number(change.value);
|
||||
if (!Number.isFinite(modifier) || modifier === 0) continue;
|
||||
|
||||
contributions.push({
|
||||
name: getRollTargetEffectLabel(effect),
|
||||
modifier,
|
||||
});
|
||||
}
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatTargetPercent(value) {
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} modifier
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatModifierPercent(modifier) {
|
||||
return `${DGUtils.formatStringWithLeadingPlus(modifier)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} base
|
||||
* @param {Array<{ name: string, modifier: number }>} contributions
|
||||
* @param {{ allowOver99?: boolean }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRollTargetBreakdownTable(
|
||||
base,
|
||||
contributions,
|
||||
{ allowOver99 = false } = {},
|
||||
) {
|
||||
const rows = [
|
||||
[
|
||||
game.i18n.localize("DG.Tooltip.RollTarget.BaseTarget"),
|
||||
formatTargetPercent(base),
|
||||
],
|
||||
];
|
||||
|
||||
for (const { name, modifier } of contributions) {
|
||||
rows.push([name, formatModifierPercent(modifier)]);
|
||||
}
|
||||
|
||||
const totalModifier = contributions.reduce(
|
||||
(sum, entry) => sum + entry.modifier,
|
||||
0,
|
||||
);
|
||||
const finalTarget = clampPercentileRollTarget(base, totalModifier, {
|
||||
allowOver99,
|
||||
});
|
||||
|
||||
rows.push([
|
||||
game.i18n.localize("DG.Tooltip.RollTarget.FinalTarget"),
|
||||
formatTargetPercent(finalTarget),
|
||||
]);
|
||||
|
||||
const body = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><td>${foundry.utils.escapeHTML(label)}</td><td>${foundry.utils.escapeHTML(value)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<table class="dg-roll-target-tooltip"><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRollTargetTargetLine(base) {
|
||||
return game.i18n.format("DG.Tooltip.RollTarget.Target", {
|
||||
value: Math.round(base),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} existingTooltip
|
||||
* @param {Actor} actor
|
||||
* @param {RollTargetFieldKey} rollTargetFieldKey
|
||||
* @param {number} base
|
||||
* @param {{ allowOver99?: boolean, showTargetWhenUnmodified?: boolean }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appendRollTargetTooltipSection(
|
||||
existingTooltip,
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
options = {},
|
||||
) {
|
||||
if (actor.type !== "agent") return existingTooltip;
|
||||
|
||||
const { showTargetWhenUnmodified = true, ...tableOptions } = options;
|
||||
|
||||
const contributions = collectRollTargetContributions(
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
);
|
||||
|
||||
let appendix = "";
|
||||
if (contributions.length > 0) {
|
||||
appendix = buildRollTargetBreakdownTable(base, contributions, tableOptions);
|
||||
} else if (showTargetWhenUnmodified) {
|
||||
appendix = buildRollTargetTargetLine(base);
|
||||
}
|
||||
|
||||
if (!appendix) return existingTooltip;
|
||||
if (!existingTooltip) return appendix;
|
||||
return `${existingTooltip}<br><br>${appendix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat and sanity roll-target tooltips for the agent sheet left bar (not skills).
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentStatSanityTooltips(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const system = actor.system;
|
||||
|
||||
for (const [key, stat] of Object.entries(system.statistics ?? {})) {
|
||||
const base = Number(stat.x5) || 0;
|
||||
const existing = game.i18n.localize(`DG.Attributes.Tooltip.${key}`);
|
||||
stat.tooltip = appendRollTargetTooltipSection(
|
||||
existing,
|
||||
actor,
|
||||
"system.rollTarget.statistics",
|
||||
base,
|
||||
{ allowOver99: base > 99 },
|
||||
);
|
||||
}
|
||||
|
||||
const sanity = system.sanity;
|
||||
if (sanity) {
|
||||
const existing = `${game.i18n.localize("DG.Tooltip.CurrentSanityPartOne")}${sanity.currentBreakingPoint}${game.i18n.localize("DG.Tooltip.CurrentSanityPartTwo")}`;
|
||||
const base = Number(sanity.value) || 0;
|
||||
sanity.tooltip = appendRollTargetTooltipSection(
|
||||
existing,
|
||||
actor,
|
||||
"system.rollTarget.sanity",
|
||||
base,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
/**
|
||||
* Splits a flat list into column arrays for vertical stacking (top-to-bottom per column).
|
||||
*
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {number} numCols
|
||||
* @returns {T[][]}
|
||||
*/
|
||||
export function splitIntoColumns(arr, numCols) {
|
||||
if (numCols <= 1) return [arr];
|
||||
const columns = Array.from({ length: numCols }, () => /** @type {T[]} */ ([]));
|
||||
const perCol = Math.ceil(arr.length / numCols);
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
columns[col] = arr.slice(col * perCol, (col + 1) * perCol);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders a flat array into column-major order for grid display.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/** @typedef {import("./profession-skills.js").ProfessionSkillRef} ProfessionSkillRef */
|
||||
/** @typedef {"dialog" | "sheet"} SkillTooltipContext */
|
||||
|
||||
import { appendRollTargetTooltipSection } from "./roll-target-tooltip.js";
|
||||
|
||||
/**
|
||||
* Descriptive skill tooltip only (no roll target or 0% appendix).
|
||||
*
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSkillDescriptiveTooltip(ref) {
|
||||
if (ref.kind === "fixed") {
|
||||
const key = `DG.Skills.Tooltip.${ref.key}`;
|
||||
const localized = game.i18n.localize(key);
|
||||
return localized === key ? "" : localized;
|
||||
}
|
||||
|
||||
const key = `DG.TypeSkills.Tooltip.${ref.group}`;
|
||||
const localized = game.i18n.localize(key);
|
||||
return localized === key ? "" : localized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skill tooltip for character creation or the agent sheet.
|
||||
*
|
||||
* @param {SkillTooltipContext} context
|
||||
* @param {Actor | null} actor
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @param {{ proficiency?: number }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSkillTooltip(
|
||||
context,
|
||||
actor,
|
||||
ref,
|
||||
{ proficiency = 0 } = {},
|
||||
) {
|
||||
let tooltip = getSkillDescriptiveTooltip(ref);
|
||||
|
||||
if (ref.kind === "fixed" && !tooltip) {
|
||||
tooltip = game.i18n.localize(`DG.Skills.Tooltip.${ref.key}`);
|
||||
}
|
||||
|
||||
if (context === "dialog") return tooltip;
|
||||
|
||||
if (ref.kind === "fixed" && !proficiency) {
|
||||
tooltip = tooltip.concat(
|
||||
"<br><br>",
|
||||
game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"),
|
||||
);
|
||||
}
|
||||
|
||||
if (actor?.type === "agent") {
|
||||
tooltip = appendRollTargetTooltipSection(
|
||||
tooltip,
|
||||
actor,
|
||||
"system.rollTarget.allSkills",
|
||||
Number(proficiency) || 0,
|
||||
{ showTargetWhenUnmodified: false },
|
||||
);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent sheet special-training row tooltip (attribute-based, not ProfessionSkillRef).
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @param {string} attributeTooltipKey i18n key for the underlying attribute/skill
|
||||
* @param {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} rollTargetFieldKey
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildAgentSpecialTrainingTooltip(
|
||||
actor,
|
||||
attributeTooltipKey,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
) {
|
||||
return appendRollTargetTooltipSection(
|
||||
game.i18n.localize(attributeTooltipKey),
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
{
|
||||
allowOver99:
|
||||
rollTargetFieldKey === "system.rollTarget.statistics" && base > 99,
|
||||
showTargetWhenUnmodified: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client skillTooltipDisplay setting (hover / hoverShift / never).
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applySkillTooltipDisplayMode(root) {
|
||||
const mode = game.settings.get("deltagreen", "skillTooltipDisplay");
|
||||
|
||||
if (mode !== "hoverShift" && mode !== "never") return;
|
||||
|
||||
const nodes = root.querySelectorAll("[data-tooltip],[title]");
|
||||
|
||||
if (mode === "never") {
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
|
||||
let html = el.getAttribute("data-tooltip");
|
||||
let isHtml = true;
|
||||
|
||||
if (!html) {
|
||||
const title = el.getAttribute("title");
|
||||
if (title) {
|
||||
html = foundry.utils.escapeHTML(title);
|
||||
isHtml = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return;
|
||||
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
|
||||
const opts = isHtml ? { html } : { text: html };
|
||||
|
||||
const show = () => game.tooltip.activate(el, opts);
|
||||
const hide = () => game.tooltip.deactivate();
|
||||
|
||||
const onKey = (ev) => {
|
||||
if (ev.key !== "Shift") return;
|
||||
if (!document.body.contains(el)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
return;
|
||||
}
|
||||
if (ev.type === "keydown") show();
|
||||
else hide();
|
||||
};
|
||||
|
||||
const onEnter = (ev) => {
|
||||
if (ev.shiftKey) show();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
hide();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
el.addEventListener("pointerenter", onEnter, { passive: true });
|
||||
el.addEventListener("pointerleave", onLeave, { passive: true });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user