From 3c2d00949348feb283cb51ba28f366b272db8f34 Mon Sep 17 00:00:00 2001 From: Aaron Lutomski Date: Sat, 13 Jun 2026 11:37:51 -0700 Subject: [PATCH] Added damaged veteran dialog --- README.md | 2 +- lang/en.json | 37 ++- module/MODULES.md | 17 +- module/applications/add-profession-dialog.js | 269 ++++++------------ module/applications/assign-stats-dialog.js | 16 +- module/applications/character-creation-ui.js | 170 +++++++++++ .../applications/damaged-veterans-dialog.js | 120 ++++++++ module/applications/hard-experience-dialog.js | 241 ++++++++++++++++ module/applications/profession-setup-flow.js | 151 ++++++++-- module/applications/roll-stats-dialog.js | 16 +- module/applications/things-disorder-dialog.js | 100 +++++++ module/data/derived/actor-derived.js | 84 ++++++ module/profession/catalog.js | 150 +++++++++- .../profession/commit-character-creation.js | 134 +++++++++ module/profession/compute.js | 80 +----- module/profession/constants.js | 4 + .../profession/damaged-veteran-modifiers.js | 222 +++++++++++++++ module/profession/disorders.js | 89 ++++++ module/profession/index.js | 23 +- module/profession/validation.js | 10 +- module/sheets/agent-sheet.js | 4 +- module/sheets/base-actor-sheet.js | 11 - module/sheets/base-item-sheet.js | 11 + module/utils/register-helpers.js | 9 + scss/_mixins.scss | 7 +- scss/deltagreen/_character-sheet.scss | 1 - scss/dg-dialog/_dialogs.scss | 161 +++++++++-- templates/actor/parts/left-bar.html | 2 +- .../actor/parts/motivations-tab-agent.html | 2 +- templates/dialog/add-profession.html | 52 ++-- templates/dialog/damaged-veterans.html | 19 ++ templates/dialog/hard-experience.html | 80 ++++++ templates/dialog/things-disorder.html | 17 ++ templates/item/parts/motivation-header.html | 18 +- 34 files changed, 1946 insertions(+), 383 deletions(-) create mode 100644 module/applications/character-creation-ui.js create mode 100644 module/applications/damaged-veterans-dialog.js create mode 100644 module/applications/hard-experience-dialog.js create mode 100644 module/applications/things-disorder-dialog.js create mode 100644 module/profession/commit-character-creation.js create mode 100644 module/profession/damaged-veteran-modifiers.js create mode 100644 module/profession/disorders.js create mode 100644 templates/dialog/damaged-veterans.html create mode 100644 templates/dialog/hard-experience.html create mode 100644 templates/dialog/things-disorder.html diff --git a/README.md b/README.md index 60ee88e..581b88d 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,4 @@ If there is a need to manually install the system, go to _Game Systems_ and clic ## System Documentation -You may find documentation for the system in [this folder](https://github.com/TheLastScrub/delta-green-foundry-vtt-system/blob/master/documentation/home.md). +You may find documentation for the system in [this folder](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/blob/master/documentation/home.md). diff --git a/lang/en.json b/lang/en.json index dd2d873..c62d621 100644 --- a/lang/en.json +++ b/lang/en.json @@ -382,14 +382,14 @@ "TYPES.Item.profession": "Profession", "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", "DG.Profession.Dialog.Title": "Character Creation", - "DG.Profession.Dialog.Tab.SkillOptions": "Skill Options", + "DG.Profession.Dialog.Tab.SkillOptions": "Skill Selections", "DG.Profession.Dialog.Tab.BonusSkills": "Bonus Skills", "DG.Profession.Dialog.Tab.Bonds": "Bonds", "DG.Profession.Dialog.Skill": "Skill", "DG.Profession.Dialog.Value": "Value", "DG.Profession.Dialog.SkillTotalsHeader": "Skill Totals", "DG.Profession.Dialog.SkillTotalsHint": "Skills in green have been increased by your profession. Hover over a skill to see what it does or consult the Agent's Handbook for the full description.", - "DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skill Selections ({picks} picks)", "DG.Profession.Dialog.BonusSkillsIntro": "Select skills from the dropdowns to add 20% to their total. A skill may be selected multiple times. Unnatural cannot be selected, nor can any skill be increased above 80% at character creation.", "DG.Profession.Dialog.BonusSkillWasteTooHigh": "{skill} cannot be increased above 80. Select a different bonus skill.", "DG.Profession.Dialog.BondsIntro": "Bonds are the important relationships in your agent's life, such as family, friends, and other loved ones. They can provide a buffer against SAN loss, at the cost of that relationship. Initial relationship score is equal to CHA.", @@ -402,7 +402,40 @@ "DG.Profession.ChooseOnePlaceholder": "choose one", "DG.Profession.Dialog.ChooseOneNamesHeader": "Name Skill Specializations", "DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization", + "DG.DamagedVeterans.Dialog.Title": "Damaged Veterans", + "DG.DamagedVeterans.Dialog.Intro": "Decide if your character is a fresh recruit or an experienced Delta Green Agent. If experienced, choose one of the other options to apply further modifications before starting play.", + "DG.DamagedVeterans.Dialog.FreshRecruit.Label": "Fresh Recruit:", + "DG.DamagedVeterans.Dialog.FreshRecruit.Body": "Your character is not yet part of Delta Green or has just joined. Start play with no modifications.", + "DG.DamagedVeterans.Dialog.ExtremeViolence.Label": "Extreme Violence:", + "DG.DamagedVeterans.Dialog.ExtremeViolence.Body": "+10 Occult, -5 SAN, -3 CHA, -3 to all Bonds. Start play Adapted to Violence.", + "DG.DamagedVeterans.Dialog.Captivity.Label": "Captivity or Imprisonment:", + "DG.DamagedVeterans.Dialog.Captivity.Body": "+10 Occult, -5 SAN, -3 POW. Start play Adapted to Helplessness.", + "DG.DamagedVeterans.Dialog.HardExperience.Label": "Hard Experience:", + "DG.DamagedVeterans.Dialog.HardExperience.Body": "+10 Occult, -5 SAN, remove one Bond. Gain +10 to any five skills other than Unnatural, to a maximum of 90%.", + "DG.DamagedVeterans.Dialog.ThingsMan.Label": "Things Man Was Not Meant to Know:", + "DG.DamagedVeterans.Dialog.ThingsMan.Body": "+10 Unnatural, +20 Occult, -SAN equal to POW. Start play with a Disorder caused by the Unnatural. Reset Breaking Point.", + "DG.DamagedVeterans.Dialog.SelectionRequired": "Select a damaged veteran option before submitting.", + "DG.DamagedVeterans.ThingsMan.Dialog.Title": "Things Man Was Not Meant to Know", + "DG.DamagedVeterans.ThingsMan.Dialog.Intro": "Choose one disorder with which to begin play. You can find the descriptions for these disorders on pages 71-72 of the Agent's Handbook.", + "DG.DamagedVeterans.ThingsMan.Disorder.Amnesia": "Amnesia", + "DG.DamagedVeterans.ThingsMan.Disorder.DepersonalizationDisorder": "Depersonalization Disorder", + "DG.DamagedVeterans.ThingsMan.Disorder.Depression": "Depression", + "DG.DamagedVeterans.ThingsMan.Disorder.DissociativeIdentityDisorder": "Dissociative Identity Disorder", + "DG.DamagedVeterans.ThingsMan.Disorder.Fugues": "Fugues", + "DG.DamagedVeterans.ThingsMan.Disorder.Megalomania": "Megalomania", + "DG.DamagedVeterans.ThingsMan.Disorder.Paranoia": "Paranoia", + "DG.DamagedVeterans.ThingsMan.Disorder.SleepDisorder": "Sleep Disorder", + "DG.DamagedVeterans.ThingsMan.Dialog.SelectionRequired": "Select a disorder before submitting.", + "DG.DamagedVeterans.ThingsMan.MotivationName": "Describe Motivation", + "DG.HardExperience.Dialog.Title": "Hard Experience", + "DG.HardExperience.Dialog.BonusIntro": "Select skills from the dropdowns to add 10% to their total. A skill may be selected multiple times. Unnatural cannot be selected, nor can any skill be increased above 90%.", + "DG.HardExperience.Dialog.BondRemovalHeader": "Remove One Bond", + "DG.HardExperience.Dialog.BondRemovalIntro": "Choose one bond to remove from your agent.", + "DG.HardExperience.Dialog.BondRemovalRequired": "Select a bond to remove before submitting.", + "DG.HardExperience.Dialog.BonusSkillRequired": "Select a skill in each of the five bonus skill dropdowns.", + "DG.HardExperience.Dialog.BonusSkillWasteTooHigh": "{skill} cannot be increased above 90. Select a different bonus skill.", "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.GoBack": "Go Back", "DG.ProfessionSetup.PickStatistics.Intro": "Choose how to set this agent's statistics before picking skills.", "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", diff --git a/module/MODULES.md b/module/MODULES.md index f9c8e65..e111154 100644 --- a/module/MODULES.md +++ b/module/MODULES.md @@ -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. diff --git a/module/applications/add-profession-dialog.js b/module/applications/add-profession-dialog.js index db100ee..2068612 100644 --- a/module/applications/add-profession-dialog.js +++ b/module/applications/add-profession-dialog.js @@ -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} chooseOneLabels + * @property {string[]} bonusCatalogIds + * @property {string[]} bonusTypedLabels + * @property {string[]} bondNames + * @property {string[]} bondRelationships + */ + /** * @param {Item} professionItem * @param {Actor} actor - * @returns {Promise} + * @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} */ - this.checkedOptionKeys = new Set(); + this.checkedOptionKeys = new Set(draft?.checkedOptionKeys ?? []); /** @type {Record} */ - 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} - */ 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} 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} 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) => - `

