Restyled character creation dialog.

Added dialog factory.
This commit is contained in:
Aaron Lutomski
2026-05-29 21:14:23 -07:00
parent 9282cdb334
commit 46a5fb0cd6
43 changed files with 2340 additions and 1112 deletions
+2
View File
@@ -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 */
+160 -80
View File
@@ -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 };
+7 -17
View File
@@ -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 };
+103
View File
@@ -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 };
+2 -4
View File
@@ -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 };
+8 -16
View File
@@ -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 };
+32 -1
View File
@@ -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(
+8 -1
View File
@@ -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 } }),
+2 -2
View File
@@ -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
View File
@@ -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)) {
+7 -4
View File
@@ -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"),
+13 -6
View File
@@ -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"]');
+49 -74
View File
@@ -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);
}
};
}
+83 -1
View File
@@ -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);
+176 -18
View File
@@ -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 };
+2 -2
View File
@@ -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) {
-15
View File
@@ -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;
+28 -4
View File
@@ -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,
});
}
+209
View File
@@ -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,
);
}
}
+18
View File
@@ -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.
*
+169
View File
@@ -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 });
});
}