Added damaged veteran dialog

This commit is contained in:
Aaron Lutomski
2026-06-13 11:37:51 -07:00
parent b662e86587
commit 3c2d009493
34 changed files with 1946 additions and 383 deletions
+13 -4
View File
@@ -9,21 +9,30 @@ Entry point: [`deltagreen.js`](deltagreen.js) (registered in `system.json`).
| [`config/`](config/) | System constants (`DG`, skill list, template paths) |
| [`data/`](data/) | TypeDataModels (`actor/`, `item/`, `derived/`) |
| [`active-effect/`](active-effect/) | Active effect metadata, data model, document class, runtime sync |
| [`profession/`](profession/) | Profession item skill catalog, validation, and character-creation math |
| [`profession/`](profession/) | Profession catalog, validation, character-creation math, deferred commit ([`commit-character-creation.js`](profession/commit-character-creation.js)), Damaged Veteran modifiers ([`damaged-veteran-modifiers.js`](profession/damaged-veteran-modifiers.js)), disorder IDs ([`disorders.js`](profession/disorders.js)) |
| [`roll/`](roll/) | Dice roll subclasses, roll pipeline, and roll UX. [`roll/roll.js`](roll/roll.js) is the public API. Internal Roll subclasses live in [`roll/classes/`](roll/classes/). |
| [`sheets/`](sheets/) | ApplicationV2 sheets and mixins |
| [`applications/`](applications/) | Standalone dialogs (profession setup, stats, active effect config) |
| [`applications/`](applications/) | Standalone dialogs, chargen orchestrator ([`profession-setup-flow.js`](applications/profession-setup-flow.js)), shared dialog UI ([`character-creation-ui.js`](applications/character-creation-ui.js)), and dialog factory ([`dg-dialog.js`](applications/dg-dialog.js)) |
| [`actor/`](actor/), [`item/`](item/) | Document subclasses |
| [`chat/`](chat/) | Chat cards and inline actions |
| [`chat/`](chat/) | Chat cards ([`dg-chat-card.js`](chat/dg-chat-card.js)) and inline actions |
| [`integrations/`](integrations/) | Optional module hooks (Dice So Nice) |
| [`utils/`](utils/) | Generic helpers only (Handlebars, migration, skill layout/tooltips) |
Root-level modules: [`settings.js`](settings.js), [`templates.js`](templates.js).
## Import conventions
- **Roll types and pipeline:** import `DGRoll`, `DGPercentileRoll`, `createDGRollFromDataset`, `processDGRoll`, etc. from [`roll/roll.js`](roll/roll.js) only — not from [`roll/classes/`](roll/classes/).
- **Roll modifier dialogs:** import from [`roll/roll-dialogs.js`](roll/roll-dialogs.js) only when calling dialogs directly (today only used from roll classes).
- **Skill improvement rolls:** import from [`roll/skill-improvement-roll.js`](roll/skill-improvement-roll.js) (used by agent sheet).
- **Profession logic:** import from [`profession/index.js`](profession/index.js).
- **Profession logic:** import from [`profession/index.js`](profession/index.js) (includes `commitProfessionSetup`, `getDisorderLabel`, etc.).
- **Profession setup flow:** import `assignProfessionToAgent` from [`applications/profession-setup-flow.js`](applications/profession-setup-flow.js).
- **Dialog factory:** feature dialogs use [`showDgDialog`](applications/dg-dialog.js) — not raw `DialogV2`.
- **Chat cards:** rolls via [`createDGRollChatMessage`](chat/dg-chat-card.js); non-roll cards via [`createDGChatMessage`](chat/dg-chat-card.js).
- **Active effect runtime:** import exhaustion/stimulant sync from [`active-effect/runtime/`](active-effect/runtime/).
- **Derived actor math:** import from [`data/derived/actor-derived.js`](data/derived/actor-derived.js).
- **Agent skill defaults:** single source in [`data/actor/base/agent-skill-defaults.js`](data/actor/base/agent-skill-defaults.js) (used by human actor schema and profession code).
## Profession setup flow
[`profession-setup-flow.js`](applications/profession-setup-flow.js) orchestrates: Pick Statistics → Roll/Assign Stats → Character Creation → Damaged Veterans → (optional follow-ups) → `commitProfessionSetup`. Dialogs return `{ outcome: 'submitted' | 'back', ... }` or `null` (window close aborts). Character Creation alone uses **Cancel** to abort the entire assignment.
+88 -181
View File
@@ -4,11 +4,15 @@ import {
getDialogContentRoot,
showDgDialog,
} from "./dg-dialog.js";
import {
buildSkillDisplayRows,
renderValidationMessagesHtml,
updateSkillGridBody,
} from "./character-creation-ui.js";
import {
BONUS_SKILL_COUNT,
buildBonusSkillCatalog,
buildSortedFixedSkillRows,
buildSortedTypedSkillRows,
buildCharacterCreationPayload,
computeSkillValues,
formatProfessionValidationMessages,
getTypedGroupDisplayName,
@@ -16,58 +20,49 @@ import {
prepareProfessionSkillRows,
splitProfessionSkillMap,
} from "../profession/index.js";
import {
reorderForColumnSorting,
splitIntoColumns,
} from "../utils/skill-layout.js";
import {
applySkillTooltipDisplayMode,
buildSkillTooltip,
} from "../utils/skill-tooltip.js";
import { splitIntoColumns } from "../utils/skill-layout.js";
import { applySkillTooltipDisplayMode } from "../utils/skill-tooltip.js";
const CHARACTER_CREATION_OPTION_COLUMNS = 2;
const ADD_PROFESSION_SKILL_COLUMNS = 2;
const { renderTemplate } = foundry.applications.handlebars;
/**
* @typedef {object} CharacterCreationDraft
* @property {string[]} checkedOptionKeys
* @property {Record<string, string>} chooseOneLabels
* @property {string[]} bonusCatalogIds
* @property {string[]} bonusTypedLabels
* @property {string[]} bondNames
* @property {string[]} bondRelationships
*/
/**
* @param {Item} professionItem
* @param {Actor} actor
* @returns {Promise<boolean>}
* @param {object} [options]
* @param {CharacterCreationDraft} [options.draft]
* @returns {Promise<{ outcome: 'submitted', payload: import("../profession/commit-character-creation.js").CharacterCreationPayload, draft: CharacterCreationDraft } | null>}
*/
export default async function showPickSkillsDialog(professionItem, actor) {
export default async function showPickSkillsDialog(
professionItem,
actor,
{ draft } = {},
) {
// eslint-disable-next-line no-use-before-define -- class defined below in this module
const controller = new AddProfessionDialogController(professionItem, actor);
const controller = new AddProfessionDialogController(professionItem, actor, {
draft,
});
return controller.run();
}
/**
* @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) =>
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, tooltip }) => ({
rowId,
label,
value,
isModified: Boolean(isModified),
tooltip: tooltip ?? "",
}));
}
class AddProfessionDialogController {
/**
* @param {Item} professionItem
* @param {Actor} actor
* @param {object} [options]
* @param {CharacterCreationDraft} [options.draft]
*/
constructor(professionItem, actor) {
constructor(professionItem, actor, { draft } = {}) {
this.professionItem = professionItem;
this.actor = actor;
@@ -83,29 +78,33 @@ class AddProfessionDialogController {
this.optionPicks = optionPicks;
/** @type {Set<string>} */
this.checkedOptionKeys = new Set();
this.checkedOptionKeys = new Set(draft?.checkedOptionKeys ?? []);
/** @type {Record<string, string>} */
this.chooseOneLabels = {};
this.chooseOneLabels = { ...(draft?.chooseOneLabels ?? {}) };
/** @type {string[]} */
this.bonusCatalogIds = Array(BONUS_SKILL_COUNT).fill("");
this.bonusCatalogIds = draft?.bonusCatalogIds
? [...draft.bonusCatalogIds]
: Array(BONUS_SKILL_COUNT).fill("");
/** @type {string[]} */
this.bonusTypedLabels = Array(BONUS_SKILL_COUNT).fill("");
this.bonusTypedLabels = draft?.bonusTypedLabels
? [...draft.bonusTypedLabels]
: Array(BONUS_SKILL_COUNT).fill("");
/** @type {string[]} */
this.bondNames = [];
this.bondNames = draft?.bondNames ? [...draft.bondNames] : [];
/** @type {string[]} */
this.bondRelationships = [];
this.bondRelationships = draft?.bondRelationships
? [...draft.bondRelationships]
: [];
/** @type {import("foundry.applications.api.DialogV2").default | null} */
this.dialog = null;
this.submitted = false;
/** @type {{ outcome: 'submitted', payload: import("../profession/commit-character-creation.js").CharacterCreationPayload, draft: CharacterCreationDraft } | null} */
this.result = null;
}
/**
* @returns {Promise<boolean>}
*/
async run() {
const content = await this.#buildTemplate();
this.submitted = false;
this.result = null;
return showDgDialog({
modifier: "character-creation",
@@ -123,14 +122,16 @@ class AddProfessionDialogController {
applySkillTooltipDisplayMode(root);
}
this.#bindListeners();
this.#restoreFormSelections();
await this.#refreshUi();
},
close: () => this.submitted,
close: () => this.result,
buttons: [
{
action: "cancel",
label: game.i18n.localize("Cancel"),
callback: async (_event, _button, dialog) => {
this.result = null;
await dialog.close();
return false;
},
@@ -161,6 +162,19 @@ class AddProfessionDialogController {
};
}
/** @returns {CharacterCreationDraft} */
#getDraft() {
const formState = this.#getFormState();
return {
checkedOptionKeys: [...formState.checkedOptionKeys],
chooseOneLabels: formState.chooseOneLabels,
bonusCatalogIds: formState.bonusCatalogIds,
bonusTypedLabels: formState.bonusTypedLabels,
bondNames: formState.bondNames,
bondRelationships: formState.bondRelationships,
};
}
#bondCount() {
return Math.max(
1,
@@ -200,52 +214,13 @@ class AddProfessionDialogController {
.filter(Boolean);
}
/**
* @param {ReturnType<computeSkillValues>} computed
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string, sortLabel: string }[]}
*/
#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),
tooltip: buildSkillTooltip("dialog", null, {
kind: "fixed",
key: r.key,
}),
})),
...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,
}),
};
}),
]);
}
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 skillRows = buildSkillDisplayRows(computed);
const optionRows = prepareProfessionSkillRows(
this.optionSkills,
@@ -456,15 +431,17 @@ class AddProfessionDialogController {
typedInput.value = this.bonusTypedLabels[i] ?? "";
}
}
for (let i = 0; i < this.#bondCount(); i++) {
const nameInput = root.querySelector(`[name="bondName-${i}"]`);
if (nameInput) nameInput.value = this.bondNames[i] ?? "";
const relInput = root.querySelector(`[name="bondRelationship-${i}"]`);
if (relInput) relInput.value = this.bondRelationships[i] ?? "";
}
}
/**
* @param {ReturnType<computeSkillValues>} computed
* @returns {{
* validationMessages: string[],
* capWarnings: { warning: string }[],
* wasteMessages: string[],
* }}
*/
#getDisplayMessages(computed) {
const wastePrefix = "bonusWaste:";
@@ -500,16 +477,6 @@ class AddProfessionDialogController {
const { validationMessages, capWarnings, wasteMessages } =
this.#getDisplayMessages(computed);
const toErrorHtml = (messages) =>
messages
.map(
(message) =>
`<p class="dg-dialog__message--error">${foundry.utils.escapeHTML(
message,
)}</p>`,
)
.join("");
const capHtml = capWarnings
.map(
(w) =>
@@ -520,19 +487,12 @@ class AddProfessionDialogController {
.join("");
return (
toErrorHtml(validationMessages) + capHtml + toErrorHtml(wasteMessages)
renderValidationMessagesHtml(validationMessages) +
capHtml +
renderValidationMessagesHtml(wasteMessages)
);
}
/**
* @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;
@@ -540,27 +500,12 @@ class AddProfessionDialogController {
this.#syncFormStateFromDom();
const computed = this.#compute();
const rows = buildSkillDisplayRows(computed);
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="dg-dialog__skill-entry${modifiedClass}" data-skill-row="${foundry.utils.escapeHTML(
r.rowId,
)}"><span class="dg-dialog__skill-label"${this.#tooltipAttr(
r.tooltip,
)}>${foundry.utils.escapeHTML(
r.label,
)}</span><span class="dg-dialog__skill-value" data-skill-value>${
r.value
}%</span></div>`;
})
.join("");
applySkillTooltipDisplayMode(body);
}
updateSkillGridBody(
root.querySelector("[data-profession-skills-body]"),
rows,
);
const messagesEl = root.querySelector("[data-profession-messages]");
if (messagesEl) {
@@ -571,8 +516,6 @@ class AddProfessionDialogController {
'button[data-action="submit"]',
);
if (submitBtn) submitBtn.disabled = !computed.isValid;
this.#restoreFormSelections();
}
async #onSubmit() {
@@ -580,51 +523,15 @@ class AddProfessionDialogController {
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,
},
});
}
await this.actor.createEmbeddedDocuments("Item", bondDocs);
this.submitted = true;
const draft = this.#getDraft();
this.result = {
outcome: "submitted",
payload: buildCharacterCreationPayload(computed, this.professionItem, {
bondNames: this.bondNames,
bondRelationships: this.bondRelationships,
}),
draft,
};
await this.dialog?.close();
}
}
+8 -8
View File
@@ -12,7 +12,7 @@ import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
const { renderTemplate } = foundry.applications.handlebars;
/** @typedef {'submitted' | 'cancelled'} AssignStatsResult */
/** @typedef {'submitted' | 'back'} AssignStatsOutcome */
/**
* @param {DialogV2} dialog
@@ -84,14 +84,14 @@ function bindAssignStatsListeners(dialog, values) {
/**
* @param {Actor} actor
* @returns {Promise<AssignStatsResult>}
* @returns {Promise<{ outcome: 'submitted' } | { outcome: 'back' } | null>}
*/
export default async function showAssignStatsDialog(actor) {
/** @type {Record<string, number>} */
const values = getDefaultPointBuyValues();
const { remaining } = validatePointBuyValues(values);
/** @type {AssignStatsResult} */
let result = "cancelled";
/** @type {{ outcome: 'submitted' } | { outcome: 'back' } | null} */
let result = null;
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/assign-stats.html`,
@@ -119,10 +119,10 @@ export default async function showAssignStatsDialog(actor) {
close: () => result,
buttons: [
{
action: "cancel",
label: game.i18n.localize("Cancel"),
action: "back",
label: game.i18n.localize("DG.ProfessionSetup.GoBack"),
callback: async (_event, _button, dialog) => {
result = "cancelled";
result = { outcome: "back" };
await dialog.close();
},
},
@@ -137,7 +137,7 @@ export default async function showAssignStatsDialog(actor) {
if (!isValid) return false;
await applyAgentStatistics(actor, { ...values });
result = "submitted";
result = { outcome: "submitted" };
await dialog.close();
return false;
},
@@ -0,0 +1,170 @@
import {
buildSortedFixedSkillRows,
buildSortedTypedSkillRows,
getBonusTrackLabel,
} from "../profession/index.js";
import { reorderForColumnSorting } from "../utils/skill-layout.js";
import {
applySkillTooltipDisplayMode,
buildSkillTooltip,
} from "../utils/skill-tooltip.js";
export const CHARACTER_CREATION_SKILL_COLUMNS = 2;
/**
* @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean, tooltip?: string }[]} rows
* @param {number} [columns]
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]}
*/
export function orderSkillRowsForDisplay(
rows,
columns = CHARACTER_CREATION_SKILL_COLUMNS,
) {
const sorted = [...rows].sort((a, b) =>
a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang),
);
const ordered = game.settings.get("deltagreen", "sortSkills")
? reorderForColumnSorting(sorted, columns)
: sorted;
return ordered.map(({ rowId, label, value, isModified, tooltip }) => ({
rowId,
label,
value,
isModified: Boolean(isModified),
tooltip: tooltip ?? "",
}));
}
/**
* @param {object} computed
* @param {Record<string, { group: string, label: string, value: number }>} [typedValuesSource]
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string, sortLabel: string }[]}
*/
export function buildSkillDisplayRows(computed, typedValuesSource) {
const typedValues = typedValuesSource ?? computed.typedValues ?? {};
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),
tooltip: buildSkillTooltip("dialog", null, {
kind: "fixed",
key: r.key,
}),
})),
...typedRows.map((r) => {
const data = 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,
}),
};
}),
]);
}
/**
* @param {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]} rows
* @returns {string}
*/
export function renderSkillGridHtml(rows) {
return rows
.map((r) => {
const modifiedClass = r.isModified ? " is-modified" : "";
const tooltipAttr = r.tooltip
? ` data-tooltip="${foundry.utils.escapeHTML(r.tooltip)}"`
: "";
return `<div class="dg-dialog__skill-entry${modifiedClass}" data-skill-row="${foundry.utils.escapeHTML(
r.rowId,
)}"><span class="dg-dialog__skill-label"${tooltipAttr}>${foundry.utils.escapeHTML(
r.label,
)}</span><span class="dg-dialog__skill-value" data-skill-value>${
r.value
}%</span></div>`;
})
.join("");
}
/**
* @param {HTMLElement} body
* @param {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]} rows
*/
export function updateSkillGridBody(body, rows) {
if (!body) return;
body.innerHTML = renderSkillGridHtml(rows);
applySkillTooltipDisplayMode(body);
}
/**
* @param {string[]} validationErrors
* @param {object} [options]
* @param {string} [options.bonusSkillRequiredKey]
* @param {string} [options.bonusSkillWasteTooHighKey]
* @returns {string[]}
*/
export function formatBonusPickValidationMessages(
validationErrors,
{
bonusSkillRequiredKey = "DG.Profession.Dialog.BonusSkillRequired",
bonusSkillWasteTooHighKey = "DG.Profession.Dialog.BonusSkillWasteTooHigh",
} = {},
) {
const messages = [];
if (validationErrors.some((code) => /^bonus\d+$/.test(code))) {
messages.push(game.i18n.localize(bonusSkillRequiredKey));
}
if (validationErrors.some((code) => code.startsWith("bonusType"))) {
messages.push(
game.i18n.localize("DG.Profession.Dialog.BonusSkillTypeRequired"),
);
}
/** @type {Set<string>} */
const bonusWasteTrackKeys = new Set();
for (const code of validationErrors) {
const match = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/);
if (match) bonusWasteTrackKeys.add(match[2]);
}
for (const trackKey of bonusWasteTrackKeys) {
messages.push(
game.i18n.format(bonusSkillWasteTooHighKey, {
skill: getBonusTrackLabel(trackKey),
}),
);
}
return messages;
}
/**
* @param {string[]} messages
* @returns {string}
*/
export function renderValidationMessagesHtml(messages) {
return messages
.map(
(message) =>
`<p class="dg-dialog__message--error">${foundry.utils.escapeHTML(
message,
)}</p>`,
)
.join("");
}
@@ -0,0 +1,120 @@
import { BASE_TEMPLATE_PATH } from "../config/index.js";
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
const { renderTemplate } = foundry.applications.handlebars;
/** @typedef {'freshRecruit' | 'extremeViolence' | 'captivity' | 'hardExperience' | 'thingsMan'} DamagedVeteranPath */
const VETERAN_OPTIONS = /** @type {const} */ ([
{
value: "freshRecruit",
labelKey: "DG.DamagedVeterans.Dialog.FreshRecruit.Label",
bodyKey: "DG.DamagedVeterans.Dialog.FreshRecruit.Body",
},
{
value: "extremeViolence",
labelKey: "DG.DamagedVeterans.Dialog.ExtremeViolence.Label",
bodyKey: "DG.DamagedVeterans.Dialog.ExtremeViolence.Body",
},
{
value: "captivity",
labelKey: "DG.DamagedVeterans.Dialog.Captivity.Label",
bodyKey: "DG.DamagedVeterans.Dialog.Captivity.Body",
},
{
value: "hardExperience",
labelKey: "DG.DamagedVeterans.Dialog.HardExperience.Label",
bodyKey: "DG.DamagedVeterans.Dialog.HardExperience.Body",
},
{
value: "thingsMan",
labelKey: "DG.DamagedVeterans.Dialog.ThingsMan.Label",
bodyKey: "DG.DamagedVeterans.Dialog.ThingsMan.Body",
},
]);
/**
* @param {import("../profession/commit-character-creation.js").CharacterCreationPayload} payload
* @param {object} [options]
* @param {DamagedVeteranPath} [options.selectedPath]
* @returns {Promise<{ outcome: 'submitted', path: DamagedVeteranPath } | { outcome: 'back' } | null>}
*/
export default async function showDamagedVeteransDialog(
payload,
{ selectedPath } = {},
) {
const defaultPath = selectedPath ?? "freshRecruit";
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/damaged-veterans.html`,
{
options: VETERAN_OPTIONS.map((option) => ({
...option,
checked: option.value === defaultPath,
})),
},
);
/** @type {{ outcome: 'submitted', path: DamagedVeteranPath } | { outcome: 'back' } | null} */
let result = null;
await showDgDialog({
modifier: "damaged-veterans",
content,
window: {
title: game.i18n.localize("DG.DamagedVeterans.Dialog.Title"),
},
position: { width: 720, height: "auto" },
form: { closeOnSubmit: false },
close: () => result,
onRender: (dialog) => {
const root = getDialogContentRoot(dialog);
if (!root) return;
const submitBtn = dialog.element?.querySelector(
'button[data-action="submit"]',
);
const updateSubmitState = () => {
const selected = root.querySelector(
'input[name="damagedVeteranPath"]:checked',
);
if (submitBtn) submitBtn.disabled = !selected;
};
root
.querySelectorAll('input[name="damagedVeteranPath"]')
.forEach((input) => {
input.addEventListener("change", updateSubmitState);
});
updateSubmitState();
},
buttons: [
{
action: "back",
label: game.i18n.localize("DG.ProfessionSetup.GoBack"),
callback: async (_event, _button, dialog) => {
result = { outcome: "back" };
await dialog.close();
return false;
},
},
{
action: "submit",
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
default: true,
disabled: false,
callback: async (_event, _button, dialog) => {
const root = getDialogContentRoot(dialog);
const selected = root?.querySelector(
'input[name="damagedVeteranPath"]:checked',
);
if (!selected) return false;
const path = /** @type {DamagedVeteranPath} */ (selected.value);
result = { outcome: "submitted", path };
await dialog.close();
return false;
},
},
],
});
return result;
}
@@ -0,0 +1,241 @@
import { BASE_TEMPLATE_PATH } from "../config/index.js";
import {
HARD_EXPERIENCE_BONUS_COUNT,
buildBonusSkillCatalog,
computeHardExperienceSkillValues,
} from "../profession/index.js";
import {
buildSkillDisplayRows,
formatBonusPickValidationMessages,
renderValidationMessagesHtml,
updateSkillGridBody,
} from "./character-creation-ui.js";
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
const { renderTemplate } = foundry.applications.handlebars;
/**
* @typedef {object} HardExperienceDialogState
* @property {string[]} [bonusCatalogIds]
* @property {string[]} [bonusTypedLabels]
* @property {number|null} [removedBondIndex]
*/
/**
* @param {import("../profession/commit-character-creation.js").CharacterCreationPayload} payload
* @param {HardExperienceDialogState} [state]
* @returns {Promise<{ outcome: 'submitted', path: 'hardExperience', bonusCatalogIds: string[], bonusTypedLabels: string[], removedBondIndex: number } | { outcome: 'back' } | null>}
*/
export default async function showHardExperienceDialog(payload, state = {}) {
const bonusCatalogIds = state.bonusCatalogIds
? [...state.bonusCatalogIds]
: Array(HARD_EXPERIENCE_BONUS_COUNT).fill("");
const bonusTypedLabels = state.bonusTypedLabels
? [...state.bonusTypedLabels]
: Array(HARD_EXPERIENCE_BONUS_COUNT).fill("");
let removedBondIndex =
state.removedBondIndex ?? (payload.bonds.length > 0 ? 0 : null);
/** @type {{ outcome: 'submitted', path: 'hardExperience', bonusCatalogIds: string[], bonusTypedLabels: string[], removedBondIndex: number } | { outcome: 'back' } | null} */
let result = null;
/** @type {import("foundry.applications.api.DialogV2").default | null} */
let dialog = null;
const bondRows = payload.bonds.map((bond, index) => ({
index,
name: bond.name,
relationship: bond.relationship,
checked: index === removedBondIndex,
}));
const bonusSlots = Array.from(
{ length: HARD_EXPERIENCE_BONUS_COUNT },
(_, index) => ({ index }),
);
const syncFormStateFromDom = () => {
const root = dialog ? getDialogContentRoot(dialog) : null;
if (!root) return;
for (let i = 0; i < HARD_EXPERIENCE_BONUS_COUNT; i++) {
const select = root.querySelector(`[name="bonusSkill-${i}"]`);
bonusCatalogIds[i] = select?.value ?? "";
const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`);
bonusTypedLabels[i] = typedInput?.value ?? "";
}
const bondSelected = root.querySelector(
'input[name="removedBondIndex"]:checked',
);
removedBondIndex = bondSelected ? Number(bondSelected.value) : null;
};
const compute = () =>
computeHardExperienceSkillValues(payload, {
bonusCatalogIds,
bonusTypedLabels,
});
const buildTemplateData = () => {
const computed = compute();
return {
skillRows: buildSkillDisplayRows(computed),
catalog: buildBonusSkillCatalog(),
bonusSlots,
bondRows,
validationMessages: formatBonusPickValidationMessages(
computed.validationErrors,
{
bonusSkillRequiredKey: "DG.HardExperience.Dialog.BonusSkillRequired",
bonusSkillWasteTooHighKey:
"DG.HardExperience.Dialog.BonusSkillWasteTooHigh",
},
),
isValid: computed.isValid && removedBondIndex !== null,
};
};
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/hard-experience.html`,
buildTemplateData(),
);
const refreshUi = async () => {
syncFormStateFromDom();
const root = dialog ? getDialogContentRoot(dialog) : null;
if (!root) return;
const templateData = buildTemplateData();
updateSkillGridBody(
root.querySelector("[data-hard-experience-skills-body]"),
templateData.skillRows,
);
const messagesEl = root.querySelector("[data-hard-experience-messages]");
if (messagesEl) {
let messagesHtml = renderValidationMessagesHtml(
templateData.validationMessages,
);
if (removedBondIndex === null) {
messagesHtml += renderValidationMessagesHtml([
game.i18n.localize("DG.HardExperience.Dialog.BondRemovalRequired"),
]);
}
messagesEl.innerHTML = messagesHtml;
}
const submitBtn = dialog?.element?.querySelector(
'button[data-action="submit"]',
);
if (submitBtn) {
submitBtn.disabled = !(templateData.isValid && removedBondIndex !== null);
}
for (let i = 0; i < HARD_EXPERIENCE_BONUS_COUNT; i++) {
const select = root.querySelector(`[name="bonusSkill-${i}"]`);
if (select) select.value = bonusCatalogIds[i] ?? "";
const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`);
const isTyped = (bonusCatalogIds[i] ?? "").startsWith("typed:");
if (typedInput) {
typedInput.classList.toggle("hidden", !isTyped);
typedInput.value = bonusTypedLabels[i] ?? "";
}
}
if (removedBondIndex !== null) {
const bondInput = root.querySelector(
`input[name="removedBondIndex"][value="${removedBondIndex}"]`,
);
if (bondInput) bondInput.checked = true;
}
};
const bindListeners = () => {
const root = dialog ? getDialogContentRoot(dialog) : null;
if (!root) return;
root.querySelectorAll("select[data-bonus-index]").forEach((select) => {
select.addEventListener("change", () => {
const index = Number(select.dataset.bonusIndex);
bonusCatalogIds[index] = select.value;
const typedInput = root.querySelector(
`[name="bonusTypedLabel-${index}"]`,
);
const isTyped = select.value.startsWith("typed:");
if (typedInput) {
typedInput.classList.toggle("hidden", !isTyped);
if (!isTyped) {
typedInput.value = "";
bonusTypedLabels[index] = "";
}
}
refreshUi();
});
});
root.querySelectorAll("[data-bonus-typed-index]").forEach((input) => {
input.addEventListener("input", () => refreshUi());
});
root.querySelectorAll('input[name="removedBondIndex"]').forEach((input) => {
input.addEventListener("change", () => refreshUi());
});
};
await showDgDialog({
modifier: "hard-experience",
content,
window: {
title: game.i18n.localize("DG.HardExperience.Dialog.Title"),
},
position: { width: 1080, height: 780 },
form: { closeOnSubmit: false },
close: () => result,
onRender: async (instance) => {
dialog = instance;
bindListeners();
await refreshUi();
},
buttons: [
{
action: "back",
label: game.i18n.localize("DG.ProfessionSetup.GoBack"),
callback: async (_event, _button, instance) => {
syncFormStateFromDom();
result = {
outcome: "back",
bonusCatalogIds: [...bonusCatalogIds],
bonusTypedLabels: [...bonusTypedLabels],
removedBondIndex,
};
await instance.close();
return false;
},
},
{
action: "submit",
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
default: true,
disabled: true,
callback: async () => {
syncFormStateFromDom();
const computed = compute();
if (!computed.isValid || removedBondIndex === null) return false;
result = {
outcome: "submitted",
path: "hardExperience",
bonusCatalogIds: [...bonusCatalogIds],
bonusTypedLabels: [...bonusTypedLabels],
removedBondIndex,
};
await dialog?.close();
return false;
},
},
],
});
return result;
}
+128 -23
View File
@@ -1,7 +1,11 @@
import showPickSkillsDialog from "./add-profession-dialog.js";
import showAssignStatsDialog from "./assign-stats-dialog.js";
import showDamagedVeteransDialog from "./damaged-veterans-dialog.js";
import showHardExperienceDialog from "./hard-experience-dialog.js";
import showPickStatisticsDialog from "./pick-statistics-dialog.js";
import showRollStatsDialog from "./roll-stats-dialog.js";
import showThingsDisorderDialog from "./things-disorder-dialog.js";
import { commitProfessionSetup } from "../profession/index.js";
/**
* @param {Actor} actor
@@ -16,37 +20,141 @@ function createProfessionStub(actor, itemData) {
}
/**
* Run the full profession setup flow: Pick Statistics → optional stat setup → Character Creation.
* @param {Actor} actor
* @param {object} [options]
* @param {TokenDocument|null} [options.token]
* @returns {Promise<boolean>}
*/
async function runStatsPhase(actor, { token = null } = {}) {
const choice = await showPickStatisticsDialog();
if (choice === null) return false;
if (choice === "skip") return true;
if (choice === "roll") {
const result = await showRollStatsDialog(actor, { token });
if (result === null) return false;
if (result.outcome === "back") return runStatsPhase(actor, { token });
return true;
}
if (choice === "assign") {
const result = await showAssignStatsDialog(actor);
if (result === null) return false;
if (result.outcome === "back") return runStatsPhase(actor, { token });
return true;
}
return false;
}
/**
* @param {import("../profession/commit-character-creation.js").CharacterCreationPayload} payload
* @param {object} [options]
* @param {string} [options.selectedPath]
* @param {import("./hard-experience-dialog.js").HardExperienceDialogState} [options.heState]
* @param {import("./things-disorder-dialog.js").ThingsDisorderDialogState} [options.tdState]
* @returns {Promise<object|null>}
*/
async function resolveVeteranResult(
payload,
{ selectedPath, heState, tdState } = {},
) {
const dvResult = await showDamagedVeteransDialog(payload, { selectedPath });
if (dvResult === null) return null;
if (dvResult.outcome === "back") return { outcome: "back" };
const { path } = dvResult;
if (path === "hardExperience") {
const heResult = await showHardExperienceDialog(payload, heState);
if (heResult === null) return null;
if (heResult.outcome === "back") {
return resolveVeteranResult(payload, {
selectedPath: "hardExperience",
heState: {
bonusCatalogIds: heResult.bonusCatalogIds,
bonusTypedLabels: heResult.bonusTypedLabels,
removedBondIndex: heResult.removedBondIndex,
},
});
}
return {
path: "hardExperience",
bonusCatalogIds: heResult.bonusCatalogIds,
bonusTypedLabels: heResult.bonusTypedLabels,
removedBondIndex: heResult.removedBondIndex,
};
}
if (path === "thingsMan") {
const tdResult = await showThingsDisorderDialog(tdState);
if (tdResult === null) return null;
if (tdResult.outcome === "back") {
return resolveVeteranResult(payload, {
selectedPath: "thingsMan",
tdState: { disorder: tdResult.disorder },
});
}
return {
path: "thingsMan",
disorder: tdResult.disorder,
};
}
return { path };
}
/**
* @param {Item} professionItem
* @param {Actor} actor
* @param {import("./add-profession-dialog.js").CharacterCreationDraft} [characterDraft]
* @returns {Promise<boolean>}
*/
async function runCharacterCreationPhase(
professionItem,
actor,
characterDraft,
) {
const ccResult = await showPickSkillsDialog(professionItem, actor, {
draft: characterDraft,
});
if (ccResult === null) return false;
const veteranResult = await resolveVeteranResult(ccResult.payload);
if (veteranResult === null) return false;
if (veteranResult.outcome === "back") {
return runCharacterCreationPhase(professionItem, actor, ccResult.draft);
}
await commitProfessionSetup(
actor,
professionItem,
ccResult.payload,
veteranResult,
);
return true;
}
/**
* Run the full profession setup flow: Pick Statistics → optional stat setup → Character Creation → Damaged Veterans.
*
* @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 Character Creation was submitted successfully.
* @returns {Promise<boolean>} True if the full setup flow completed successfully.
*/
async function runProfessionSetupFlow(
professionItem,
actor,
{ token = null } = {},
) {
let statsComplete = false;
const statsComplete = await runStatsPhase(actor, { token });
if (!statsComplete) return false;
while (!statsComplete) {
const choice = await showPickStatisticsDialog();
if (choice === null) return false;
if (choice === "skip") {
statsComplete = true;
} else if (choice === "roll") {
const result = await showRollStatsDialog(actor, { token });
if (result === "submitted") statsComplete = true;
} else if (choice === "assign") {
const result = await showAssignStatsDialog(actor);
if (result === "submitted") statsComplete = true;
}
}
return showPickSkillsDialog(professionItem, actor);
return runCharacterCreationPhase(professionItem, actor);
}
/**
@@ -74,8 +182,5 @@ export default async function assignProfessionToAgent(
const submitted = await runProfessionSetupFlow(stub, actor, { token });
if (!submitted) return null;
const createData = stub.toObject();
delete createData._id;
const created = await actor.createEmbeddedDocuments("Item", [createData]);
return created[0] ?? null;
return actor.items.find((item) => item.type === "profession") ?? null;
}
+8 -8
View File
@@ -11,7 +11,7 @@ import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
const { renderTemplate } = foundry.applications.handlebars;
/** @typedef {'submitted' | 'cancelled'} RollStatsResult */
/** @typedef {'submitted' | 'back'} RollStatsOutcome */
/**
* @param {object} state
@@ -183,7 +183,7 @@ function bindRollStatsListeners(state, dialog) {
* @param {Actor} actor
* @param {object} [options]
* @param {TokenDocument|null} [options.token]
* @returns {Promise<RollStatsResult>}
* @returns {Promise<{ outcome: 'submitted' } | { outcome: 'back' } | null>}
*/
export default async function showRollStatsDialog(
actor,
@@ -207,8 +207,8 @@ export default async function showRollStatsDialog(
assignments: Object.fromEntries(STAT_KEYS.map((key) => [key, null])),
dragRollIndex: null,
};
/** @type {RollStatsResult} */
let result = "cancelled";
/** @type {{ outcome: 'submitted' } | { outcome: 'back' } | null} */
let result = null;
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/roll-stats.html`,
@@ -233,10 +233,10 @@ export default async function showRollStatsDialog(
close: () => result,
buttons: [
{
action: "cancel",
label: game.i18n.localize("Cancel"),
action: "back",
label: game.i18n.localize("DG.ProfessionSetup.GoBack"),
callback: async (_event, _button, dialog) => {
result = "cancelled";
result = { outcome: "back" };
await dialog.close();
},
},
@@ -261,7 +261,7 @@ export default async function showRollStatsDialog(
}
await applyAgentStatistics(actor, valuesByKey);
result = "submitted";
result = { outcome: "submitted" };
await dialog.close();
return false;
},
@@ -0,0 +1,100 @@
import { BASE_TEMPLATE_PATH } from "../config/index.js";
import { DISORDER_OPTIONS } from "../profession/disorders.js";
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
const { renderTemplate } = foundry.applications.handlebars;
/**
* @typedef {object} ThingsDisorderDialogState
* @property {string} [disorder]
*/
/**
* @param {ThingsDisorderDialogState} [state]
* @returns {Promise<{ outcome: 'submitted', path: 'thingsMan', disorder: string } | { outcome: 'back' } | null>}
*/
export default async function showThingsDisorderDialog(state = {}) {
const defaultDisorder = state.disorder ?? "amnesia";
const content = await renderTemplate(
`${BASE_TEMPLATE_PATH}/dialog/things-disorder.html`,
{
disorders: DISORDER_OPTIONS.map(({ id, labelKey }) => ({
value: id,
labelKey,
checked: id === defaultDisorder,
})),
},
);
/** @type {{ outcome: 'submitted', path: 'thingsMan', disorder: string } | { outcome: 'back' } | null} */
let result = null;
await showDgDialog({
modifier: "things-disorder",
content,
window: {
title: game.i18n.localize("DG.DamagedVeterans.ThingsMan.Dialog.Title"),
},
position: { width: 560, height: "auto" },
form: { closeOnSubmit: false },
close: () => result,
onRender: (dialog) => {
const root = getDialogContentRoot(dialog);
if (!root) return;
const submitBtn = dialog.element?.querySelector(
'button[data-action="submit"]',
);
const updateSubmitState = () => {
const selected = root.querySelector(
'input[name="thingsDisorder"]:checked',
);
if (submitBtn) submitBtn.disabled = !selected;
};
root.querySelectorAll('input[name="thingsDisorder"]').forEach((input) => {
input.addEventListener("change", updateSubmitState);
});
updateSubmitState();
},
buttons: [
{
action: "back",
label: game.i18n.localize("DG.ProfessionSetup.GoBack"),
callback: async (_event, _button, dialog) => {
const root = getDialogContentRoot(dialog);
const selected = root?.querySelector(
'input[name="thingsDisorder"]:checked',
);
result = {
outcome: "back",
disorder: selected?.value,
};
await dialog.close();
return false;
},
},
{
action: "submit",
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
default: true,
disabled: false,
callback: async (_event, _button, dialog) => {
const root = getDialogContentRoot(dialog);
const selected = root?.querySelector(
'input[name="thingsDisorder"]:checked',
);
if (!selected) return false;
result = {
outcome: "submitted",
path: "thingsMan",
disorder: selected.value,
};
await dialog.close();
return false;
},
},
],
});
return result;
}
+84
View File
@@ -131,6 +131,90 @@ export function applyAgentResourceMaxBonuses(model, sourceStatistics) {
);
}
/**
* Compute agent HP/WP maximums from current statistics (including AE bonuses on max).
* @param {Actor} actor
* @returns {{ healthMax: number, wpMax: number }}
*/
export function computeAgentResourceMaximums(actor) {
const { system } = actor;
const sourceStatistics = actor._source?.system?.statistics;
const healthMax = cleanDerivedNumber(
system,
"health.max",
calculateHealthMax(system.statistics, sourceStatistics) +
(system.health.maxBonus ?? 0),
);
const wpMax = cleanDerivedNumber(
system,
"wp.max",
getStatisticEffectiveValue(system.statistics.pow, sourceStatistics?.pow) +
(system.wp.maxBonus ?? 0),
);
return { healthMax, wpMax };
}
/**
* @param {Actor} actor
* @param {string} veteranPath
* @returns {number}
*/
export function computeAgentBreakingPoint(actor, veteranPath) {
const sourceStatistics = actor._source?.system?.statistics;
const pow = getStatisticEffectiveValue(
actor.system.statistics.pow,
sourceStatistics?.pow,
);
if (veteranPath === "thingsMan") {
const sanity = Number(actor.system.sanity.value) || 0;
return Math.max(0, sanity - pow);
}
const fullStartingSan = pow * 5;
return Math.max(0, fullStartingSan - pow);
}
/**
* Persist agent HP/WP max from current statistics, fill both resources to max,
* initialize SAN for fresh recruits, and set breaking point after chargen.
* @param {Actor} actor
* @param {string} [veteranPath]
* @returns {Promise<void>}
*/
export async function finalizeAgentStartingResources(
actor,
veteranPath = "freshRecruit",
) {
if (actor.type !== "agent") return;
const { healthMax, wpMax } = computeAgentResourceMaximums(actor);
const breakingPoint = computeAgentBreakingPoint(actor, veteranPath);
/** @type {Record<string, unknown>} */
const updateData = {
"system.health.max": healthMax,
"system.health.value": healthMax,
"system.wp.max": wpMax,
"system.wp.value": wpMax,
"system.sanity.currentBreakingPoint": breakingPoint,
};
const persistedSanity = Number(actor._source?.system?.sanity?.value) ?? 0;
if (veteranPath === "freshRecruit" && persistedSanity >= 100) {
const sourceStatistics = actor._source?.system?.statistics;
const pow = getStatisticEffectiveValue(
actor.system.statistics.pow,
sourceStatistics?.pow,
);
updateData["system.sanity.value"] = pow * 5;
}
await actor.update(updateData);
}
export function setSkillTargetProficiencies(skills) {
for (const skill of Object.values(skills)) {
skill.targetProficiency = skill.proficiency;
+140 -10
View File
@@ -143,12 +143,21 @@ function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) {
}
/**
* Bonus skill slots that would waste 20+ points over the creation cap (skill below 80).
* @typedef {object} BonusPickOptions
* @property {number} [count]
* @property {number} [increment]
* @property {number} [cap]
* @property {number} [maxWaste]
*/
/**
* Bonus skill slots that would waste points over the creation cap.
*
* @param {Record<string, number>} baseFixed
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
* @param {string[]} bonusCatalogIds
* @param {string[]} bonusTypedLabels
* @param {BonusPickOptions} [options]
* @returns {string[]}
*/
export function collectBonusCapValidationErrors(
@@ -156,6 +165,12 @@ export function collectBonusCapValidationErrors(
baseTyped,
bonusCatalogIds,
bonusTypedLabels,
{
count = BONUS_SKILL_COUNT,
increment = BONUS_SKILL_INCREMENT,
cap = SKILL_CAP,
maxWaste = MAX_ALLOWED_BONUS_WASTE,
} = {},
) {
const defaults = getAgentSkillDefaults();
/** @type {Record<string, number>} */
@@ -163,7 +178,7 @@ export function collectBonusCapValidationErrors(
/** @type {(string | null)[]} */
const slotTrackKeys = [];
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
for (let i = 0; i < count; i++) {
const catalogId = bonusCatalogIds?.[i];
if (!catalogId) {
slotTrackKeys.push(null);
@@ -182,23 +197,23 @@ export function collectBonusCapValidationErrors(
/** @type {Set<string>} */
const violatingTrackKeys = new Set();
for (const [trackKey, count] of Object.entries(counts)) {
for (const [trackKey, countForTrack] 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) {
const final = base + countForTrack * increment;
const waste = Math.max(0, final - cap);
if (base < cap && waste >= maxWaste) {
violatingTrackKeys.add(trackKey);
}
}
/** @type {string[]} */
const errors = [];
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
for (let i = 0; i < count; i++) {
const trackKey = slotTrackKeys[i];
if (trackKey && violatingTrackKeys.has(trackKey)) {
const base = getBonusTrackBaseValue(
@@ -207,9 +222,9 @@ export function collectBonusCapValidationErrors(
baseTyped,
defaults,
);
const count = counts[trackKey] ?? 0;
const final = base + count * BONUS_SKILL_INCREMENT;
const waste = Math.max(0, final - SKILL_CAP);
const countForTrack = counts[trackKey] ?? 0;
const final = base + countForTrack * increment;
const waste = Math.max(0, final - cap);
errors.push(`bonusWaste:${i}|${waste}|${trackKey}`);
}
@@ -217,3 +232,118 @@ export function collectBonusCapValidationErrors(
return errors;
}
/**
* @param {Record<string, number>} baseFixed
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
* @param {{ bonusCatalogIds?: string[], bonusTypedLabels?: string[] }} formState
* @param {BonusPickOptions} [options]
* @returns {{
* fixedValues: Record<string, number>,
* typedValues: Record<string, { group: string, label: string, value: number }>,
* modifiedFixedKeys: string[],
* modifiedTypedKeys: string[],
* validationErrors: string[],
* isValid: boolean,
* }}
*/
export function applyBonusSkillPicks(
baseFixed,
baseTyped,
formState,
{
count = BONUS_SKILL_COUNT,
increment = BONUS_SKILL_INCREMENT,
cap = SKILL_CAP,
maxWaste = MAX_ALLOWED_BONUS_WASTE,
} = {},
) {
const defaults = getAgentSkillDefaults();
const fixedValues = { ...baseFixed };
const typedValues = foundry.utils.deepClone(baseTyped);
/** @type {Set<string>} */
const modifiedFixedKeys = new Set();
/** @type {Set<string>} */
const modifiedTypedKeys = new Set();
/** @type {Record<string, number>} */
const bonusCounts = {};
const bonusIds = formState.bonusCatalogIds ?? [];
const bonusLabels = formState.bonusTypedLabels ?? [];
for (let i = 0; i < count; i++) {
const catalogId = bonusIds[i];
if (catalogId) {
const typedLabel = bonusLabels[i] ?? "";
const ref = catalogIdToSkillRef(catalogId, typedLabel);
if (ref) {
if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key);
else modifiedTypedKeys.add(formatProfessionSkillKey(ref));
const trackKey = getBonusTrackKey(ref);
bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1;
}
}
}
for (const [trackKey, pickCount] of Object.entries(bonusCounts)) {
const bonus = pickCount * increment;
if (trackKey.startsWith("fixed:")) {
const key = trackKey.slice(6);
fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus;
} else {
const ref = parseProfessionSkillKey(trackKey);
if (ref?.kind === "typed") {
const base = typedValues[trackKey]?.value ?? 0;
typedValues[trackKey] = {
group: ref.group,
label: ref.label,
value: base + bonus,
};
}
}
}
for (const key of Object.keys(fixedValues)) {
if (fixedValues[key] > cap) fixedValues[key] = cap;
}
for (const data of Object.values(typedValues)) {
if (data.value > cap) data.value = cap;
}
/** @type {string[]} */
const validationErrors = [];
for (let i = 0; i < count; i++) {
const catalogId = bonusIds[i];
if (!catalogId) {
validationErrors.push(`bonus${i}`);
} else {
const ref = catalogIdToSkillRef(catalogId, bonusLabels[i] ?? "");
if (!ref) {
validationErrors.push(`bonus${i}`);
} else if (ref.kind === "typed" && !bonusLabels[i]?.trim()) {
validationErrors.push(`bonusType${i}`);
}
}
}
validationErrors.push(
...collectBonusCapValidationErrors(
baseFixed,
baseTyped,
bonusIds,
bonusLabels,
{ count, increment, cap, maxWaste },
),
);
return {
fixedValues,
typedValues,
modifiedFixedKeys: [...modifiedFixedKeys],
modifiedTypedKeys: [...modifiedTypedKeys],
validationErrors,
isValid: validationErrors.length === 0,
};
}
@@ -0,0 +1,134 @@
import { applyDamagedVeteranModifications } from "./damaged-veteran-modifiers.js";
import { finalizeAgentStartingResources } from "../data/derived/actor-derived.js";
/**
* @typedef {object} CharacterCreationBond
* @property {string} name
* @property {string} relationship
*/
/**
* @typedef {object} CharacterCreationPayload
* @property {Record<string, number>} fixedValues
* @property {Record<string, { group: string, label: string, value: number }>} typedValues
* @property {string} professionName
* @property {CharacterCreationBond[]} bonds
*/
/**
* @param {ReturnType<import("./compute.js").default>} computed
* @param {Item} professionItem
* @param {{ bondNames: string[], bondRelationships: string[] }} formState
* @returns {CharacterCreationPayload}
*/
export function buildCharacterCreationPayload(
computed,
professionItem,
formState,
) {
const bonds = [];
const bondCount = Math.max(
1,
Math.trunc(Number(professionItem.system.bonds) || 1),
);
for (let i = 0; i < bondCount; i++) {
bonds.push({
name: formState.bondNames?.[i]?.trim() ?? "",
relationship: formState.bondRelationships?.[i]?.trim() ?? "",
});
}
return {
fixedValues: { ...computed.fixedValues },
typedValues: foundry.utils.deepClone(computed.typedValues),
professionName: professionItem.name,
bonds,
};
}
/**
* @param {Actor} actor
* @param {CharacterCreationPayload} payload
* @param {{ skipBondIndices?: number[] }} [options]
*/
export async function applyCharacterCreationPayload(
actor,
payload,
{ skipBondIndices = [] } = {},
) {
const skipSet = new Set(skipBondIndices);
const updateData = {};
for (const [key, value] of Object.entries(payload.fixedValues)) {
updateData[`system.skills.${key}.proficiency`] = value;
updateData[`system.skills.${key}.failure`] = false;
}
const typedSkills = { ...(actor.system.typedSkills ?? {}) };
for (const [, data] of Object.entries(payload.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"] = payload.professionName;
await actor.update(updateData);
const chaScore =
actor.system.statistics.cha.effectiveValue ??
actor.system.statistics.cha.value;
/** @type {object[]} */
const bondDocs = [];
payload.bonds.forEach((bond, index) => {
if (skipSet.has(index)) return;
bondDocs.push({
name: bond.name,
type: "bond",
system: {
score: chaScore,
relationship: bond.relationship,
},
});
});
if (bondDocs.length) {
await actor.createEmbeddedDocuments("Item", bondDocs);
}
}
/**
* @param {Item} professionStub
* @param {Actor} actor
* @param {CharacterCreationPayload} payload
* @param {object} veteranResult
*/
export async function commitProfessionSetup(
actor,
professionStub,
payload,
veteranResult,
) {
const skipBondIndices =
veteranResult.path === "hardExperience" &&
Number.isInteger(veteranResult.removedBondIndex)
? [veteranResult.removedBondIndex]
: [];
await applyCharacterCreationPayload(actor, payload, { skipBondIndices });
await applyDamagedVeteranModifications(actor, veteranResult, payload);
await finalizeAgentStartingResources(actor, veteranResult.path);
const createData = professionStub.toObject();
delete createData._id;
const created = await actor.createEmbeddedDocuments("Item", [createData]);
return created[0] ?? null;
}
+15 -65
View File
@@ -1,10 +1,6 @@
import { SKILL_CAP } from "./constants.js";
import {
BONUS_SKILL_COUNT,
BONUS_SKILL_INCREMENT,
SKILL_CAP,
} from "./constants.js";
import {
catalogIdToSkillRef,
applyBonusSkillPicks,
collectBonusCapValidationErrors,
} from "./catalog.js";
import {
@@ -143,53 +139,11 @@ export default function computeSkillValues(
}
}
/** @type {Record<string, number>} */
const bonusCounts = {};
const bonusIds = formState.bonusCatalogIds ?? [];
const bonusLabels = formState.bonusTypedLabels ?? [];
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
const catalogId = bonusIds[i];
if (catalogId) {
const typedLabel = bonusLabels[i] ?? "";
const ref = catalogIdToSkillRef(catalogId, typedLabel);
if (ref) {
if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key);
else modifiedTypedKeys.add(formatProfessionSkillKey(ref));
let trackKey;
if (ref.kind === "fixed") {
trackKey = `fixed:${ref.key}`;
} else {
trackKey = formatProfessionSkillKey(ref);
}
bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1;
}
}
}
/** Pre-bonus base for cap warnings */
const baseFixed = { ...fixedValues };
const baseTyped = foundry.utils.deepClone(typedValues);
for (const [trackKey, count] of Object.entries(bonusCounts)) {
const bonus = count * BONUS_SKILL_INCREMENT;
if (trackKey.startsWith("fixed:")) {
const key = trackKey.slice(6);
fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus;
} else {
const ref = parseProfessionSkillKey(trackKey);
if (ref?.kind === "typed") {
const base = typedValues[trackKey]?.value ?? 0;
typedValues[trackKey] = {
group: ref.group,
label: ref.label,
value: base + bonus,
};
}
}
}
const bonusResult = applyBonusSkillPicks(baseFixed, baseTyped, formState);
/** @type {{ label: string, attempted: number, waste: number }[]} */
const capWarnings = [];
@@ -204,13 +158,13 @@ export default function computeSkillValues(
}
};
for (const [key, final] of Object.entries(fixedValues)) {
for (const [key, final] of Object.entries(bonusResult.fixedValues)) {
const base = baseFixed[key] ?? defaults[key] ?? 0;
const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key });
checkCap(formatProfessionSkillLabel(ref), base, final);
}
for (const [storageKey, data] of Object.entries(typedValues)) {
for (const [storageKey, data] of Object.entries(bonusResult.typedValues)) {
const base = baseTyped[storageKey]?.value ?? 0;
const ref = /** @type {TypedSkillRef} */ ({
kind: "typed",
@@ -220,14 +174,6 @@ export default function computeSkillValues(
checkCap(formatProfessionSkillLabel(ref), base, data.value);
}
/** Apply cap for display (values shown capped at 80) */
for (const key of Object.keys(fixedValues)) {
if (fixedValues[key] > SKILL_CAP) fixedValues[key] = SKILL_CAP;
}
for (const data of Object.values(typedValues)) {
if (data.value > SKILL_CAP) data.value = SKILL_CAP;
}
const validationErrors = validateProfessionFormState(optionPicks, formState, {
automaticSkills,
optionSkills,
@@ -240,18 +186,22 @@ export default function computeSkillValues(
...collectBonusCapValidationErrors(
baseFixed,
baseTyped,
bonusIds,
bonusLabels,
formState.bonusCatalogIds ?? [],
formState.bonusTypedLabels ?? [],
),
);
return {
fixedValues,
typedValues,
fixedValues: bonusResult.fixedValues,
typedValues: bonusResult.typedValues,
capWarnings,
isValid: validationErrors.length === 0,
validationErrors,
modifiedFixedKeys: [...modifiedFixedKeys],
modifiedTypedKeys: [...modifiedTypedKeys],
modifiedFixedKeys: [
...new Set([...modifiedFixedKeys, ...bonusResult.modifiedFixedKeys]),
],
modifiedTypedKeys: [
...new Set([...modifiedTypedKeys, ...bonusResult.modifiedTypedKeys]),
],
};
}
+4
View File
@@ -28,6 +28,10 @@ export const TYPED_GROUP_I18N = {
export const BONUS_SKILL_COUNT = 8;
export const BONUS_SKILL_INCREMENT = 20;
export const HARD_EXPERIENCE_BONUS_COUNT = 5;
export const HARD_EXPERIENCE_BONUS_INCREMENT = 10;
export const HARD_EXPERIENCE_SKILL_CAP = 90;
/** @type {ReadonlySet<string>} */
export const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]);
export const SKILL_CAP = 80;
@@ -0,0 +1,222 @@
import {
HARD_EXPERIENCE_BONUS_COUNT,
HARD_EXPERIENCE_BONUS_INCREMENT,
HARD_EXPERIENCE_SKILL_CAP,
} from "./constants.js";
import { applyBonusSkillPicks } from "./catalog.js";
import { formatProfessionSkillKey } from "./keys.js";
function getPowRating(actor) {
return (
actor.system.statistics.pow.effectiveValue ??
actor.system.statistics.pow.value
);
}
function getChaRating(actor) {
return (
actor.system.statistics.cha.effectiveValue ??
actor.system.statistics.cha.value
);
}
const DEFAULT_SANITY_POW_MULTIPLIER = 5;
const THINGS_MAN_SANITY_POW_MULTIPLIER = 4;
/**
* @param {Actor} actor
* @param {number} [powMultiplier]
* @returns {number}
*/
function getSanityFromPow(
actor,
powMultiplier = DEFAULT_SANITY_POW_MULTIPLIER,
) {
const pow = getPowRating(actor);
return Math.max(0, pow * powMultiplier);
}
function buildOccultSanityStatUpdate(actor, options) {
const occult = Number(actor.system.skills.occult?.proficiency) || 0;
const sanity = Math.max(
0,
getSanityFromPow(actor, DEFAULT_SANITY_POW_MULTIPLIER) +
(options.sanityDelta ?? 0),
);
/** @type {Record<string, unknown>} */
const updateData = {
"system.skills.occult.proficiency": occult + (options.occultBonus ?? 0),
"system.sanity.value": sanity,
};
if (options.chaDelta) {
updateData["system.statistics.cha.value"] =
getChaRating(actor) + options.chaDelta;
}
if (options.powDelta) {
updateData["system.statistics.pow.value"] =
getPowRating(actor) + options.powDelta;
}
return updateData;
}
async function adjustAllBondScores(actor, delta) {
const bonds = actor.items.filter((item) => item.type === "bond");
if (!bonds.length) return;
const updates = bonds.map((bond) => ({
_id: bond.id,
system: {
score: Math.max(0, Number(bond.system.score) + delta),
},
}));
await actor.updateEmbeddedDocuments("Item", updates);
}
async function applyExtremeViolence(actor) {
const updateData = buildOccultSanityStatUpdate(actor, {
occultBonus: 10,
sanityDelta: -5,
chaDelta: -3,
});
updateData["system.sanity.adaptations.violence.incident1"] = true;
updateData["system.sanity.adaptations.violence.incident2"] = true;
updateData["system.sanity.adaptations.violence.incident3"] = true;
await actor.update(updateData);
await adjustAllBondScores(actor, -3);
}
async function applyCaptivity(actor) {
const updateData = buildOccultSanityStatUpdate(actor, {
occultBonus: 10,
sanityDelta: -5,
powDelta: -3,
});
updateData["system.sanity.adaptations.helplessness.incident1"] = true;
updateData["system.sanity.adaptations.helplessness.incident2"] = true;
updateData["system.sanity.adaptations.helplessness.incident3"] = true;
await actor.update(updateData);
}
async function applyHardExperience(actor, veteranResult, payload) {
const occultSanityUpdate = buildOccultSanityStatUpdate(actor, {
occultBonus: 10,
sanityDelta: -5,
});
const computed = applyBonusSkillPicks(
payload.fixedValues,
payload.typedValues,
{
bonusCatalogIds: veteranResult.bonusCatalogIds,
bonusTypedLabels: veteranResult.bonusTypedLabels,
},
{
count: HARD_EXPERIENCE_BONUS_COUNT,
increment: HARD_EXPERIENCE_BONUS_INCREMENT,
cap: HARD_EXPERIENCE_SKILL_CAP,
maxWaste: HARD_EXPERIENCE_BONUS_INCREMENT,
},
);
const updateData = { ...occultSanityUpdate };
for (const [key, value] of Object.entries(computed.fixedValues)) {
updateData[`system.skills.${key}.proficiency`] = value;
}
const typedSkills = { ...(actor.system.typedSkills ?? {}) };
const existingByKey = new Map();
for (const [id, skill] of Object.entries(typedSkills)) {
const storageKey = formatProfessionSkillKey({
kind: "typed",
group: skill.group,
label: skill.label,
});
existingByKey.set(storageKey, id);
}
for (const [storageKey, data] of Object.entries(computed.typedValues)) {
const existingId = existingByKey.get(storageKey);
if (existingId) {
typedSkills[existingId].proficiency = data.value;
} else {
const id = foundry.utils.randomID();
typedSkills[id] = {
label: data.label,
group: data.group,
proficiency: data.value,
failure: false,
};
}
}
updateData["system.typedSkills"] = typedSkills;
await actor.update(updateData);
}
async function applyThingsMan(actor, veteranResult) {
const sanity = getSanityFromPow(actor, THINGS_MAN_SANITY_POW_MULTIPLIER);
const occult = Number(actor.system.skills.occult?.proficiency) || 0;
const unnatural = Number(actor.system.skills.unnatural?.proficiency) || 0;
await actor.update({
"system.skills.unnatural.proficiency": unnatural + 10,
"system.skills.occult.proficiency": occult + 20,
"system.sanity.value": sanity,
});
await actor.createEmbeddedDocuments("Item", [
{
name: game.i18n.localize("DG.DamagedVeterans.ThingsMan.MotivationName"),
type: "motivation",
system: {
crossedOut: true,
disorder: veteranResult.disorder,
},
},
]);
}
export async function applyDamagedVeteranModifications(
actor,
veteranResult,
payload,
) {
if (veteranResult.path === "freshRecruit") {
return;
}
if (veteranResult.path === "extremeViolence") {
await applyExtremeViolence(actor);
return;
}
if (veteranResult.path === "captivity") {
await applyCaptivity(actor);
return;
}
if (veteranResult.path === "hardExperience") {
await applyHardExperience(actor, veteranResult, payload);
return;
}
if (veteranResult.path === "thingsMan") {
await applyThingsMan(actor, veteranResult);
}
}
export function computeHardExperienceSkillValues(payload, formState) {
return applyBonusSkillPicks(
payload.fixedValues,
payload.typedValues,
formState,
{
count: HARD_EXPERIENCE_BONUS_COUNT,
increment: HARD_EXPERIENCE_BONUS_INCREMENT,
cap: HARD_EXPERIENCE_SKILL_CAP,
maxWaste: HARD_EXPERIENCE_BONUS_INCREMENT,
},
);
}
+89
View File
@@ -0,0 +1,89 @@
/** @typedef {typeof DISORDER_OPTIONS[number]["id"]} DisorderId */
export const DISORDER_OPTIONS = /** @type {const} */ ([
{
id: "amnesia",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Amnesia",
},
{
id: "depersonalizationDisorder",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.DepersonalizationDisorder",
},
{
id: "depression",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Depression",
},
{
id: "dissociativeIdentityDisorder",
labelKey:
"DG.DamagedVeterans.ThingsMan.Disorder.DissociativeIdentityDisorder",
},
{
id: "fugues",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Fugues",
},
{
id: "megalomania",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Megalomania",
},
{
id: "paranoia",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Paranoia",
},
{
id: "sleepDisorder",
labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.SleepDisorder",
},
]);
/** @type {Map<string, string>} */
const LABEL_KEY_BY_ID = new Map(
DISORDER_OPTIONS.map((entry) => [entry.id, entry.labelKey]),
);
/** Legacy English display strings from earlier builds. */
/** @type {Map<string, string>} */
const LEGACY_LABEL_TO_ID = new Map([
["Amnesia", "amnesia"],
["Depersonalization Disorder", "depersonalizationDisorder"],
["Depression", "depression"],
["Dissociative Identity Disorder", "dissociativeIdentityDisorder"],
["Fugues", "fugues"],
["Megalomania", "megalomania"],
["Paranoia", "paranoia"],
["Sleep Disorder", "sleepDisorder"],
]);
/**
* @param {string} value
* @returns {string|null}
*/
export function resolveDisorderId(value) {
const trimmed = value?.trim();
if (!trimmed) return null;
if (LABEL_KEY_BY_ID.has(trimmed)) return trimmed;
return LEGACY_LABEL_TO_ID.get(trimmed) ?? null;
}
/**
* @param {string} value
* @returns {boolean}
*/
export function isKnownDisorderId(value) {
return resolveDisorderId(value) !== null;
}
/**
* @param {string} value Stored disorder id or legacy display string.
* @returns {string}
*/
export function getDisorderLabel(value) {
const trimmed = value?.trim();
if (!trimmed) return "";
const id = resolveDisorderId(trimmed) ?? trimmed;
const labelKey = LABEL_KEY_BY_ID.get(id);
if (labelKey) return game.i18n.localize(labelKey);
return trimmed;
}
+22 -1
View File
@@ -1,4 +1,23 @@
export { TYPED_SKILL_TEMPLATE_GROUPS, BONUS_SKILL_COUNT } from "./constants.js";
export {
TYPED_SKILL_TEMPLATE_GROUPS,
BONUS_SKILL_COUNT,
HARD_EXPERIENCE_BONUS_COUNT,
} from "./constants.js";
export {
buildCharacterCreationPayload,
applyCharacterCreationPayload,
commitProfessionSetup,
} from "./commit-character-creation.js";
export {
applyDamagedVeteranModifications,
computeHardExperienceSkillValues,
} from "./damaged-veteran-modifiers.js";
export {
DISORDER_OPTIONS,
getDisorderLabel,
isKnownDisorderId,
resolveDisorderId,
} from "./disorders.js";
export {
getChooseOnePlaceholderLabel,
normalizeTypedSkillName,
@@ -20,6 +39,8 @@ export {
buildBonusSkillCatalog,
catalogIdToSkillRef,
collectBonusCapValidationErrors,
applyBonusSkillPicks,
getBonusTrackLabel,
} from "./catalog.js";
export { default as computeSkillValues } from "./compute.js";
export {
+5 -5
View File
@@ -118,7 +118,7 @@ export function validateProfessionFormState(
*/
export function formatProfessionValidationMessages(
errors,
{ optionPicks = 0 } = {},
{ optionPicks = 0, bonusCount = BONUS_SKILL_COUNT, skillCap = 80 } = {},
) {
const messages = [];
const seen = new Set();
@@ -136,8 +136,8 @@ export function formatProfessionValidationMessages(
let needsBonusSkillType = false;
/** @type {Set<string>} */
const typedNameRequiredGroups = new Set();
/** @type {string[]} */
const bonusWasteTrackKeys = [];
/** @type {Set<string>} */
const bonusWasteTrackKeys = new Set();
for (const code of errors) {
if (code === "optionPicks") {
@@ -149,7 +149,7 @@ export function formatProfessionValidationMessages(
} else {
const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/);
if (bonusWaste) {
bonusWasteTrackKeys.push(bonusWaste[2]);
bonusWasteTrackKeys.add(bonusWaste[2]);
} else if (code.startsWith("bonusType")) {
needsBonusSkillType = true;
} else if (/^bonus\d+$/.test(code)) {
@@ -184,7 +184,7 @@ export function formatProfessionValidationMessages(
if (needsBonusSkill) {
push(
game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", {
count: BONUS_SKILL_COUNT,
count: bonusCount,
}),
);
}
+2 -2
View File
@@ -78,9 +78,9 @@ export default class DGAgentSheet extends AgentSheetBase {
],
},
leftBarRestSanity: {
initial: "rest",
initial: "sanity",
labelPrefix: "DG.Navigation.Agent.LeftBar",
tabs: [{ id: "rest" }, { id: "sanity" }],
tabs: [{ id: "sanity" }, { id: "rest" }],
},
});
-11
View File
@@ -165,15 +165,4 @@ export default class DGActorSheet extends ComposedActorSheetBase {
this._tooltipsSettings(element);
this._bindAeBackedInputs(element);
}
/** @override */
_getHeaderControls() {
const controls = super._getHeaderControls();
controls.push({
action: "rollLuck",
label: "DG.RollLuck",
icon: "fas fa-dice",
});
return controls;
}
}
+11
View File
@@ -1,4 +1,8 @@
import { BASE_TEMPLATE_PATH } from "../config/index.js";
import {
getDisorderLabel,
isKnownDisorderId,
} from "../profession/disorders.js";
import DGSheetMixin from "./base-sheet.js";
import EffectsTabMixin from "./mixins/effects-tab-mixin.js";
import ProfessionItemMixin from "./mixins/profession-item-mixin.js";
@@ -249,6 +253,13 @@ export default class DGItemSheet extends ItemSheetBase {
context.owner = this.document.isOwner;
if (this.item.type === "motivation" && this.item.system.disorder) {
context.disorderUsesKnownId = isKnownDisorderId(
this.item.system.disorder,
);
context.disorderLabel = getDisorderLabel(this.item.system.disorder);
}
return context;
}
+9
View File
@@ -1,3 +1,4 @@
import { getDisorderLabel } from "../profession/disorders.js";
import DGUtils from "./utility-functions.js";
export default function registerHandlebarsHelpers() {
@@ -100,6 +101,14 @@ export default function registerHandlebarsHelpers() {
return game.user.isGM;
});
Handlebars.registerHelper("dgDisorderLabel", (value) => {
try {
return getDisorderLabel(value);
} catch {
return value ?? "";
}
});
Handlebars.registerHelper("showImpossibleLandscapesContent", () => {
let result = false;
const setting = game.settings.get(