${foundry.utils.escapeHTML( - message, - )}

`, - ) - .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 `
${foundry.utils.escapeHTML( - r.label, - )}${ - r.value - }%
`; - }) - .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(); } } diff --git a/module/applications/assign-stats-dialog.js b/module/applications/assign-stats-dialog.js index 2f9bd82..be4e175 100644 --- a/module/applications/assign-stats-dialog.js +++ b/module/applications/assign-stats-dialog.js @@ -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} + * @returns {Promise<{ outcome: 'submitted' } | { outcome: 'back' } | null>} */ export default async function showAssignStatsDialog(actor) { /** @type {Record} */ 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; }, diff --git a/module/applications/character-creation-ui.js b/module/applications/character-creation-ui.js new file mode 100644 index 0000000..145b31a --- /dev/null +++ b/module/applications/character-creation-ui.js @@ -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} [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 `
${foundry.utils.escapeHTML( + r.label, + )}${ + r.value + }%
`; + }) + .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} */ + 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) => + `

${foundry.utils.escapeHTML( + message, + )}

`, + ) + .join(""); +} diff --git a/module/applications/damaged-veterans-dialog.js b/module/applications/damaged-veterans-dialog.js new file mode 100644 index 0000000..6b6c505 --- /dev/null +++ b/module/applications/damaged-veterans-dialog.js @@ -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; +} diff --git a/module/applications/hard-experience-dialog.js b/module/applications/hard-experience-dialog.js new file mode 100644 index 0000000..2ce2964 --- /dev/null +++ b/module/applications/hard-experience-dialog.js @@ -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; +} diff --git a/module/applications/profession-setup-flow.js b/module/applications/profession-setup-flow.js index 047eec2..244c214 100644 --- a/module/applications/profession-setup-flow.js +++ b/module/applications/profession-setup-flow.js @@ -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} + */ +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} + */ +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} + */ +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} True if Character Creation was submitted successfully. + * @returns {Promise} 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; } diff --git a/module/applications/roll-stats-dialog.js b/module/applications/roll-stats-dialog.js index af4fee8..69db878 100644 --- a/module/applications/roll-stats-dialog.js +++ b/module/applications/roll-stats-dialog.js @@ -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} + * @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; }, diff --git a/module/applications/things-disorder-dialog.js b/module/applications/things-disorder-dialog.js new file mode 100644 index 0000000..65024fe --- /dev/null +++ b/module/applications/things-disorder-dialog.js @@ -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; +} diff --git a/module/data/derived/actor-derived.js b/module/data/derived/actor-derived.js index 5e96382..bc24413 100644 --- a/module/data/derived/actor-derived.js +++ b/module/data/derived/actor-derived.js @@ -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} + */ +export async function finalizeAgentStartingResources( + actor, + veteranPath = "freshRecruit", +) { + if (actor.type !== "agent") return; + + const { healthMax, wpMax } = computeAgentResourceMaximums(actor); + const breakingPoint = computeAgentBreakingPoint(actor, veteranPath); + + /** @type {Record} */ + 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; diff --git a/module/profession/catalog.js b/module/profession/catalog.js index 0d2e1fe..21cb518 100644 --- a/module/profession/catalog.js +++ b/module/profession/catalog.js @@ -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} baseFixed * @param {Record} 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} */ @@ -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} */ 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} baseFixed + * @param {Record} baseTyped + * @param {{ bonusCatalogIds?: string[], bonusTypedLabels?: string[] }} formState + * @param {BonusPickOptions} [options] + * @returns {{ + * fixedValues: Record, + * typedValues: Record, + * 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} */ + const modifiedFixedKeys = new Set(); + /** @type {Set} */ + const modifiedTypedKeys = new Set(); + /** @type {Record} */ + 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, + }; +} diff --git a/module/profession/commit-character-creation.js b/module/profession/commit-character-creation.js new file mode 100644 index 0000000..93b66e1 --- /dev/null +++ b/module/profession/commit-character-creation.js @@ -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} fixedValues + * @property {Record} typedValues + * @property {string} professionName + * @property {CharacterCreationBond[]} bonds + */ + +/** + * @param {ReturnType} 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; +} diff --git a/module/profession/compute.js b/module/profession/compute.js index 639839b..84b56d0 100644 --- a/module/profession/compute.js +++ b/module/profession/compute.js @@ -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} */ - 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]), + ], }; } diff --git a/module/profession/constants.js b/module/profession/constants.js index 9825567..065d372 100644 --- a/module/profession/constants.js +++ b/module/profession/constants.js @@ -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} */ export const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]); export const SKILL_CAP = 80; diff --git a/module/profession/damaged-veteran-modifiers.js b/module/profession/damaged-veteran-modifiers.js new file mode 100644 index 0000000..e8195b6 --- /dev/null +++ b/module/profession/damaged-veteran-modifiers.js @@ -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} */ + 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, + }, + ); +} diff --git a/module/profession/disorders.js b/module/profession/disorders.js new file mode 100644 index 0000000..3d22d96 --- /dev/null +++ b/module/profession/disorders.js @@ -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} */ +const LABEL_KEY_BY_ID = new Map( + DISORDER_OPTIONS.map((entry) => [entry.id, entry.labelKey]), +); + +/** Legacy English display strings from earlier builds. */ +/** @type {Map} */ +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; +} diff --git a/module/profession/index.js b/module/profession/index.js index 2563e8c..642b37f 100644 --- a/module/profession/index.js +++ b/module/profession/index.js @@ -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 { diff --git a/module/profession/validation.js b/module/profession/validation.js index 8147372..b215dc8 100644 --- a/module/profession/validation.js +++ b/module/profession/validation.js @@ -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} */ const typedNameRequiredGroups = new Set(); - /** @type {string[]} */ - const bonusWasteTrackKeys = []; + /** @type {Set} */ + 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, }), ); } diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js index 80d0142..b089b69 100644 --- a/module/sheets/agent-sheet.js +++ b/module/sheets/agent-sheet.js @@ -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" }], }, }); diff --git a/module/sheets/base-actor-sheet.js b/module/sheets/base-actor-sheet.js index f895f58..27a355e 100644 --- a/module/sheets/base-actor-sheet.js +++ b/module/sheets/base-actor-sheet.js @@ -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; - } } diff --git a/module/sheets/base-item-sheet.js b/module/sheets/base-item-sheet.js index 55dee7f..3aafe2f 100644 --- a/module/sheets/base-item-sheet.js +++ b/module/sheets/base-item-sheet.js @@ -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; } diff --git a/module/utils/register-helpers.js b/module/utils/register-helpers.js index 807761d..4448418 100644 --- a/module/utils/register-helpers.js +++ b/module/utils/register-helpers.js @@ -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( diff --git a/scss/_mixins.scss b/scss/_mixins.scss index fd33e82..b0ea93e 100644 --- a/scss/_mixins.scss +++ b/scss/_mixins.scss @@ -5,10 +5,15 @@ } @mixin dg-window-form-inputs { - input[type="checkbox"]:checked::before { + input[type="checkbox"]:checked::before, + input[type="radio"]:checked::before { color: var(--dg-ink); } + label.checkbox > input[type="radio"] { + --checkbox-background-color: var(--dg-ink); + } + input[type="text"], input[type="number"], input[type="password"] { diff --git a/scss/deltagreen/_character-sheet.scss b/scss/deltagreen/_character-sheet.scss index 1fd4672..0f4f4f4 100644 --- a/scss/deltagreen/_character-sheet.scss +++ b/scss/deltagreen/_character-sheet.scss @@ -85,7 +85,6 @@ .deltagreen .tabs [data-action="tab"].active { text-shadow: none; - font-style: italic; border-color: var(--dg-ink); border-width: 2px 2px 0px 2px; border-style: solid; diff --git a/scss/dg-dialog/_dialogs.scss b/scss/dg-dialog/_dialogs.scss index 05f5531..966829b 100644 --- a/scss/dg-dialog/_dialogs.scss +++ b/scss/dg-dialog/_dialogs.scss @@ -385,11 +385,15 @@ overflow: hidden; } -.dg-dialog--character-creation - .dg-dialog__column--right - > .dg-dialog__messages { - flex: 0 0 auto; +.dg-dialog--character-creation .dg-dialog__column--right > .dg-dialog__messages, +.dg-dialog--hard-experience .dg-dialog__column--right > .dg-dialog__messages { + flex: 0 0 6rem; + height: 6rem; + min-height: 6rem; + max-height: 6rem; + overflow-y: auto; margin-top: 0.5rem; + scrollbar-color: #215112 #111 !important; } /* Character creation tabs — same equal-width overlap as agent sheet */ @@ -491,33 +495,44 @@ 2px 0 5px rgba(0, 0, 0, 0.14); } -.dg-dialog--character-creation .dg-dialog__tab-panels > .tab.active { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - .dg-dialog--character-creation .dg-dialog__tab-panels { - flex: 1; - min-height: 0; -} - -.dg-dialog--character-creation .dg-dialog__tab-panels > .tab { - display: none; - overflow-y: auto; - max-height: 28rem; -} - -.dg-dialog--character-creation .dg-dialog__tab-panels > .tab.active { - display: block; -} - -.dg-dialog--character-creation .character-creation-skills-section { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; +} + +.dg-dialog--character-creation .dg-dialog__tab-panels > .tab { + display: none; + overflow-y: auto; + min-height: 0; + scrollbar-color: #215112 #111 !important; +} + +.dg-dialog--character-creation .dg-dialog__tab-panels > .tab.active { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.dg-dialog--character-creation .character-creation-skill-totals { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; margin-bottom: 0.5rem; + scrollbar-color: #215112 #111 !important; +} + +.dg-dialog--character-creation .character-creation-skills-section { + display: flex; + flex-direction: column; + margin-bottom: 0; } .dg-dialog--character-creation .character-creation-skill-totals-header { @@ -559,10 +574,6 @@ display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.2rem 0.75rem; - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-color: #215112 #111 !important; } .dg-dialog--character-creation .dg-dialog__skill-entry { @@ -776,3 +787,97 @@ .dg-dialog--character-creation .character-creation-bond-field-score input { text-align: center; } + +/* Damaged Veterans */ +.dg-dialog-app.dg-dialog-app--damaged-veterans.application, +.dg-dialog-app.dg-dialog-app--things-disorder.application { + display: flex; + flex-direction: column; +} + +.dg-dialog--damaged-veterans .damaged-veterans-options, +.dg-dialog--things-disorder .things-disorder-options { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin: 0; + padding: 0; + border: 0; +} + +.dg-dialog--damaged-veterans label.checkbox.damaged-veterans-option, +.dg-dialog--things-disorder label.checkbox.things-disorder-option, +.dg-dialog--hard-experience label.checkbox.hard-experience-bond-option { + align-items: flex-start; + gap: 0.5rem; + font-size: inherit; + line-height: 1.4; +} + +.dg-dialog--damaged-veterans label.checkbox.damaged-veterans-option > input[type="radio"], +.dg-dialog--things-disorder label.checkbox.things-disorder-option > input[type="radio"], +.dg-dialog--hard-experience label.checkbox.hard-experience-bond-option > input[type="radio"] { + margin-top: 0.15rem; +} + +.dg-dialog--damaged-veterans .damaged-veterans-option-text strong { + font-weight: bold; +} + +/* Hard Experience — reuse character creation two-column layout */ +.dg-dialog-app.dg-dialog-app--hard-experience.application { + display: flex; + flex-direction: column; +} + +.dg-dialog-app.dg-dialog-app--hard-experience.application > .window-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--hard-experience .dialog-form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--hard-experience + .dialog-form + > .dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--hard-experience.dg-dialog { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--hard-experience .hard-experience-bonus-section { + margin-bottom: 1rem; +} + +.dg-dialog--hard-experience .hard-experience-bond-removal { + flex: 0 0 auto; +} + +.dg-dialog--hard-experience .hard-experience-bond-options { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin: 0; + padding: 0; + border: 0; +} diff --git a/templates/actor/parts/left-bar.html b/templates/actor/parts/left-bar.html index 5e9dd40..9b31ed4 100644 --- a/templates/actor/parts/left-bar.html +++ b/templates/actor/parts/left-bar.html @@ -117,7 +117,7 @@
- {{> "systems/deltagreen/templates/actor/partials/left-bar-rest-tab.html" }} {{> "systems/deltagreen/templates/actor/partials/left-bar-sanity-tab.html" }} + {{> "systems/deltagreen/templates/actor/partials/left-bar-rest-tab.html" }}
diff --git a/templates/actor/parts/motivations-tab-agent.html b/templates/actor/parts/motivations-tab-agent.html index 553f5a6..f53242c 100644 --- a/templates/actor/parts/motivations-tab-agent.html +++ b/templates/actor/parts/motivations-tab-agent.html @@ -37,7 +37,7 @@ - {{#if item.system.crossedOut }}{{item.system.disorder}}{{/if}} + {{#if item.system.crossedOut }}{{dgDisorderLabel item.system.disorder}}{{/if}} diff --git a/templates/dialog/add-profession.html b/templates/dialog/add-profession.html index b028c7e..de4ef0a 100644 --- a/templates/dialog/add-profession.html +++ b/templates/dialog/add-profession.html @@ -1,32 +1,34 @@
-
-

{{localize "DG.Profession.Dialog.SkillTotalsHeader"}}

-

{{localize "DG.Profession.Dialog.SkillTotalsHint"}}

-
-
-
-
- {{localize "DG.Profession.Dialog.Skill"}} - {{localize "DG.Profession.Dialog.Value"}} -
-
- {{localize "DG.Profession.Dialog.Skill"}} - {{localize "DG.Profession.Dialog.Value"}} -
-
-
- {{#each skillRows}} -
- {{label}} - {{value}}% +
+
+

{{localize "DG.Profession.Dialog.SkillTotalsHeader"}}

+

{{localize "DG.Profession.Dialog.SkillTotalsHint"}}

+
+
+
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}}
- {{/each}} -
-
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+
+ {{#each skillRows}} +
+ {{label}} + {{value}}% +
+ {{/each}} +
+
+
{{#if automaticChooseOneRows.length}}
diff --git a/templates/dialog/damaged-veterans.html b/templates/dialog/damaged-veterans.html new file mode 100644 index 0000000..e4dff3a --- /dev/null +++ b/templates/dialog/damaged-veterans.html @@ -0,0 +1,19 @@ +
+

{{localize "DG.DamagedVeterans.Dialog.Intro"}}

+
+ {{localize "DG.DamagedVeterans.Dialog.Title"}} + {{#each options}} + + {{/each}} +
+ +
diff --git a/templates/dialog/hard-experience.html b/templates/dialog/hard-experience.html new file mode 100644 index 0000000..b9fc9d8 --- /dev/null +++ b/templates/dialog/hard-experience.html @@ -0,0 +1,80 @@ +
+
+
+
+
+

{{localize "DG.Profession.Dialog.SkillTotalsHeader"}}

+

{{localize "DG.Profession.Dialog.SkillTotalsHint"}}

+
+
+
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+ {{localize "DG.Profession.Dialog.Skill"}} + {{localize "DG.Profession.Dialog.Value"}} +
+
+
+ {{#each skillRows}} +
+ {{label}} + {{value}}% +
+ {{/each}} +
+
+
+
+ +
+
+

{{localize "DG.HardExperience.Dialog.BonusIntro"}}

+
+ {{#each bonusSlots}} +
+ + +
+ {{/each}} +
+
+ +
+

{{localize "DG.HardExperience.Dialog.BondRemovalHeader"}}

+

{{localize "DG.HardExperience.Dialog.BondRemovalIntro"}}

+
+ {{#each bondRows}} + + {{/each}} +
+
+ +
+ {{#each validationMessages}} +

{{this}}

+ {{/each}} +
+
+
+
diff --git a/templates/dialog/things-disorder.html b/templates/dialog/things-disorder.html new file mode 100644 index 0000000..0a544ac --- /dev/null +++ b/templates/dialog/things-disorder.html @@ -0,0 +1,17 @@ +
+

{{localize "DG.DamagedVeterans.ThingsMan.Dialog.Intro"}}

+
+ {{localize "DG.DamagedVeterans.ThingsMan.Dialog.Title"}} + {{#each disorders}} + + {{/each}} +
+ +
diff --git a/templates/item/parts/motivation-header.html b/templates/item/parts/motivation-header.html index ef17516..faa89c5 100644 --- a/templates/item/parts/motivation-header.html +++ b/templates/item/parts/motivation-header.html @@ -19,11 +19,19 @@ title="{{localize 'DG.ItemWindow.Motivations.IsDisorderTooltip'}}" /> {{#if item.system.crossedOut }} {{localize 'DG.ItemWindow.Motivations.Disorder'}}: - + {{#if disorderUsesKnownId}} + {{disorderLabel}} + + {{else}} + + {{/if}} {{localize 'DG.ItemWindow.Motivations.AcuteEpisode'}}: