Files
DeltaGreen/module/applications/add-profession-dialog.js
T
Aaron Lutomski 849c4de44d Added profession item type and partially automated chargen.
Overhauled modified roll dialog.

Added stylized chat card.
2026-06-21 13:44:54 -07:00

550 lines
16 KiB
JavaScript

import { BASE_TEMPLATE_PATH } from "../config.js";
import {
BONUS_SKILL_COUNT,
buildSkillCatalog,
buildSortedFixedSkillRows,
buildSortedTypedSkillRows,
computeSkillValues,
formatProfessionValidationMessages,
getTypedGroupDisplayName,
parseProfessionSkillKey,
prepareProfessionSkillRows,
splitProfessionSkillMap,
} from "../utils/profession-skills.js";
import { reorderForColumnSorting } from "../utils/skill-layout.js";
const { DialogV2 } = foundry.applications.api;
const ADD_PROFESSION_SKILL_COLUMNS = 4;
const { renderTemplate } = foundry.applications.handlebars;
/**
* @param {Item} professionItem
* @param {Actor} actor
* @returns {Promise<boolean>}
*/
export async function showPickSkillsDialog(professionItem, actor) {
// eslint-disable-next-line no-use-before-define -- class defined below in this module
const controller = new AddProfessionDialogController(professionItem, actor);
return controller.run();
}
/**
* @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean }[]} rows
* @returns {{ rowId: string, label: string, value: number, isModified: boolean }[]}
*/
function orderSkillRowsForDisplay(rows) {
const sorted = [...rows].sort((a, b) =>
a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang),
);
const ordered = game.settings.get("deltagreen", "sortSkills")
? reorderForColumnSorting(sorted, ADD_PROFESSION_SKILL_COLUMNS)
: sorted;
return ordered.map(({ rowId, label, value, isModified }) => ({
rowId,
label,
value,
isModified: Boolean(isModified),
}));
}
class AddProfessionDialogController {
/**
* @param {Item} professionItem
* @param {Actor} actor
*/
constructor(professionItem, actor) {
this.professionItem = professionItem;
this.actor = actor;
const { optionPicks, skills: optionSkillMap } = splitProfessionSkillMap(
professionItem.system.optionSkills ?? {},
);
this.automaticSkills = { ...(professionItem.system.automaticSkills ?? {}) };
this.automaticSkillMeta = {
...(professionItem.system.automaticSkillMeta ?? {}),
};
this.optionSkills = { ...optionSkillMap };
this.optionSkillMeta = { ...(professionItem.system.optionSkillMeta ?? {}) };
this.optionPicks = optionPicks;
/** @type {Set<string>} */
this.checkedOptionKeys = new Set();
/** @type {Record<string, string>} */
this.chooseOneLabels = {};
/** @type {string[]} */
this.bonusCatalogIds = Array(BONUS_SKILL_COUNT).fill("");
/** @type {string[]} */
this.bonusTypedLabels = Array(BONUS_SKILL_COUNT).fill("");
/** @type {string[]} */
this.bondNames = [];
/** @type {string[]} */
this.bondRelationships = [];
/** @type {DialogV2 | null} */
this.dialog = null;
this.submitted = false;
}
/**
* @returns {Promise<boolean>}
*/
async run() {
const content = await this.#buildTemplate();
this.submitted = false;
return DialogV2.wait({
content,
window: {
title: game.i18n.localize("DG.Profession.Dialog.Title"),
},
position: { width: 960 },
classes: ["add-profession-dialog-app"],
form: { closeOnSubmit: false },
render: async (_event, dialog) => {
this.dialog = dialog;
this.#bindListeners();
await this.#refreshUi();
},
close: () => this.submitted,
buttons: [
{
action: "cancel",
label: game.i18n.localize("Cancel"),
callback: async (_event, _button, dialog) => {
await dialog.close();
return false;
},
},
{
action: "submit",
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
default: true,
disabled: true,
callback: async () => {
await this.#onSubmit();
return false;
},
},
],
});
}
#getFormState() {
this.#syncFormStateFromDom();
return {
checkedOptionKeys: this.checkedOptionKeys,
chooseOneLabels: { ...this.chooseOneLabels },
bonusCatalogIds: [...this.bonusCatalogIds],
bonusTypedLabels: [...this.bonusTypedLabels],
bondNames: [...this.bondNames],
bondRelationships: [...this.bondRelationships],
};
}
#bondCount() {
return Number(this.professionItem.system.bonds) || 0;
}
#computeContext() {
return {
automaticMeta: this.automaticSkillMeta,
optionMeta: this.optionSkillMeta,
bondCount: this.#bondCount(),
};
}
#compute() {
return computeSkillValues(
this.automaticSkills,
this.optionSkills,
this.optionPicks,
this.#getFormState(),
this.#computeContext(),
);
}
#buildAutomaticChooseOneRows() {
return Object.entries(this.automaticSkills)
.map(([key]) => {
if (!this.automaticSkillMeta[key]?.chooseOne) return null;
const ref = parseProfessionSkillKey(key);
if (!ref || ref.kind !== "typed") return null;
return {
key,
groupLabel: getTypedGroupDisplayName(ref.group),
};
})
.filter(Boolean);
}
/**
* @param {ReturnType<computeSkillValues>} computed
* @returns {{ rowId: string, label: string, value: number }[]}
*/
#buildSkillDisplayRows(computed) {
const modifiedFixed = new Set(computed.modifiedFixedKeys ?? []);
const modifiedTyped = new Set(computed.modifiedTypedKeys ?? []);
const fixedRows = buildSortedFixedSkillRows(computed.fixedValues);
const typedRows = buildSortedTypedSkillRows(computed.typedValues);
return orderSkillRowsForDisplay([
...fixedRows.map((r) => ({
rowId: r.key,
label: r.label,
value: r.value,
sortLabel: r.sortLabel,
isModified: modifiedFixed.has(r.key),
})),
...typedRows.map((r) => ({
rowId: r.storageKey,
label: r.label,
value: r.value,
sortLabel: r.sortLabel,
isModified: modifiedTyped.has(r.storageKey),
})),
]);
}
async #buildTemplate() {
const computed = this.#compute();
const chaScore =
this.actor.system.statistics.cha.effectiveValue ??
this.actor.system.statistics.cha.value;
const skillRows = this.#buildSkillDisplayRows(computed);
const optionRows = prepareProfessionSkillRows(
this.optionSkills,
this.optionSkillMeta,
).map((row) => ({
...row,
isChooseOne: Boolean(this.optionSkillMeta[row.key]?.chooseOne),
}));
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 bondRows = Array.from({ length: bondCount }, (_, index) => ({
index,
number: index + 1,
chaScore,
}));
const bonusSlots = Array.from(
{ length: BONUS_SKILL_COUNT },
(_, index) => ({
index,
}),
);
return renderTemplate(`${BASE_TEMPLATE_PATH}/dialog/add-profession.html`, {
skillRows,
showOptionSkills: this.optionPicks > 0,
optionPicks: this.optionPicks,
optionRows,
automaticChooseOneRows,
catalog: buildSkillCatalog(),
bonusSlots,
validationMessages: formatProfessionValidationMessages(
computed.validationErrors,
{ optionPicks: this.optionPicks },
),
capWarnings,
bondCount,
bondRows,
});
}
#contentRoot() {
return this.dialog?.element?.querySelector(".dialog-content");
}
#bindListeners() {
const root = this.#contentRoot();
if (!root) return;
root.querySelectorAll('[name="optionSkillChecked"]').forEach((el) => {
el.addEventListener("change", () => this.#onOptionCheckChange(el));
});
root.querySelectorAll("[data-choose-one-key]").forEach((el) => {
el.addEventListener("input", () => this.#refreshUi());
});
root.querySelectorAll("select[data-bonus-index]").forEach((el) => {
el.addEventListener("change", () => this.#onBonusChange(el));
});
root.querySelectorAll("[data-bonus-typed-index]").forEach((el) => {
el.addEventListener("input", () => this.#refreshUi());
});
for (let i = 0; i < this.#bondCount(); i++) {
root
.querySelector(`[name="bondName-${i}"]`)
?.addEventListener("input", () => this.#refreshUi());
root
.querySelector(`[name="bondRelationship-${i}"]`)
?.addEventListener("input", () => this.#refreshUi());
}
}
/**
* @param {HTMLInputElement} checkbox
*/
#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) {
checkbox.checked = false;
return;
}
this.checkedOptionKeys.add(key);
if (isChooseOne && root) {
const nameInput = root.querySelector(
`[data-choose-one-key="${CSS.escape(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 (nameInput) {
nameInput.disabled = true;
nameInput.value = "";
}
delete this.chooseOneLabels[key];
}
}
this.#refreshUi();
}
/**
* @param {HTMLSelectElement} select
*/
#onBonusChange(select) {
const index = Number(select.dataset.bonusIndex);
this.bonusCatalogIds[index] = select.value;
const root = this.#contentRoot();
const typedInput = root?.querySelector(`[name="bonusTypedLabel-${index}"]`);
const isTyped = select.value.startsWith("typed:");
if (typedInput) {
typedInput.classList.toggle("hidden", !isTyped);
if (!isTyped) {
typedInput.value = "";
this.bonusTypedLabels[index] = "";
}
}
this.#syncFormStateFromDom();
this.#refreshUi();
}
#syncFormStateFromDom() {
const root = this.#contentRoot();
if (!root) return;
root.querySelectorAll("[data-choose-one-key]").forEach((input) => {
const key = input.dataset.chooseOneKey;
if (key) this.chooseOneLabels[key] = input.value;
});
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
const select = root.querySelector(`[name="bonusSkill-${i}"]`);
this.bonusCatalogIds[i] = select?.value ?? "";
const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`);
this.bonusTypedLabels[i] = typedInput?.value ?? "";
}
const bondCount = this.#bondCount();
this.bondNames.length = bondCount;
this.bondRelationships.length = bondCount;
for (let i = 0; i < bondCount; i++) {
this.bondNames[i] =
root.querySelector(`[name="bondName-${i}"]`)?.value ?? "";
this.bondRelationships[i] =
root.querySelector(`[name="bondRelationship-${i}"]`)?.value ?? "";
}
}
#restoreFormSelections() {
const root = this.#contentRoot();
if (!root) return;
for (const key of this.checkedOptionKeys) {
const cb = root.querySelector(
`[name="optionSkillChecked"][value="${CSS.escape(key)}"]`,
);
if (cb) cb.checked = true;
if (!this.optionSkillMeta[key]?.chooseOne) continue;
const nameInput = root.querySelector(
`[data-choose-one-key="${CSS.escape(key)}"]`,
);
if (nameInput) {
nameInput.disabled = false;
nameInput.value = this.chooseOneLabels[key] ?? "";
}
}
for (const row of this.#buildAutomaticChooseOneRows()) {
const nameInput = root.querySelector(
`[data-choose-one-key="${CSS.escape(row.key)}"]`,
);
if (nameInput) nameInput.value = this.chooseOneLabels[row.key] ?? "";
}
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
const select = root.querySelector(`[name="bonusSkill-${i}"]`);
if (select) select.value = this.bonusCatalogIds[i] ?? "";
const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`);
const isTyped = (this.bonusCatalogIds[i] ?? "").startsWith("typed:");
if (typedInput) {
typedInput.classList.toggle("hidden", !isTyped);
typedInput.value = this.bonusTypedLabels[i] ?? "";
}
}
}
/**
* @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 capHtml = computed.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>`,
)
.join("");
return validationHtml + capHtml;
}
async #refreshUi() {
const root = this.#contentRoot();
if (!root) return;
const computed = this.#compute();
const rows = this.#buildSkillDisplayRows(computed);
const body = root.querySelector("[data-profession-skills-body]");
if (body) {
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(
r.rowId,
)}"><span class="add-profession-skill-label">${foundry.utils.escapeHTML(
r.label,
)}</span><span class="add-profession-skill-value" data-skill-value>${
r.value
}%</span></div>`;
})
.join("");
}
const messagesEl = root.querySelector("[data-profession-messages]");
if (messagesEl) {
messagesEl.innerHTML = this.#buildMessagesHtml(computed);
}
const submitBtn = this.dialog?.element?.querySelector(
'button[data-action="submit"]',
);
if (submitBtn) submitBtn.disabled = !computed.isValid;
this.#restoreFormSelections();
}
async #onSubmit() {
this.#syncFormStateFromDom();
const computed = this.#compute();
if (!computed.isValid) return;
const updateData = {};
for (const [key, value] of Object.entries(computed.fixedValues)) {
updateData[`system.skills.${key}.proficiency`] = value;
updateData[`system.skills.${key}.failure`] = false;
}
const typedSkills = { ...(this.actor.system.typedSkills ?? {}) };
for (const [, data] of Object.entries(computed.typedValues)) {
const id = foundry.utils.randomID();
typedSkills[id] = {
label: data.label,
group: data.group,
proficiency: data.value,
failure: false,
};
}
updateData["system.typedSkills"] = typedSkills;
updateData["system.biography.profession"] = this.professionItem.name;
await this.actor.update(updateData);
const chaScore =
this.actor.system.statistics.cha.effectiveValue ??
this.actor.system.statistics.cha.value;
const bondCount = this.#bondCount();
const bondDocs = [];
for (let i = 0; i < bondCount; i++) {
const name = this.bondNames[i]?.trim() ?? "";
const relationship = this.bondRelationships[i]?.trim() ?? "";
bondDocs.push({
name,
type: "bond",
system: {
score: chaScore,
relationship,
},
});
}
if (bondDocs.length) {
await this.actor.createEmbeddedDocuments("Item", bondDocs);
}
this.submitted = true;
await this.dialog?.close();
}
}
export default { showPickSkillsDialog };