} computed
* @returns {string}
*/
#buildMessagesHtml(computed) {
- const validationHtml = formatProfessionValidationMessages(
- computed.validationErrors,
- { optionPicks: this.optionPicks },
- )
- .map(
- (message) =>
- `${foundry.utils.escapeHTML(
- message,
- )}
`,
- )
- .join("");
+ const { validationMessages, capWarnings, atCapMessages } =
+ this.#getDisplayMessages(computed);
- const capHtml = computed.capWarnings
+ const toErrorHtml = (messages) =>
+ messages
+ .map(
+ (message) =>
+ `${foundry.utils.escapeHTML(
+ message,
+ )}
`,
+ )
+ .join("");
+
+ const capHtml = capWarnings
.map(
(w) =>
- `${foundry.utils.escapeHTML(
- game.i18n.format("DG.Profession.Dialog.CapWarning", {
- skill: w.label,
- attempted: w.attempted,
- waste: w.waste,
- }),
+ `
${foundry.utils.escapeHTML(
+ w.warning,
)}
`,
)
.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 `${foundry.utils.escapeHTML(
+ )}">${foundry.utils.escapeHTML(
r.label,
- )}${
+ )}${
r.value
}%
`;
})
.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 };
diff --git a/module/applications/assign-stats-dialog.js b/module/applications/assign-stats-dialog.js
index 4b8d57f..91fbdcd 100644
--- a/module/applications/assign-stats-dialog.js
+++ b/module/applications/assign-stats-dialog.js
@@ -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} 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} 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 };
diff --git a/module/applications/dg-dialog.js b/module/applications/dg-dialog.js
new file mode 100644
index 0000000..792cd97
--- /dev/null
+++ b/module/applications/dg-dialog.js
@@ -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} [options.onRender]
+ * @param {string[]} [options.classes] — extra app classes merged after base
+ * @returns {Promise}
+ */
+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);
+ },
+ });
+}
diff --git a/module/applications/pick-statistics-dialog.js b/module/applications/pick-statistics-dialog.js
index 9e29d1f..31da8ef 100644
--- a/module/applications/pick-statistics-dialog.js
+++ b/module/applications/pick-statistics-dialog.js
@@ -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 };
diff --git a/module/applications/profession-setup-flow.js b/module/applications/profession-setup-flow.js
index d825d10..e3055b4 100644
--- a/module/applications/profession-setup-flow.js
+++ b/module/applications/profession-setup-flow.js
@@ -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} True if Pick Skills was submitted successfully.
+ * @returns {Promise} 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 };
diff --git a/module/applications/roll-stats-dialog.js b/module/applications/roll-stats-dialog.js
index 4a4f67c..a854a8b 100644
--- a/module/applications/roll-stats-dialog.js
+++ b/module/applications/roll-stats-dialog.js
@@ -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 };
diff --git a/module/chat/dg-chat-card.js b/module/chat/dg-chat-card.js
index 5f515f0..da015db 100644
--- a/module/chat/dg-chat-card.js
+++ b/module/chat/dg-chat-card.js
@@ -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}
+ */
+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 , 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(
diff --git a/module/data/item/profession.js b/module/data/item/profession.js
index 1233355..cce5c37 100644
--- a/module/data/item/profession.js
+++ b/module/data/item/profession.js
@@ -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 } }),
diff --git a/module/documents/dg-active-effect.js b/module/documents/dg-active-effect.js
index 8af94f1..d9fc4e6 100644
--- a/module/documents/dg-active-effect.js
+++ b/module/documents/dg-active-effect.js
@@ -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;
}
diff --git a/module/roll/roll-classes.js b/module/roll/roll-classes.js
index 4ee80ff..3ecf69f 100644
--- a/module/roll/roll-classes.js
+++ b/module/roll/roll-classes.js
@@ -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 = `${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()}] ${endOfLabel}`
: `${startOfLabel}
${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)) {
diff --git a/module/roll/roll-dialogs.js b/module/roll/roll-dialogs.js
index d8d7e25..98c36b0 100644
--- a/module/roll/roll-dialogs.js
+++ b/module/roll/roll-dialogs.js
@@ -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"),
diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js
index 5938d7b..5e211b7 100644
--- a/module/sheets/agent-sheet.js
+++ b/module/sheets/agent-sheet.js
@@ -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"),
diff --git a/module/sheets/mixins/profession-item-mixin.js b/module/sheets/mixins/profession-item-mixin.js
index 34eb572..4b31ab4 100644
--- a/module/sheets/mixins/profession-item-mixin.js
+++ b/module/sheets/mixins/profession-item-mixin.js
@@ -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"]');
diff --git a/module/sheets/mixins/skill-prep-mixin.js b/module/sheets/mixins/skill-prep-mixin.js
index e89f9e4..69d3c78 100644
--- a/module/sheets/mixins/skill-prep-mixin.js
+++ b/module/sheets/mixins/skill-prep-mixin.js
@@ -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(
- "
",
- 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);
}
};
}
diff --git a/module/utils/active-effect-derived.js b/module/utils/active-effect-derived.js
index 60270bf..4b4eaf6 100644
--- a/module/utils/active-effect-derived.js
+++ b/module/utils/active-effect-derived.js
@@ -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);
diff --git a/module/utils/profession-skills.js b/module/utils/profession-skills.js
index ee3c946..09e65e8 100644
--- a/module/utils/profession-skills.js
+++ b/module/utils/profession-skills.js
@@ -75,7 +75,12 @@ const AGENT_SKILL_DEFAULTS = /** @type {Record} */ ({
const BONUS_SKILL_COUNT = 8;
const BONUS_SKILL_INCREMENT = 20;
+
+/** @type {ReadonlySet} */
+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} automaticMeta
- * @param {Record} 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} baseFixed
+ * @param {Record} baseTyped
+ * @param {Record} 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} baseFixed
+ * @param {Record} baseTyped
+ * @param {string[]} bonusCatalogIds
+ * @param {string[]} bonusTypedLabels
+ * @returns {string[]}
+ */
+export function collectBonusCapValidationErrors(
+ baseFixed,
+ baseTyped,
+ bonusCatalogIds,
+ bonusTypedLabels,
+) {
+ const defaults = getAgentSkillDefaults();
+ /** @type {Record} */
+ 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} */
+ 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} automaticSkills
* @param {Record} 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} */
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 };
diff --git a/module/utils/profession-stat-setup.js b/module/utils/profession-stat-setup.js
index 1f39af5..8dff4e5 100644
--- a/module/utils/profession-stat-setup.js
+++ b/module/utils/profession-stat-setup.js
@@ -94,8 +94,8 @@ export async function rollStatisticScores({
*/
export function buildRollStatsChatContent(rolls) {
const cells = rolls.map(
- (entry) =>
- `${foundry.utils.escapeHTML(String(entry.total))} | `,
+ (entry, index) =>
+ `${foundry.utils.escapeHTML(String(entry.total))} | `,
);
const rows = [];
for (let i = 0; i < cells.length; i += 3) {
diff --git a/module/utils/register-helpers.js b/module/utils/register-helpers.js
index c836d47..7e91c7e 100644
--- a/module/utils/register-helpers.js
+++ b/module/utils/register-helpers.js
@@ -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;
diff --git a/module/utils/resource-chat.js b/module/utils/resource-chat.js
index e3ac65c..c86ecda 100644
--- a/module/utils/resource-chat.js
+++ b/module/utils/resource-chat.js
@@ -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} 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}
*/
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}
${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,
});
}
diff --git a/module/utils/roll-target-tooltip.js b/module/utils/roll-target-tooltip.js
new file mode 100644
index 0000000..9f614f2
--- /dev/null
+++ b/module/utils/roll-target-tooltip.js
@@ -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]) =>
+ `| ${foundry.utils.escapeHTML(label)} | ${foundry.utils.escapeHTML(value)} |
`,
+ )
+ .join("");
+
+ return ``;
+}
+
+/**
+ * @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}
${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,
+ );
+ }
+}
diff --git a/module/utils/skill-layout.js b/module/utils/skill-layout.js
index 7f6d669..7aef8ba 100644
--- a/module/utils/skill-layout.js
+++ b/module/utils/skill-layout.js
@@ -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.
*
diff --git a/module/utils/skill-tooltip.js b/module/utils/skill-tooltip.js
new file mode 100644
index 0000000..3ddccb0
--- /dev/null
+++ b/module/utils/skill-tooltip.js
@@ -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(
+ "
",
+ 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 });
+ });
+}
diff --git a/system.json b/system.json
index bce0c25..d2622b5 100644
--- a/system.json
+++ b/system.json
@@ -28,7 +28,7 @@
}
},
"esmodules": ["module/deltagreen.js"],
- "styles": ["css/deltagreen.css", "css/dialogue.css"],
+ "styles": ["css/deltagreen.css", "css/dg-dialog.css", "css/dialogue.css"],
"languages": [
{
"lang": "en",
diff --git a/templates/actor/partials/custom-skills-partial-agent.html b/templates/actor/partials/custom-skills-partial-agent.html
index beb84f2..7d1f949 100644
--- a/templates/actor/partials/custom-skills-partial-agent.html
+++ b/templates/actor/partials/custom-skills-partial-agent.html
@@ -53,18 +53,13 @@
data-target="{{block.proficiency}}"
data-label="{{block.label}}"
{{#unless (eq ../../skillTooltipDisplay "never") }}
- {{#if block.proficiency }}
- data-tooltip="{{localize 'DG.Tooltip.SkillLabel' }}"
- {{/if }}
- {{#unless block.proficiency }}
- data-tooltip="{{localize 'DG.Tooltip.CannotRollSkillLabel' }}"
- {{/unless }}
+ data-tooltip="{{block.tooltip}}"
{{/unless }}>
{{block.label}}
-
-
{{else}}
-
+ data-tooltip="{{actor.system.sanity.tooltip}}" />
/
diff --git a/templates/actor/parts/left-bar.html b/templates/actor/parts/left-bar.html
index 6b147a1..5e9dd40 100644
--- a/templates/actor/parts/left-bar.html
+++ b/templates/actor/parts/left-bar.html
@@ -24,18 +24,18 @@
{{#each actor.system.statistics as |statistic key| }}