Added damaged veteran dialog
This commit is contained in:
+13
-4
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user