From 4d556b3b3b6bdaa5cbc9be995c5d4b4498251c48 Mon Sep 17 00:00:00 2001 From: Aaron Lutomski Date: Sun, 24 May 2026 14:11:51 -0700 Subject: [PATCH] Large scale refactor to bring system in line with modern Foundry system guidelines and improve maintainability. --- CHANGELOG.md | 6 + assets/img/green_delta_icon.webp | Bin 0 -> 51086 bytes css/deltagreen.css | 20 +- lang/ca.json | 2 + lang/de.json | 2 + lang/en.json | 1 - module/actor/actor.js | 290 ---- module/{sheets => applications}/edit-stats.js | 0 module/{other => chat}/inline.js | 0 module/config.js | 1 - module/config/rich-text-fields.js | 72 +- module/data/actor/agent.js | 40 +- module/data/actor/npc.js | 27 +- module/data/actor/unnatural.js | 26 +- module/data/actor/vehicle.js | 6 + module/deltagreen.js | 35 +- module/item/item-sheet.js | 1 - module/item/item.js | 5 - module/{other => macros}/macro-functions.js | 0 module/{other => macros}/stat-parser-macro.js | 0 module/other/register-helpers.js | 264 ---- module/roll/roll-classes.js | 669 +++++++++ module/roll/roll-dialogs.js | 177 +++ module/roll/roll.js | 870 +----------- module/roll/skill-improvement-roll.js | 106 ++ module/settings.js | 48 +- module/sheets/agent-sheet-v2.js | 283 ---- module/sheets/agent-sheet.js | 333 +++-- module/sheets/base-actor-sheet.js | 1249 +---------------- module/sheets/base-item-sheet.js | 14 - module/sheets/base-sheet.js | 44 +- module/sheets/mixins/compose-mixins.js | 7 + module/sheets/mixins/item-list-mixin.js | 239 ++++ module/sheets/mixins/roll-sheet-mixin.js | 74 + module/sheets/mixins/skill-prep-mixin.js | 281 ++++ .../sheets/mixins/special-training-mixin.js | 157 +++ module/sheets/mixins/typed-skill-mixin.js | 276 ++++ module/sheets/unnatural-sheet.js | 15 +- module/utils/actor-submit.js | 16 + module/utils/derived-actor-data.js | 138 ++ module/utils/register-helpers.js | 131 ++ module/{sheets => utils}/rich-text.js | 0 module/utils/skill-layout.js | 126 ++ module/{other => utils}/utility-functions.js | 0 module/utils/world-migration.js | 58 + ....html => custom-skills-partial-agent.html} | 45 +- .../actor/partials/custom-skills-partial.html | 24 +- templates/actor/parts/bio-tab-v2.html | 98 -- templates/actor/parts/bio-tab.html | 98 -- templates/actor/parts/bonds-tab.html | 73 - ...mbat-tab-v2.html => combat-tab-agent.html} | 0 .../{gear-tab-v2.html => gear-tab-agent.html} | 0 templates/actor/parts/left-bar.html | 4 + ...tab-v2.html => motivations-tab-agent.html} | 0 templates/actor/parts/motivations-tab.html | 149 -- ...al-tab-v2.html => personal-tab-agent.html} | 0 templates/actor/parts/physical-tab-v2.html | 59 - templates/actor/parts/physical-tab.html | 61 - templates/actor/parts/right-bar.html | 10 +- ...ills-tab-v2.html => skills-tab-agent.html} | 6 +- templates/actor/parts/skills-tab.html | 4 +- templates/dialog/modify-percentile-roll.html | 4 +- templates/roll/lethality-roll.hbs | 30 + templates/roll/sanity-damage-roll.hbs | 29 + 64 files changed, 2985 insertions(+), 3818 deletions(-) create mode 100644 assets/img/green_delta_icon.webp rename module/{sheets => applications}/edit-stats.js (100%) rename module/{other => chat}/inline.js (100%) delete mode 100644 module/item/item-sheet.js rename module/{other => macros}/macro-functions.js (100%) rename module/{other => macros}/stat-parser-macro.js (100%) delete mode 100644 module/other/register-helpers.js create mode 100644 module/roll/roll-classes.js create mode 100644 module/roll/roll-dialogs.js create mode 100644 module/roll/skill-improvement-roll.js delete mode 100644 module/sheets/agent-sheet-v2.js create mode 100644 module/sheets/mixins/compose-mixins.js create mode 100644 module/sheets/mixins/item-list-mixin.js create mode 100644 module/sheets/mixins/roll-sheet-mixin.js create mode 100644 module/sheets/mixins/skill-prep-mixin.js create mode 100644 module/sheets/mixins/special-training-mixin.js create mode 100644 module/sheets/mixins/typed-skill-mixin.js create mode 100644 module/utils/actor-submit.js create mode 100644 module/utils/derived-actor-data.js create mode 100644 module/utils/register-helpers.js rename module/{sheets => utils}/rich-text.js (100%) create mode 100644 module/utils/skill-layout.js rename module/{other => utils}/utility-functions.js (100%) create mode 100644 module/utils/world-migration.js rename templates/actor/partials/{custom-skills-partial-v2.html => custom-skills-partial-agent.html} (73%) delete mode 100644 templates/actor/parts/bio-tab-v2.html delete mode 100644 templates/actor/parts/bio-tab.html delete mode 100644 templates/actor/parts/bonds-tab.html rename templates/actor/parts/{combat-tab-v2.html => combat-tab-agent.html} (100%) rename templates/actor/parts/{gear-tab-v2.html => gear-tab-agent.html} (100%) rename templates/actor/parts/{motivations-tab-v2.html => motivations-tab-agent.html} (100%) delete mode 100644 templates/actor/parts/motivations-tab.html rename templates/actor/parts/{personal-tab-v2.html => personal-tab-agent.html} (100%) delete mode 100644 templates/actor/parts/physical-tab-v2.html delete mode 100644 templates/actor/parts/physical-tab.html rename templates/actor/parts/{skills-tab-v2.html => skills-tab-agent.html} (86%) create mode 100644 templates/roll/lethality-roll.hbs create mode 100644 templates/roll/sanity-damage-roll.hbs diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a089d..ec4d744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,17 @@ - [#374](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/374) - Moving Age to Bio tab - [#393](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/393) - Updated to support Foundry v14 - [#223](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/223) - Migrated item sheets to Foundry ApplicationV2 (`ItemSheetV2`) +key ### **Bug Fixes:** - [#392](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/392) - Fixed sheet rolls - [#394](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/394) - Unified actor and item description fields on schema with shared ProseMirror preparation +### **Under the Hood:** +- [#323](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/323) - Refactored roll logic and split into util functions +- Agent sheet refactor: modular actor sheet mixins, roll module split, skill improvement roll helpers, and consolidated utilities under `module/utils/` +- Removed obsolete world settings + ## Version 1.6.6 - 2026-04-15 ### **Bug Fixes:** diff --git a/assets/img/green_delta_icon.webp b/assets/img/green_delta_icon.webp new file mode 100644 index 0000000000000000000000000000000000000000..eaaa4799a70aa4e8e6c8029ad221282e398bb2ed GIT binary patch literal 51086 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 100) { - system.sanity.value = system.statistics.pow.x5; - system.sanity.currentBreakingPoint = - system.sanity.value - system.statistics.pow.value; - } - - system.sanity.max = 99 - system.skills.unnatural.proficiency; - - system.wp.max = system.statistics.pow.value; - - try { - system.health.max = Math.ceil( - (system.statistics.con.value + system.statistics.str.value) / 2, - ); - } catch { - system.health.max = 10; - } - - const strength = system.statistics.str; - system.statistics.str.meleeDamageBonusFormula = - this._calculateMeleeDamageBonusFormula(strength.value); - - try { - delete system.skills.ritual; // try to remove legacy skill for ritual if it exists - } catch { - // do nothing - } - - system.sanity.ritual = 99 - system.sanity.value; - - if (system.sanity.ritual > 99) { - system.sanity.ritual = 99; - } else if (system.sanity.ritual < 1) { - system.sanity.ritual = 1; - } - - // calculate total armor rating - let protection = 0; - for (const i of actor.items) { - if (i.type === "armor") { - if (i.system.equipped === true) { - protection += i.system.protection; - } - } - } - - system.health.protection = protection; - - for (const skill of Object.values(system.skills)) { - skill.targetProficiency = skill.proficiency; - } - } - - /** - * - * @param {*} agent - */ - _prepareUnnaturalData(actor) { - const { system } = actor; - - // Loop through ability scores, and add their modifiers to our sheet output. - for (const statistic of Object.values(system.statistics)) { - // the x5 is just whatever the raw statistic is x 5 to turn it into a d100 percentile - statistic.x5 = statistic.value * 5; - } - - if (system.wp.maxNeedsUpdate) { - system.wp.max = system.statistics.pow.value; - } - - if (system.health.maxNeedsUpdate) { - try { - system.health.max = Math.ceil( - (system.statistics.con.value + system.statistics.str.value) / 2, - ); - } catch { - system.health.max = 10; - } - } - - // calculate total armor rating - let protection = 0; - for (const i of actor.items) { - if (i.type === "armor") { - if (i.system.equipped === true) { - protection += i.system.protection; - } - } - } - - system.health.protection = protection; - - for (const skill of Object.values(system.skills)) { - skill.targetProficiency = skill.proficiency; - } - } - - /** - * Prepare Agent type specific data - */ - _prepareAgentData(agent) { - const { system } = agent; - - // Make modifications to data here. For example: - - // Loop through ability scores, and add their modifiers to our sheet output. - for (const statistic of Object.values(system.statistics)) { - // the x5 is just whatever the raw statistic is x 5 to turn it into a d100 percentile - statistic.x5 = statistic.value * 5; - } - - try { - delete system.skills.ritual; // try to remove legacy skill for ritual if it exists - } catch { - // do nothing - } - - system.sanity.ritual = 99 - system.sanity.value; - - if (system.sanity.ritual > 99) { - system.sanity.ritual = 99; - } else if (system.sanity.ritual < 1) { - system.sanity.ritual = 1; - } - - // The unnatural skill is sort of special - // It cannot be improved via failure, so add in a special property to reflect this - // Mostly to make it easy to deactivate the failure checkbox in the GUI - for (const [key, skill] of Object.entries(system.skills)) { - if (key === "unnatural") { - skill.cannotBeImprovedByFailure = true; - } else if (key === "luck") { - skill.cannotBeImprovedByFailure = true; - } else if (key === "ritual") { - skill.cannotBeImprovedByFailure = true; - } else { - skill.cannotBeImprovedByFailure = false; - } - - // For ritual skill, it's calculated, so add some logic to turn off changing that entirely. - if (key === "ritual") { - skill.isCalculatedValue = true; - } else { - skill.isCalculatedValue = false; - } - - // Add field for calculation - skill.targetProficiency = skill.proficiency; - } - - system.wp.max = system.statistics.pow.value; - - system.health.max = Math.ceil( - (system.statistics.con.value + system.statistics.str.value) / 2, - ); - - // initialize sanity, don't set these afterwards, as they need to be manually edited - if (system.sanity.value >= 100) { - system.sanity.value = system.statistics.pow.x5; - system.sanity.currentBreakingPoint = - system.sanity.value - system.statistics.pow.value; - } - - system.sanity.max = 99 - system.skills.unnatural.proficiency; - - // Sanity Loss Adaptations Logic - const { adaptations } = system.sanity; - - if ( - adaptations.violence.incident1 && - adaptations.violence.incident2 && - adaptations.violence.incident3 - ) { - system.sanity.adaptations.violence.isAdapted = true; - } else { - system.sanity.adaptations.violence.isAdapted = false; - } - - if ( - adaptations.helplessness.incident1 && - adaptations.helplessness.incident2 && - adaptations.helplessness.incident3 - ) { - system.sanity.adaptations.helplessness.isAdapted = true; - } else { - system.sanity.adaptations.helplessness.isAdapted = false; - } - - if (system.sanity.value <= system.sanity.currentBreakingPoint) { - system.sanity.breakingPointHit = true; - } else { - system.sanity.breakingPointHit = false; - } - - // calculate total armor rating - let protection = 0; - for (const i of agent.items) { - if (i.type === "armor") { - if (i.system.equipped === true) { - protection += i.system.protection; - } - } - } - - system.health.protection = protection; - - const strength = system.statistics.str; - system.statistics.str.meleeDamageBonusFormula = - this._calculateMeleeDamageBonusFormula(strength.value); - - if (system.physical.exhaustedPenalty > 0) { - system.physical.exhaustedPenalty = - -1 * Math.abs(system.physical.exhaustedPenalty); - } - } - - // Returns Bonus From Strength in Hand-to-hand Combat (melee/unarmed) - _calculateMeleeDamageBonusFormula(strengthValue) { - if (strengthValue < 5) { - return "-2"; - } - if (strengthValue < 9) { - return "-1"; - } - if (strengthValue > 12 && strengthValue < 17) { - return "+1"; - } - if (strengthValue > 16) { - return "+2"; - } - return ""; - } - /** @override */ static async create(data, options = {}) { data.prototypeToken = data.prototypeToken || {}; diff --git a/module/sheets/edit-stats.js b/module/applications/edit-stats.js similarity index 100% rename from module/sheets/edit-stats.js rename to module/applications/edit-stats.js diff --git a/module/other/inline.js b/module/chat/inline.js similarity index 100% rename from module/other/inline.js rename to module/chat/inline.js diff --git a/module/config.js b/module/config.js index 7d0c7a4..fe4ec10 100644 --- a/module/config.js +++ b/module/config.js @@ -47,7 +47,6 @@ const DG = /** @type {const} */ ({ "swim", "unarmed_combat", "unnatural", - "ritual", ], skillImprovementFormulas: { diff --git a/module/config/rich-text-fields.js b/module/config/rich-text-fields.js index 91d5d36..eff831a 100644 --- a/module/config/rich-text-fields.js +++ b/module/config/rich-text-fields.js @@ -1,33 +1,61 @@ /** - * Rich-text field specs for actor sheets (by actor type). - * @type {Record} + * Rich-text field specs keyed by actor sheet part id. + * @type {Record>} */ -export const ACTOR_RICH_TEXT_BY_TYPE = { - agent: [{ path: "physical.description", key: "description" }], - npc: [{ path: "notes", key: "description" }], - unnatural: [{ path: "notes", key: "description" }], - vehicle: [{ path: "description", key: "description" }], +const ACTOR_RICH_TEXT_BY_PART = { + agent: { + rightBar: [{ path: "physical.description", key: "description" }], + }, + npc: { + skills: [{ path: "notes", key: "description" }], + }, + unnatural: { + skills: [{ path: "notes", key: "description" }], + }, + vehicle: { + notes: [{ path: "description", key: "description" }], + }, }; /** - * Rich-text field specs for all item types. - * @type {{ path: string, key: string, types?: string[] }[]} + * Rich-text field specs keyed by item sheet part id. + * @type {Record} */ -export const ITEM_RICH_TEXT_FIELDS = [ - { path: "description", key: "description" }, - { - path: "handlerNotes", - key: "handlerNotes", - types: ["tome", "ritual"], - }, -]; +const ITEM_RICH_TEXT_BY_PART = { + description: [{ path: "description", key: "description" }], + handler: [ + { + path: "handlerNotes", + key: "handlerNotes", + types: ["tome", "ritual"], + }, + ], +}; /** - * @param {string} itemType + * @param {"Actor"|"Item"} documentName + * @param {string} partId + * @param {object} [options] + * @param {string} [options.actorType] + * @param {string} [options.itemType] + * @param {boolean} [options.showNotesInSkills] * @returns {{ path: string, key: string }[]} */ -export function getItemRichTextFields(itemType) { - return ITEM_RICH_TEXT_FIELDS.filter( - (spec) => !spec.types || spec.types.includes(itemType), - ); +export function getRichTextFieldsForPart( + documentName, + partId, + { actorType, itemType, showNotesInSkills = true } = {}, +) { + if (documentName === "Actor") { + if (partId === "skills" && !showNotesInSkills) return []; + return ACTOR_RICH_TEXT_BY_PART[actorType]?.[partId] ?? []; + } + + if (documentName === "Item") { + return (ITEM_RICH_TEXT_BY_PART[partId] ?? []).filter( + (spec) => !spec.types || spec.types.includes(itemType), + ); + } + + return []; } diff --git a/module/data/actor/agent.js b/module/data/actor/agent.js index 227f6f4..531bf43 100644 --- a/module/data/actor/agent.js +++ b/module/data/actor/agent.js @@ -1,6 +1,18 @@ import HumanSkillsActorData from "./base/human-skills.js"; import CharacterData from "./base/character.js"; import DGHTMLField from "../fields/html-content-field.js"; +import { + calculateHealthMax, + calculateMeleeDamageBonusFormula, + clampSanityRitualValue, + computeEquippedArmorProtection, + initializeSanityIfUnset, + prepareAgentSkillFlags, + prepareBreakingPointHit, + prepareSanityAdaptations, + prepareStatisticsX5, + removeLegacyRitualSkill, +} from "../../utils/derived-actor-data.js"; const { SchemaField, NumberField, StringField, BooleanField, HTMLField } = foundry.data.fields; @@ -12,7 +24,7 @@ export default class AgentData extends CharacterData { return { ...superData, ...HumanSkillsActorData.defineSchema(), - schemaVersion: new NumberField({ initial: 1.0 }), + schemaVersion: new NumberField({ initial: 2.0 }), sanity: new SchemaField({ value: new NumberField({ initial: 100 }), currentBreakingPoint: new NumberField({ initial: 101 }), @@ -52,4 +64,30 @@ export default class AgentData extends CharacterData { }), }; } + + /** @inheritdoc */ + prepareDerivedData() { + prepareStatisticsX5(this.statistics); + removeLegacyRitualSkill(this); + clampSanityRitualValue(this.sanity); + prepareAgentSkillFlags(this.skills); + + this.wp.max = this.statistics.pow.value; + this.health.max = calculateHealthMax(this.statistics); + initializeSanityIfUnset(this.sanity, this.statistics); + this.sanity.max = 99 - this.skills.unnatural.proficiency; + prepareSanityAdaptations(this.sanity.adaptations); + prepareBreakingPointHit(this.sanity); + + this.health.protection = computeEquippedArmorProtection( + this.parent.items, + ); + this.statistics.str.meleeDamageBonusFormula = + calculateMeleeDamageBonusFormula(this.statistics.str.value); + + if (this.physical.exhaustedPenalty > 0) { + this.physical.exhaustedPenalty = + -1 * Math.abs(this.physical.exhaustedPenalty); + } + } } diff --git a/module/data/actor/npc.js b/module/data/actor/npc.js index 403af85..70b7713 100644 --- a/module/data/actor/npc.js +++ b/module/data/actor/npc.js @@ -1,6 +1,16 @@ import HumanSkillsActorData from "./base/human-skills.js"; import CharacterData from "./base/character.js"; import DGHTMLField from "../fields/html-content-field.js"; +import { + calculateHealthMax, + calculateMeleeDamageBonusFormula, + clampSanityRitualValue, + computeEquippedArmorProtection, + initializeSanityIfUnset, + prepareStatisticsX5, + removeLegacyRitualSkill, + setSkillTargetProficiencies, +} from "../../utils/derived-actor-data.js"; const { SchemaField, NumberField, StringField, BooleanField, HTMLField } = foundry.data.fields; @@ -12,7 +22,7 @@ export default class NPCData extends CharacterData { return { ...superData, ...HumanSkillsActorData.defineSchema(), - schemaVersion: new NumberField({ initial: 1.0 }), + schemaVersion: new NumberField({ initial: 2.0 }), sanity: new SchemaField({ value: new NumberField({ initial: 100 }), currentBreakingPoint: new NumberField({ initial: 101 }), @@ -25,4 +35,19 @@ export default class NPCData extends CharacterData { showUntrainedSkills: new BooleanField({ initial: true }), }; } + + /** @inheritdoc */ + prepareDerivedData() { + prepareStatisticsX5(this.statistics); + initializeSanityIfUnset(this.sanity, this.statistics); + this.sanity.max = 99 - this.skills.unnatural.proficiency; + this.wp.max = this.statistics.pow.value; + this.health.max = calculateHealthMax(this.statistics); + this.statistics.str.meleeDamageBonusFormula = + calculateMeleeDamageBonusFormula(this.statistics.str.value); + removeLegacyRitualSkill(this); + clampSanityRitualValue(this.sanity); + this.health.protection = computeEquippedArmorProtection(this.parent.items); + setSkillTargetProficiencies(this.skills); + } } diff --git a/module/data/actor/unnatural.js b/module/data/actor/unnatural.js index 001b28e..75571f2 100644 --- a/module/data/actor/unnatural.js +++ b/module/data/actor/unnatural.js @@ -1,6 +1,13 @@ import UnnaturalSkillsActorData from "./base/unnatural-skills.js"; import CharacterData from "./base/character.js"; import DGHTMLField from "../fields/html-content-field.js"; +import { + calculateHealthMax, + computeEquippedArmorProtection, + prepareStatisticsX5, + removeLegacyRitualSkill, + setSkillTargetProficiencies, +} from "../../utils/derived-actor-data.js"; const { SchemaField, NumberField, StringField, BooleanField, HTMLField } = foundry.data.fields; @@ -17,10 +24,27 @@ export default class UnnaturalData extends CharacterData { failedLoss: new StringField({ initial: "1D4" }), successLoss: new StringField({ initial: "1" }), }), - schemaVersion: new NumberField({ initial: 1.0 }), + schemaVersion: new NumberField({ initial: 2.0 }), notes: DGHTMLField(), shortDescription: new StringField({ initial: "" }), showUntrainedSkills: new BooleanField({ initial: true }), }; } + + /** @inheritdoc */ + prepareDerivedData() { + prepareStatisticsX5(this.statistics); + + if (this.wp.maxNeedsUpdate) { + this.wp.max = this.statistics.pow.value; + } + + if (this.health.maxNeedsUpdate) { + this.health.max = calculateHealthMax(this.statistics); + } + + removeLegacyRitualSkill(this); + this.health.protection = computeEquippedArmorProtection(this.parent.items); + setSkillTargetProficiencies(this.skills); + } } diff --git a/module/data/actor/vehicle.js b/module/data/actor/vehicle.js index 78b083e..6703686 100644 --- a/module/data/actor/vehicle.js +++ b/module/data/actor/vehicle.js @@ -1,5 +1,6 @@ import SheetSettingsActorData from "./base/sheet-settings.js"; import DGHTMLField from "../fields/html-content-field.js"; +import { computeEquippedArmorProtection } from "../../utils/derived-actor-data.js"; const { SchemaField, NumberField, StringField, ArrayField, HTMLField } = foundry.data.fields; @@ -21,4 +22,9 @@ export default class VehicleData extends foundry.abstract.TypeDataModel { passengers: new ArrayField(new StringField()), }; } + + /** @inheritdoc */ + prepareDerivedData() { + this.health.protection = computeEquippedArmorProtection(this.parent.items); + } } diff --git a/module/deltagreen.js b/module/deltagreen.js index 3ff6720..7077398 100644 --- a/module/deltagreen.js +++ b/module/deltagreen.js @@ -4,23 +4,29 @@ import DeltaGreenActor from "./actor/actor.js"; import DGAgentSheet from "./sheets/agent-sheet.js"; import DeltaGreenItem from "./item/item.js"; import DGItemSheet from "./sheets/base-item-sheet.js"; -import * as DGRolls from "./roll/roll.js"; +import { + DGRoll, + DGPercentileRoll, + DGLethalityRoll, + DGDamageRoll, + DGSanityDamageRoll, +} from "./roll/roll-classes.js"; import registerSystemSettings from "./settings.js"; import preloadHandlebarsTemplates from "./templates.js"; -import registerHandlebarsHelpers from "./other/register-helpers.js"; -import ParseDeltaGreenStatBlock from "./other/stat-parser-macro.js"; +import registerHandlebarsHelpers from "./utils/register-helpers.js"; +import ParseDeltaGreenStatBlock from "./macros/stat-parser-macro.js"; import { createDeltaGreenMacro, rollItemMacro, rollItemSkillCheckMacro, rollSkillMacro, rollSkillTestAndDamageForOwnedItem, -} from "./other/macro-functions.js"; -import { handleInlineActions } from "./other/inline.js"; +} from "./macros/macro-functions.js"; +import { handleInlineActions } from "./chat/inline.js"; +import { runWorldMigration } from "./utils/world-migration.js"; import DGNPCSheet from "./sheets/npc-sheet.js"; import DGUnnaturalSheet from "./sheets/unnatural-sheet.js"; import DGVehicleSheet from "./sheets/vehicle-sheet.js"; -import DGAgentSheetV2 from "./sheets/agent-sheet-v2.js"; import AgentData from "./data/actor/agent.js"; import UnnaturalData from "./data/actor/unnatural.js"; import NPCData from "./data/actor/npc.js"; @@ -74,7 +80,13 @@ Hooks.once("init", async () => { }; // Register custom dice rolls - Object.values(DGRolls).forEach((cls) => CONFIG.Dice.rolls.push(cls)); + [ + DGRoll, + DGPercentileRoll, + DGLethalityRoll, + DGDamageRoll, + DGSanityDamageRoll, + ].forEach((cls) => CONFIG.Dice.rolls.push(cls)); // Register System Settings registerSystemSettings(); @@ -103,13 +115,6 @@ Hooks.once("init", async () => { }); }); - Actors.registerSheet(DG.ID, DGAgentSheetV2, { - makeDefault: false, - themes: null, - label: "DG.Sheet.class.agentV2", - types: ["agent"], - }); - // Register item sheet. Items.registerSheet(DG.ID, DGItemSheet, { makeDefault: true, @@ -125,6 +130,8 @@ Hooks.once("init", async () => { }); Hooks.once("ready", async () => { + await runWorldMigration(); + // Wait to register hotbar drop hook on ready so that modules could register earlier if they want to // TODO: Fix eslint issue on next line // eslint-disable-next-line consistent-return diff --git a/module/item/item-sheet.js b/module/item/item-sheet.js deleted file mode 100644 index 9ef2bf7..0000000 --- a/module/item/item-sheet.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../sheets/base-item-sheet.js"; diff --git a/module/item/item.js b/module/item/item.js index 1beafa7..96aa820 100644 --- a/module/item/item.js +++ b/module/item/item.js @@ -10,11 +10,6 @@ export default class DeltaGreenItem extends Item { */ prepareData() { super.prepareData(); - - // Get the Item's data - const itemData = this; - const actorData = this.actor || {}; - const { system } = itemData; } /** diff --git a/module/other/macro-functions.js b/module/macros/macro-functions.js similarity index 100% rename from module/other/macro-functions.js rename to module/macros/macro-functions.js diff --git a/module/other/stat-parser-macro.js b/module/macros/stat-parser-macro.js similarity index 100% rename from module/other/stat-parser-macro.js rename to module/macros/stat-parser-macro.js diff --git a/module/other/register-helpers.js b/module/other/register-helpers.js deleted file mode 100644 index 3938d26..0000000 --- a/module/other/register-helpers.js +++ /dev/null @@ -1,264 +0,0 @@ -import DGUtils from "./utility-functions.js"; - -export default function registerHandlebarsHelpers() { - // Add Handlebars helpers - Handlebars.registerHelper("localizeWithFallback", (value, fallbackValue) => { - return DGUtils.localizeWithFallback(value, fallbackValue); - }); - - Handlebars.registerHelper("concat", (...args) => { - let outStr = ""; - for (const arg in args) { - if (typeof args[arg] !== "object") { - outStr += args[arg]; - } - } - return outStr; - }); - - Handlebars.registerHelper("toLowerCase", (str) => { - try { - return str.toLowerCase(); - } catch (error) { - return ""; - } - }); - - Handlebars.registerHelper("toUpperCase", (str) => { - try { - return str.toUpperCase(); - } catch (error) { - return ""; - } - }); - - Handlebars.registerHelper("if_eq", (a, b, opts) => { - if (a === b) { - return opts.fn(this); - } - return opts.inverse(this); - }); - - Handlebars.registerHelper("if_not_eq", (a, b, opts) => { - if (a !== b) { - return opts.fn(this); - } - return opts.inverse(this); - }); - - Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => { - if (a > b) { - return trueVal; - } - return falseVal; - }); - - Handlebars.registerHelper("cite_ahb", (page) => { - return `See page ${page} of the Agent's Handbook.`; - }); - - Handlebars.registerHelper("formatLethality", (lethality) => { - if (lethality > 0) { - return `${lethality.toString()}%`; - } - return ""; - }); - - // Is this used anywhere? - Handlebars.registerHelper( - "getActorSkillProp", - (actorData, skillName, prop) => { - try { - if (skillName !== "" && prop !== "") { - const { skills } = actorData.data; - const skill = skills[skillName]; - const propVal = skill[prop]; - return propVal; - } - return ""; - } catch (ex) { - console.log(ex); - return ""; - } - }, - ); - - /** Roll visibility modes for dialog selects (v14+ CONFIG.ChatMessage.modes keys). */ - const ROLL_MESSAGE_MODE_KEYS = ["public", "gm", "blind", "self"]; - - Handlebars.registerHelper("getAvailableRollModes", () => { - try { - return Object.fromEntries( - ROLL_MESSAGE_MODE_KEYS.filter((key) => key in CONFIG.ChatMessage.modes).map( - (key) => [key, CONFIG.ChatMessage.modes[key]], - ), - ); - } catch (error) { - return console.log(error); - } - }); - - Handlebars.registerHelper("getDefaultRollMode", () => { - try { - return game.settings.get("core", "messageMode"); - } catch (error) { - return console.log(error); - } - }); - - Handlebars.registerHelper( - "calculateHandToHandCombatDamageFormulaBonus", - (strength) => { - try { - let bonus = ""; - - if (strength.value < 5) { - bonus = " - 2"; - } else if (strength.value < 9) { - bonus = " - 1"; - } else if (strength.value > 12 && strength < 17) { - bonus = " + 1"; - } else if (strength.value > 16) { - bonus = " + 2"; - } - - return bonus; - } catch (error) { - return console.log(error); - } - }, - ); - - Handlebars.registerHelper("localizeWeaponSkill", (skill) => { - let label = skill; - - try { - if (skill === "dex") { - label = game.i18n.localize("DG.Attributes.dex"); - } - if (skill === "DG.Skills.custom") { - label = game.i18n.localize("DG.ItemWindow.Custom"); - } else { - label = game.i18n.localize(`DG.Skills.${skill}`); - } - } catch (error) { - console.log(error); - } - - return label; - }); - - Handlebars.registerHelper( - "hideSkillBasedOnProficiencyAndUserChoice", - (hideUntrainedSkills, proficiency) => { - let showValue = true; - - try { - if (hideUntrainedSkills === false) { - showValue = true; - } else if (proficiency > 0) { - showValue = true; - } else { - showValue = false; - } - } catch (error) { - console.log(error); - } - - return showValue; - }, - ); - - // looks at system setting for what font to use and returns the class that is then used in the handlebars template that - // generates the character sheet. - Handlebars.registerHelper("getFontFamilySystemSettingClass", () => { - const setting = game.settings.get("deltagreen", "characterSheetFont"); - - const characterSheetStyle = game.settings.get( - "deltagreen", - "characterSheetStyle", - ); - - /* - if(setting === "TypeWriterCondensed"){ - return "typewriter-condensed-font"; - } - else if(setting === "Martel"){ - return "martel-font"; - } - else if(setting === "Signika"){ - return "signika-font"; - } - else if(setting === "atwriter"){ - return "atwriter-font"; - } - else if(setting === "SpecialElite"){ - return "special-elite-font"; - } - else if(setting === "PublicSans"){ - return "public-sans-font"; - } - else{ - return "martel-font"; - } - */ - - /* - if(characterSheetStyle === "cowboy"){ - return "special-elite-font"; - } - else{ - return "public-sans-font"; - } - */ - }); - - Handlebars.registerHelper("getCharacterSheetStyle", () => { - const characterSheetStyle = game.settings.get( - "deltagreen", - "characterSheetStyle", - ); - - if (characterSheetStyle === "cowboy") { - return "cowboy-style"; - } - if (characterSheetStyle === "outlaw") { - return "outlaw-style"; - } - return "program-style"; - }); - - Handlebars.registerHelper("keepSanityPrivate", () => { - let setting = false; - - try { - setting = game.settings.get("deltagreen", "keepSanityPrivate"); - - if (game.user.isGM) { - setting = false; - } - } catch (ex) { - setting = false; - } - - return setting; - }); - - Handlebars.registerHelper("playerHasGamemasterPrivileges", () => { - return game.user.isGM; - }); - - Handlebars.registerHelper("showImpossibleLandscapesContent", () => { - let result = false; - const setting = game.settings.get( - "deltagreen", - "showImpossibleLandscapesContent", - ); - - if (game.user.isGM === true && setting === true) { - result = true; - } - - return result; - }); -} diff --git a/module/roll/roll-classes.js b/module/roll/roll-classes.js new file mode 100644 index 0000000..92248d6 --- /dev/null +++ b/module/roll/roll-classes.js @@ -0,0 +1,669 @@ +/* eslint-disable max-classes-per-file */ +import DGUtils from "../utils/utility-functions.js"; +import DG from "../config.js"; +import { + showDamageRollModifyDialog, + showPercentileRollModifyDialog, +} from "./roll-dialogs.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +export class DGRoll extends Roll { + /** + * NOTE: This class will rarely be called on its own. It should generally be extended. Look to DGPercentileRoll as an example. + * + * Customize our roll with some useful information, passed in the `options` Object. + * + * @param {string} formula Unused - The string formula to parse (from Foundry) + * @param {Object} data Unused - The data object against which to parse attributes within the formula + * @param {Object} [options] Additional data which is preserved in the database + * @param {Number} [options.rollType] The type of roll (stat, skill, sanity, damage, etc). + * @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll. + * @param {DeltaGreenActor} [options.actor] The actor that this roll originates from. + * @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates. + */ + constructor(formula, data = {}, options = {}) { + super(formula, data, options); + const { rollType, key, actor, item } = options; + this.type = rollType; + this.key = key; + this.actor = actor; + this.item = item; + this.modifier = 0; + } + + /** + * Simple function that actually creates the message and sends it to chat. + * We override this to have a little more control over certain aspects of the message, + * right now, its `speaker` and `messageMode`. + * + * @override + * The following `@param` descriptions comes from the Foundry VTT code. + * @param {object} messageData The data object to use when creating the message + * @param {options} [options] Additional options which modify the created message. + * @param {string} [options.messageMode] A key of CONFIG.ChatMessage.modes + * @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the + * prepared chatData object. + * @returns {Promise} A promise which resolves to the created ChatMessage document if create is + * true, or the Object of prepared chatData otherwise. + */ + async toMessage(messageData = {}, { messageMode, create = true } = {}) { + // eslint-disable-next-line no-param-reassign + messageData.speaker = ChatMessage.getSpeaker({ actor: this.actor }); + const mode = + messageMode ?? + this.options.messageMode ?? + this.options.rollMode; + const opts = { create }; + if (mode) { + opts.messageMode = foundry.dice.Roll._mapLegacyRollMode(mode); + } + return super.toMessage(messageData, opts); + } +} + +export class DGPercentileRoll extends DGRoll { + /** + * Creates D100 rolls, the base die of the system. + * + * This constructor embeds the following info into the roll: + * 1. Target number that the roll needs to beat. + * 2. Localized name for the roll. + * + * Note: In order for all of our custom data to persist, our constructor must use the same parameters as its parent class. + * So, even though percentile rolls will always have a formula of "1d100" and we don't use the `data` object, + * we still have to keep them as parameters. + * + * @param {string} formula Unused - The string formula to parse (from Foundry) - Always "1d100" for percentile rolls. + * @param {Object} data Unused - The data object against which to parse attributes within the formula + * @param {Object} [options] Additional data which is preserved in the database + * @param {Number} [options.rollType] The type of roll (stat, skill, sanity, etc). + * @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll. + * @param {DeltaGreenActor} [options.actor] The actor that this roll originates from. + * @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates. + * @param {DeltaGreenItem} [options.specialTrainingName] Optional - Special training rolls have names that are different from the roll key. + */ + // eslint-disable-next-line default-param-last, no-unused-vars + constructor(formula = "1D100", data = {}, options) { + super("1D100", {}, options); + + // Set roll info for Skill, Stat, Typed Skill, and non-custom Weapon Percentile rolls. + const { target, localizedKey, skillPath } = this.getRollInfoFromKey( + this.key, + this.actor.system, + ); + this.target = target; + this.localizedKey = localizedKey; + this.skillPath = skillPath; + + // Set roll info for other Percentile rolls + switch (this.type) { + case "special-training": + this.specialTrainingName = options.specialTrainingName; + this.localizedKey = `${this.specialTrainingName} - (${this.localizedKey})`; + break; + case "weapon": + // If this weapon uses a custom target for rolls, we set that explicitly. + if (this.key === "custom") { + this.target = this.item.system.customSkillTarget; + this.localizedKey = game.i18n.localize("DG.ItemWindow.Custom"); + } + // Add a the weapon's internal modifier. + this.modifier += this.item.system.skillModifier; + break; + case "sanity": + this.target = this.actor.system.sanity.value; + this.localizedKey = game.i18n.localize("DG.Attributes.SAN"); + break; + case "luck": + this.target = 50; + this.localizedKey = game.i18n.localize("DG.Luck"); + break; + default: + break; + } + } + + /** + * Shows a dialog that can modify the roll. + * + * @returns {Promise} - the results of the dialog. + */ + async showDialog() { + const privateSanSetting = game.settings.get( + "deltagreen", + "keepSanityPrivate", + ); + + let hideSanTarget = false; + if ( + privateSanSetting && + (this.type === "sanity" || this.key === "ritual") && + !game.user.isGM + ) { + hideSanTarget = true; + } + + let customModifierTarget = 20; + + if (this.actor != null) { + try { + customModifierTarget = parseInt( + this.actor.system.settings.rolling.defaultPercentileModifier, + ); + } catch { + // do nothing + } + } + + return showPercentileRollModifyDialog({ + label: this.localizedKey, + target: this.target, + hideTarget: hideSanTarget, + defaultModifier: customModifierTarget, + actor: this.actor, + }); + } + + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * Lays out and styles message based on outcome of the roll. + * + * @returns {Promise} - the created chat message. + */ + async toChat() { + // if using private san rolls, must hide any SAN roll unless user is a GM + const privateSanSetting = game.settings.get( + "deltagreen", + "keepSanityPrivate", + ); + if ( + privateSanSetting && + (this.type === "sanity" || this.key === "ritual") && + !game.user.isGM + ) { + this.options.messageMode = "blind"; + } + + const diceSoNice = + game.modules.has("dice-so-nice") && + game.modules.get("dice-so-nice").active; + + const label = this.createLabel(); + + let resultString = ""; + let styleOverride = ""; + + if (this.isSuccess) { + if (this.isCritical) { + resultString = `${game.i18n.localize("DG.Roll.CriticalSuccess")}`; + resultString = `${resultString.toUpperCase()}`; + styleOverride = "color: green"; + } else { + resultString = `${game.i18n.localize("DG.Roll.Success")}`; + } + } else if (this.isCritical) { + resultString = `${game.i18n.localize("DG.Roll.CriticalFailure")}`; + resultString = `${resultString.toUpperCase()}`; + styleOverride = "color: red"; + } else { + resultString = `${game.i18n.localize("DG.Roll.Failure")}`; + } + + const failureMark = + this.actor?.type === "agent" && + !this.isSuccess && + this.skillPath && + this.key !== "unnatural" && + !foundry.utils.getProperty(this.actor, `${this.skillPath}.failure`) && + game.settings.get(DG.ID, "skillFailure"); + + const html = await renderTemplate( + "systems/deltagreen/templates/roll/percentile-roll.hbs", + { + styleOverride, + resultString, + formula: this.formula, + total: this.total, + failureMark, + }, + ); + + // TODO: add setting for it? + if (failureMark) { + const keyForUpdate = `${this.skillPath}.failure`; + + const message = await this.toMessage({ + flags: { + deltagreen: { + rollbacks: { + [keyForUpdate]: false, + }, + }, + }, + content: html, + flavor: label, + }); + + if (diceSoNice) { + await game.dice3d.waitFor3DAnimationByMessageID(message.id); + } + + // TODO: auto-update actor or post icon with manual apply + await this.actor.update({ + [keyForUpdate]: true, + }); + + return message; + } + + return this.toMessage({ content: html, flavor: label }); + } + + /** + * Utility function, called in the DGPercentileRoll constructor. + * If this roll key corresponds to a stat, skill, + * or typedSkill, get pertinent info. + * + * This is used for Stat, Skill, Typed Skill, Weapon, and Special Training Rolls. + * + * @returns {Object} - Contains the roll target and localized version of the key. + */ + getRollInfoFromKey() { + const actorData = this.actor.system; + const skillKeys = Object.keys(actorData.skills); + const typedSkillKeys = Object.keys(actorData.typedSkills); + const statKeys = Object.keys(actorData.statistics); + + let target = null; + let localizedKey = null; + let skillPath = null; // For optimization of failure checks + if (statKeys.includes(this.key)) { + target = actorData.statistics[this.key].x5; + localizedKey = game.i18n.localize(`DG.Attributes.${this.key}`); + } + if (skillKeys.includes(this.key)) { + // use calculated target proficiency (effects and etc like aim + 20%) + target = + actorData.skills[this.key].targetProficiency || + actorData.skills[this.key].proficiency; + localizedKey = game.i18n.localize(`DG.Skills.${this.key}`); + skillPath = `system.skills.${this.key}`; + } + if (typedSkillKeys.includes(this.key)) { + const skill = actorData.typedSkills[this.key]; + target = skill.proficiency; + localizedKey = `${skill.group} (${skill.label})`; + skillPath = `system.typedSkills.${this.key}`; + } + if (this.key === "ritual") { + target = actorData.sanity.ritual; + localizedKey = game.i18n.localize(`DG.Skills.ritual`); + } + return { target, localizedKey, skillPath }; + } + + /** + * Create label based on result of roll + * + * todo: do we want make isInhuman more similar to base label? + * + * @returns {string} + */ + createLabel() { + const startOfLabel = `${game.i18n.localize("DG.Roll.Rolling")} ${ + this.localizedKey + }`; + const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${ + this.effectiveTarget + }`; + + let label = this.isInhuman + ? // "Inhuman" stat being rolled. See function for details. + `${startOfLabel} [${game.i18n + .localize("DG.Roll.Inhuman") + .toUpperCase()}] ${endOfLabel}` + : `${startOfLabel}
${endOfLabel}%`; + + const { isExhausted, exhaustedCheckPenalty } = this.exhausted; + + if (this.modifier || isExhausted) { + label += ` (${this.target}%`; + + if (this.modifier) { + label += `${DGUtils.formatStringWithLeadingPlus(this.modifier)}%`; + } + + if (isExhausted) { + label += `${DGUtils.formatStringWithLeadingPlus( + exhaustedCheckPenalty, + )}%`; + } + + label += `)`; + } + + return label; + } + + get exhausted() { + let isExhausted = false; + let exhaustedCheckPenalty = -20; + + try { + // I suspect (but am not entirely certain) that being tired doesn't make you less lucky) + if (this.type !== "luck") { + isExhausted = this.actor.system.physical.exhausted; + exhaustedCheckPenalty = this.actor.system.physical.exhaustedPenalty; + exhaustedCheckPenalty = -1 * Math.abs(exhaustedCheckPenalty); + } + } catch { + isExhausted = false; + exhaustedCheckPenalty = -20; + } + + return { isExhausted, exhaustedCheckPenalty }; + } + + /** + * "Inhuman" stat being rolled, logic is different per page 188 of the Handler's Guide. + * Note - originally implemented by Uriele, but my attempt at merging conficts went poorly, so re-implementing. + * For an inhuman check, the roll succeeds except on a roll of 100 which fails AND fumbles. + * If the roll is a matching digit roll, it is a critical as normal. + * Also, if the roll is below the regular (non-x5) value of the stat, it is a critical. E.g. a CON of 25, a d100 roll of 21 would be a critical. + * + * @returns {Boolean} + */ + get isInhuman() { + /* + Changing this to only consider the base x5 stat target for whether something is 'inhuman' + because I do not think the intent was an Agent with a high strength getting a +40% bonus to be considered 'inhuman' + and therefore benefit from the increased crit threshold, although could be wrong about this. + */ + if (this.target > 99 && this.type === "stat") { + return true; + } + return false; + } + + /** + * Determines if a roll result is critical. + * If roll has not been evaluated, return null. + * + * @returns {null|Boolean} + */ + get isCritical() { + // If roll isn't evaluated, return null. + if (!this.total) { + return null; + } + let isCritical = false; + + // 1, 100, or any matching dice are a crit, i.e. 11, 22, 33...99. + if (this.total === 1 || this.total === 100 || this.total % 11 === 0) { + // really good, or reeaaaally bad + isCritical = true; + } + + // If inhuman and the roll is below the regular (non-x5) value of the stat, it is a critical. + // E.g. a CON of 25, a d100 roll of 21 would be a critical. + if (this.isInhuman && this.total <= this.target / 5) { + isCritical = true; + } + + return isCritical; + } + + /** + * Determines if a roll succeeded. + * If roll has not been evaluated, return null. + * + * @returns {null|Boolean} + */ + get isSuccess() { + // If roll isn't evaluated, return null. + if (!this.total) { + return null; + } + + // A roll of 100 always (critically) fails, even for inhuman rolls. + if (this.total === 100) return false; + return this.total <= this.effectiveTarget; + } + + /** + * Actual target for the roll accounting for modifier if present. + * Floored to 1 if a negative modifier would bring it below 1. + * Capped at 99 unless it is an inhuman stat test. + * Also worth noting, per page 47 of the Agent's Handbook, Exhaustion penalties + * affect not only skill and stat tests, but SAN tests as well... + * + * @returns {null|integer} + */ + get effectiveTarget() { + let target = 1; + + const { isExhausted, exhaustedCheckPenalty } = this.exhausted; + + if (!this.target || Number.isNaN(this.target)) { + return null; + } + + target = parseInt(this.target); + + if (isExhausted) { + target += exhaustedCheckPenalty; + } + + if (this.modifier && !Number.isNaN(this.modifier)) { + const modifier = parseInt(this.modifier); + + target += modifier; + + // per agent's handbook (pg.43), a negative modifier can't lower a target below 1% + target = Math.max(target, 1); + } + + // an 'inhuman' stat test can exceed 99% as a target, but skill tests otherwise cannot (agents handbook pg.43) + if (!this.isInhuman) { + target = Math.min(target, 99); + } + + return target; + } +} + +export class DGLethalityRoll extends DGPercentileRoll { + /** + * See constructor for DGPercentileRoll. This theoretically could be done in the parent class' + * constructor, but since Lethality rolls needs its own class for custom methods anyway, + * we will set the target and localized key here. + * + * @param {String} formula + * @param {Object} data + * @param {Object} options + */ + constructor(formula, data, options) { + super(formula, data, options); + this.target = options.item.system.lethality; + this.localizedKey = game.i18n.localize("DG.ItemWindow.Weapons.Lethality"); + } + + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * Lays out and styles message based on outcome of the roll. + * + * Overrides `DGPercentileRoll.toChat()` + * + * @returns {Promise} - the created chat message. + * @override + */ + async toChat() { + let resultString = ""; + let styleOverride = ""; + if (this.total <= this.target) { + resultString = `${game.i18n.localize("DG.Roll.Lethal").toUpperCase()}`; + styleOverride = "color: red"; + } else { + resultString = `${game.i18n.localize("DG.Roll.Failure")}`; + } + + const { nonLethalDamage } = this; + let label = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n + .localize("DG.Roll.Lethality") + .toUpperCase()} ${game.i18n.localize( + "DG.Roll.For", + )} ${this.item.name.toUpperCase()} ${game.i18n.localize( + "DG.Roll.Target", + )} ${this.target + this.modifier}`; + if (this.modifier) { + label += ` (${DGUtils.formatStringWithLeadingPlus(this.modifier)}%)`; + } + + const html = await renderTemplate( + "systems/deltagreen/templates/roll/lethality-roll.hbs", + { + styleOverride, + resultString, + total: this.total, + die1: nonLethalDamage.die1, + die2: nonLethalDamage.die2, + nonLethalTotal: nonLethalDamage.total, + damageLabel: game.i18n.localize("DG.Roll.Damage"), + }, + ); + + return this.toMessage({ content: html, flavor: label }); + } + + /** + * Calculates the damage for when a lethality roll fails. + * If roll has not been evaluated, return null. + * + * See full rules on page 57 of agent's handbook. + * + * Note, this getter does not actually care if the roll has failed. + * + * @returns {null|Object} - return data about the non-lethal damage. + */ + get nonLethalDamage() { + if (!this.total) { + return null; + } + + // Try to determine what the d100 result would be as if it was two d10's being rolled. + const totalString = this.total.toString(); + const digits = totalString.length; + let die1; + let die2; + switch (digits) { + case 1: + // If one digit in the result, one die is a 10, and the other is the result. + [die1, die2] = [10, this.total]; + break; + case 2: + // If two digits in the result, each die is the value of one of the digits. If one of those digits is 0, make it 10. + [die1, die2] = totalString + .split("") + .map((digit) => parseInt(digit)) + .map((digit) => digit || 10); + break; + case 3: + // If three digits in the result (aka result === 100), each die is a 10. + [die1, die2] = [10, 10]; + break; + default: + break; + } + + const total = die1 + die2; + return { die1, die2, total }; + } +} + +export class DGDamageRoll extends DGRoll { + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * @returns {Promise} - the created chat message. + * @override + */ + async toChat() { + let label = this.formula; + try { + label = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n + .localize("DG.Roll.Damage") + .toUpperCase()} ${game.i18n.localize("DG.Roll.For")} ${ + this.item.name + } (${ + this.item.system.armorPiercing + } armor penetration)`; + } catch (ex) { + // console.log(ex); + label = `Rolling DAMAGE for ${label.toUpperCase()}`; + } + return this.toMessage({ content: this.total, flavor: label }); + } + + async showDialog() { + return showDamageRollModifyDialog({ + itemName: this.item?.name, + formula: this.formula, + }); + } +} + +export class DGSanityDamageRoll extends DGRoll { + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * @returns {Promise} - the created chat message. + * @override + */ + async toChat() { + const [lowDie, highDie] = this.terms[0].terms.map((formula) => { + return Roll.parse(formula)[0] || { faces: parseInt(formula), number: 1 }; + }); + + const [lowResult, highResult] = this.damageResults; + + const flavor = `Rolling ${DGUtils.localizeWithFallback( + "DG.Generic.SanDamage", + "SAN DAMAGE", + )} For ${lowDie.formula} / ${highDie.formula}`; + + const html = await renderTemplate( + "systems/deltagreen/templates/roll/sanity-damage-roll.hbs", + { + lowFormula: lowDie.formula, + highFormula: highDie.formula, + lowFaces: lowDie.faces, + highFaces: highDie.faces, + lowResult, + highResult, + }, + ); + + return this.toMessage({ content: html, flavor }); + } + + /** + * Returns the two results for a sanity damage roll. + * + * Returns null if the roll has not been evaluated. + * + * @returns {null|Array} - Array of result numbers + */ + get damageResults() { + if (!this.total) return null; + + const [lowResult, highResult] = this.terms[0].results; + return [lowResult?.result, highResult?.result]; + } +} diff --git a/module/roll/roll-dialogs.js b/module/roll/roll-dialogs.js new file mode 100644 index 0000000..1472715 --- /dev/null +++ b/module/roll/roll-dialogs.js @@ -0,0 +1,177 @@ +import DGUtils from "../utils/utility-functions.js"; + +const { renderTemplate } = foundry.applications.handlebars; +const { DialogV2 } = foundry.applications.api; + +const PERCENTILE_ROLL_DIALOG_TEMPLATE = + "systems/deltagreen/templates/dialog/modify-percentile-roll.html"; +const DAMAGE_ROLL_DIALOG_TEMPLATE = + "systems/deltagreen/templates/dialog/modify-damage-roll.html"; + +/** + * @param {object} options + * @param {string} options.label + * @param {number} options.target + * @param {boolean} [options.hideTarget] + * @param {number} [options.defaultModifier] + * @param {Actor|null} [options.actor] + * @returns {Promise<{ targetModifier: number, messageMode?: string }|void>} + */ +export function showPercentileRollModifyDialog({ + label, + target, + hideTarget = false, + defaultModifier = 20, + actor = null, +}) { + const backingData = { + data: { + label, + originalTarget: target, + targetModifier: defaultModifier, + hideTarget, + }, + }; + + return renderTemplate(PERCENTILE_ROLL_DIALOG_TEMPLATE, backingData).then( + (content) => + new Promise((resolve, reject) => { + const modButtons = [-40, -20, 20, 40].map((mod) => { + const sign = mod > 0 ? "Positive" : "Negative"; + return { + action: `roll${Math.abs(mod)}${sign}`, + label: mod > 0 ? `+${mod}` : String(mod), + callback: (event, button, dialog) => { + try { + const messageMode = + dialog.element.querySelector("[name='messageMode']")?.value; + resolve({ targetModifier: mod, messageMode }); + } catch (ex) { + reject(console.log(ex)); + } + }, + }; + }); + + new DialogV2({ + content, + window: { + title: DGUtils.localizeWithFallback( + "DG.ModifySkillRollDialogue.Title", + "Modify Roll", + ), + }, + buttons: [ + { + default: true, + action: "roll", + label: DGUtils.localizeWithFallback("DG.Roll.Roll", "Roll"), + callback: (event, button, dialog) => { + try { + let targetModifier = dialog.element.querySelector( + "[name='targetModifier']", + )?.value; + + const messageMode = + dialog.element.querySelector("[name='messageMode']")?.value; + + const plusMinus = dialog.element.querySelector( + "[name='plusOrMinus']", + )?.value; + + if ( + targetModifier.trim() !== "" && + !Number.isNaN(targetModifier) + ) { + targetModifier = Math.abs(parseInt(targetModifier, 10)); + + if (plusMinus === "-") { + targetModifier *= -1; + } + + if (actor != null) { + actor.update({ + "system.settings.rolling.defaultPercentileModifier": + targetModifier, + }); + } + } + resolve({ targetModifier, messageMode }); + } catch (ex) { + reject(console.log(ex)); + } + }, + }, + ...modButtons, + ], + }).render(true); + }), + ); +} + +/** + * @param {object} options + * @param {string} [options.itemName] + * @param {string} options.formula + * @returns {Promise<{ newFormula: string, messageMode?: string }|void>} + */ +export function showDamageRollModifyDialog({ itemName, formula }) { + const backingData = { + data: { + label: itemName, + originalFormula: formula, + outerModifier: "2 * ", + innerModifier: "+ 0", + }, + }; + + return renderTemplate(DAMAGE_ROLL_DIALOG_TEMPLATE, backingData).then( + (content) => + new Promise((resolve, reject) => { + new DialogV2({ + content, + window: { + title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"), + }, + buttons: [ + { + default: true, + label: game.i18n.translations.DG.Roll.Roll, + action: "roll", + callback: (event, button, dialog) => { + try { + const outerModifier = dialog.element.querySelector( + "[name='outerModifier']", + )?.value; + let innerModifier = dialog.element.querySelector( + "[name='innerModifier']", + )?.value; + const modifiedBaseRoll = dialog.element.querySelector( + "[name='originalFormula']", + )?.value; + const messageMode = dialog.element.querySelector( + "[name='targetRollMode']", + )?.value; + + if (innerModifier.replace(" ", "") === "+0") { + innerModifier = ""; + } + + let newFormula = ""; + if (outerModifier.trim() !== "") { + newFormula += `${outerModifier}(${modifiedBaseRoll}${innerModifier.trim()})`; + } else { + newFormula += modifiedBaseRoll + innerModifier.trim(); + } + + resolve({ newFormula, messageMode }); + } catch (ex) { + reject(console.log(ex)); + } + }, + }, + ], + }).render(true); + }), + ); +} diff --git a/module/roll/roll.js b/module/roll/roll.js index 578d182..6f7708c 100644 --- a/module/roll/roll.js +++ b/module/roll/roll.js @@ -1,855 +1,18 @@ -/* eslint-disable max-classes-per-file */ -import DGUtils from "../other/utility-functions.js"; -import DG from "../config.js"; - -const { renderTemplate } = foundry.applications.handlebars; -const { DialogV2 } = foundry.applications.api; - -export class DGRoll extends Roll { - /** - * NOTE: This class will rarely be called on its own. It should generally be extended. Look to DGPercentileRoll as an example. - * - * Customize our roll with some useful information, passed in the `options` Object. - * - * @param {string} formula Unused - The string formula to parse (from Foundry) - * @param {Object} data Unused - The data object against which to parse attributes within the formula - * @param {Object} [options] Additional data which is preserved in the database - * @param {Number} [options.rollType] The type of roll (stat, skill, sanity, damage, etc). - * @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll. - * @param {DeltaGreenActor} [options.actor] The actor that this roll originates from. - * @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates. - */ - constructor(formula, data = {}, options = {}) { - super(formula, data, options); - const { rollType, key, actor, item } = options; - this.type = rollType; - this.key = key; - this.actor = actor; - this.item = item; - this.modifier = 0; - } - - /** - * Simple function that actually creates the message and sends it to chat. - * We override this to have a little more control over certain aspects of the message, - * right now, its `speaker` and `messageMode`. - * - * @override - * The following `@param` descriptions comes from the Foundry VTT code. - * @param {object} messageData The data object to use when creating the message - * @param {options} [options] Additional options which modify the created message. - * @param {string} [options.messageMode] A key of CONFIG.ChatMessage.modes - * @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the - * prepared chatData object. - * @returns {Promise} A promise which resolves to the created ChatMessage document if create is - * true, or the Object of prepared chatData otherwise. - */ - async toMessage(messageData = {}, { messageMode, create = true } = {}) { - // eslint-disable-next-line no-param-reassign - messageData.speaker = ChatMessage.getSpeaker({ actor: this.actor }); - const mode = - messageMode ?? - this.options.messageMode ?? - this.options.rollMode; - const opts = { create }; - if (mode) { - opts.messageMode = foundry.dice.Roll._mapLegacyRollMode(mode); - } - return super.toMessage(messageData, opts); - } -} - -export class DGPercentileRoll extends DGRoll { - /** - * Creates D100 rolls, the base die of the system. - * - * This constructor embeds the following info into the roll: - * 1. Target number that the roll needs to beat. - * 2. Localized name for the roll. - * - * Note: In order for all of our custom data to persist, our constructor must use the same parameters as its parent class. - * So, even though percentile rolls will always have a formula of "1d100" and we don't use the `data` object, - * we still have to keep them as parameters. - * - * @param {string} formula Unused - The string formula to parse (from Foundry) - Always "1d100" for percentile rolls. - * @param {Object} data Unused - The data object against which to parse attributes within the formula - * @param {Object} [options] Additional data which is preserved in the database - * @param {Number} [options.rollType] The type of roll (stat, skill, sanity, etc). - * @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll. - * @param {DeltaGreenActor} [options.actor] The actor that this roll originates from. - * @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates. - * @param {DeltaGreenItem} [options.specialTrainingName] Optional - Special training rolls have names that are different from the roll key. - */ - // eslint-disable-next-line default-param-last, no-unused-vars - constructor(formula = "1D100", data = {}, options) { - super("1D100", {}, options); - - // Set roll info for Skill, Stat, Typed Skill, and non-custom Weapon Percentile rolls. - const { target, localizedKey, skillPath } = this.getRollInfoFromKey( - this.key, - this.actor.system, - ); - this.target = target; - this.localizedKey = localizedKey; - this.skillPath = skillPath; - - // Set roll info for other Percentile rolls - switch (this.type) { - case "special-training": - this.specialTrainingName = options.specialTrainingName; - this.localizedKey = `${this.specialTrainingName} - (${this.localizedKey})`; - break; - case "weapon": - // If this weapon uses a custom target for rolls, we set that explicitly. - if (this.key === "custom") { - this.target = this.item.system.customSkillTarget; - this.localizedKey = game.i18n.localize("DG.ItemWindow.Custom"); - } - // Add a the weapon's internal modifier. - this.modifier += this.item.system.skillModifier; - break; - case "sanity": - this.target = this.actor.system.sanity.value; - this.localizedKey = game.i18n.localize("DG.Attributes.SAN"); - break; - case "luck": - this.target = 50; - this.localizedKey = game.i18n.localize("DG.Luck"); - break; - default: - break; - } - } - - /** - * Shows a dialog that can modify the roll. - * - * @returns {Promise} - the results of the dialog. - */ - async showDialog() { - const privateSanSetting = game.settings.get( - "deltagreen", - "keepSanityPrivate", - ); - - let hideSanTarget = false; - if ( - privateSanSetting && - (this.type === "sanity" || this.key === "ritual") && - !game.user.isGM - ) { - hideSanTarget = true; - } - - let customModifierTarget = 20; - - if (this.actor != null) { - try { - customModifierTarget = parseInt( - this.actor.system.settings.rolling.defaultPercentileModifier, - ); - } catch { - // do nothing - } - } - - const backingData = { - data: { - label: this.localizedKey, - originalTarget: this.target, - targetModifier: customModifierTarget, - hideTarget: hideSanTarget, - }, - }; - - const template = - "systems/deltagreen/templates/dialog/modify-percentile-roll.html"; - const content = await renderTemplate(template, backingData); - return new Promise((resolve, reject) => { - const modButtons = [-40, -20, 20, 40].map((mod) => { - const sign = mod > 0 ? "Positive" : "Negative"; - return { - action: `roll${Math.abs(mod)}${sign}`, - label: mod > 0 ? `+${mod}` : String(mod), - callback: (event, button, dialog) => { - try { - const messageMode = - dialog.element.querySelector("[name='rollMode']")?.value; - resolve({ targetModifier: mod, messageMode }); - } catch (ex) { - reject(console.log(ex)); - } - }, - }; - }); - - new DialogV2({ - content, - window: { - title: DGUtils.localizeWithFallback( - "DG.ModifySkillRollDialogue.Title", - "Modify Roll", - ), - }, - buttons: [ - { - default: true, - action: "roll", - label: DGUtils.localizeWithFallback("DG.Roll.Roll", "Roll"), - callback: (event, button, dialog) => { - try { - let targetModifier = dialog.element.querySelector( - "[name='targetModifier']", - )?.value; // this is text as a heads up - - const messageMode = - dialog.element.querySelector("[name='rollMode']")?.value; - - const plusMinus = dialog.element.querySelector( - "[name='plusOrMinus']", - )?.value; - - if ( - targetModifier.trim() !== "" && - !Number.isNaN(targetModifier) - ) { - targetModifier = Math.abs(parseInt(targetModifier)); - - if (plusMinus === "-") { - targetModifier *= -1; - } - - // update the custom target modifier so it 'persists' between dialogs - if (this.actor != null) { - this.actor.update({ - "system.settings.rolling.defaultPercentileModifier": - targetModifier, - }); - } - } - resolve({ targetModifier, messageMode }); - } catch (ex) { - reject(console.log(ex)); - } - }, - }, - ...modButtons, - ], - }).render(true); - }); - } - - /** - * Prepares data for a chat message and then passes that data - * to a method that actually creates a ChatMessage. - * - * Lays out and styles message based on outcome of the roll. - * - * @returns {Promise} - the created chat message. - */ - async toChat() { - // if using private san rolls, must hide any SAN roll unless user is a GM - const privateSanSetting = game.settings.get( - "deltagreen", - "keepSanityPrivate", - ); - if ( - privateSanSetting && - (this.type === "sanity" || this.key === "ritual") && - !game.user.isGM - ) { - this.options.messageMode = "blind"; - } - - const diceSoNice = - game.modules.has("dice-so-nice") && - game.modules.get("dice-so-nice").active; - - const label = this.createLabel(); - - let resultString = ""; - let styleOverride = ""; - - if (this.isSuccess) { - if (this.isCritical) { - resultString = `${game.i18n.localize("DG.Roll.CriticalSuccess")}`; - resultString = `${resultString.toUpperCase()}`; - styleOverride = "color: green"; - } else { - resultString = `${game.i18n.localize("DG.Roll.Success")}`; - } - } else if (this.isCritical) { - resultString = `${game.i18n.localize("DG.Roll.CriticalFailure")}`; - resultString = `${resultString.toUpperCase()}`; - styleOverride = "color: red"; - } else { - resultString = `${game.i18n.localize("DG.Roll.Failure")}`; - } - - const failureMark = - this.actor?.type === "agent" && - !this.isSuccess && - this.skillPath && - this.key !== "unnatural" && - !foundry.utils.getProperty(this.actor, `${this.skillPath}.failure`) && - game.settings.get(DG.ID, "skillFailure"); - - const html = await renderTemplate( - "systems/deltagreen/templates/roll/percentile-roll.hbs", - { - styleOverride, - resultString, - formula: this.formula, - total: this.total, - failureMark, - }, - ); - - // TODO: add setting for it? - if (failureMark) { - const keyForUpdate = `${this.skillPath}.failure`; - - const message = await this.toMessage({ - flags: { - deltagreen: { - rollbacks: { - [keyForUpdate]: false, - }, - }, - }, - content: html, - flavor: label, - }); - - if (diceSoNice) { - await game.dice3d.waitFor3DAnimationByMessageID(message.id); - } - - // TODO: auto-update actor or post icon with manual apply - await this.actor.update({ - [keyForUpdate]: true, - }); - - return message; - } - - return this.toMessage({ content: html, flavor: label }); - } - - /** - * Utility function, called in the DGPercentileRoll constructor. - * If this roll key corresponds to a stat, skill, - * or typedSkill, get pertinent info. - * - * This is used for Stat, Skill, Typed Skill, Weapon, and Special Training Rolls. - * - * @returns {Object} - Contains the roll target and localized version of the key. - */ - getRollInfoFromKey() { - const actorData = this.actor.system; - const skillKeys = Object.keys(actorData.skills); - const typedSkillKeys = Object.keys(actorData.typedSkills); - const statKeys = Object.keys(actorData.statistics); - - let target = null; - let localizedKey = null; - let skillPath = null; // For optimization of failure checks - if (statKeys.includes(this.key)) { - target = actorData.statistics[this.key].x5; - localizedKey = game.i18n.localize(`DG.Attributes.${this.key}`); - } - if (skillKeys.includes(this.key)) { - // use calculated target proficiency (effects and etc like aim + 20%) - target = - actorData.skills[this.key].targetProficiency || - actorData.skills[this.key].proficiency; - localizedKey = game.i18n.localize(`DG.Skills.${this.key}`); - skillPath = `system.skills.${this.key}`; - } - if (typedSkillKeys.includes(this.key)) { - const skill = actorData.typedSkills[this.key]; - target = skill.proficiency; - localizedKey = `${skill.group} (${skill.label})`; - skillPath = `system.typedSkills.${this.key}`; - } - if (this.key === "ritual") { - target = actorData.sanity.ritual; - localizedKey = game.i18n.localize(`DG.Skills.ritual`); - } - return { target, localizedKey, skillPath }; - } - - /** - * Create label based on result of roll - * - * todo: do we want make isInhuman more similar to base label? - * - * @returns {string} - */ - createLabel() { - const startOfLabel = `${game.i18n.localize("DG.Roll.Rolling")} ${ - this.localizedKey - }`; - const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${ - this.effectiveTarget - }`; - - let label = this.isInhuman - ? // "Inhuman" stat being rolled. See function for details. - `${startOfLabel} [${game.i18n - .localize("DG.Roll.Inhuman") - .toUpperCase()}] ${endOfLabel}` - : `${startOfLabel}
${endOfLabel}%`; - - const { isExhausted, exhaustedCheckPenalty } = this.exhausted; - - if (this.modifier || isExhausted) { - label += ` (${this.target}%`; - - if (this.modifier) { - label += `${DGUtils.formatStringWithLeadingPlus(this.modifier)}%`; - } - - if (isExhausted) { - label += `${DGUtils.formatStringWithLeadingPlus( - exhaustedCheckPenalty, - )}%`; - } - - label += `)`; - } - - return label; - } - - get exhausted() { - let isExhausted = false; - let exhaustedCheckPenalty = -20; - - try { - // I suspect (but am not entirely certain) that being tired doesn't make you less lucky) - if (this.type !== "luck") { - isExhausted = this.actor.system.physical.exhausted; - exhaustedCheckPenalty = this.actor.system.physical.exhaustedPenalty; - exhaustedCheckPenalty = -1 * Math.abs(exhaustedCheckPenalty); - } - } catch { - isExhausted = false; - exhaustedCheckPenalty = -20; - } - - return { isExhausted, exhaustedCheckPenalty }; - } - - /** - * "Inhuman" stat being rolled, logic is different per page 188 of the Handler's Guide. - * Note - originally implemented by Uriele, but my attempt at merging conficts went poorly, so re-implementing. - * For an inhuman check, the roll succeeds except on a roll of 100 which fails AND fumbles. - * If the roll is a matching digit roll, it is a critical as normal. - * Also, if the roll is below the regular (non-x5) value of the stat, it is a critical. E.g. a CON of 25, a d100 roll of 21 would be a critical. - * - * @returns {Boolean} - */ - get isInhuman() { - /* - Changing this to only consider the base x5 stat target for whether something is 'inhuman' - because I do not think the intent was an Agent with a high strength getting a +40% bonus to be considered 'inhuman' - and therefore benefit from the increased crit threshold, although could be wrong about this. - */ - if (this.target > 99 && this.type === "stat") { - return true; - } - return false; - } - - /** - * Determines if a roll result is critical. - * If roll has not been evaluated, return null. - * - * @returns {null|Boolean} - */ - get isCritical() { - // If roll isn't evaluated, return null. - if (!this.total) { - return null; - } - let isCritical = false; - - // 1, 100, or any matching dice are a crit, i.e. 11, 22, 33...99. - if (this.total === 1 || this.total === 100 || this.total % 11 === 0) { - // really good, or reeaaaally bad - isCritical = true; - } - - // If inhuman and the roll is below the regular (non-x5) value of the stat, it is a critical. - // E.g. a CON of 25, a d100 roll of 21 would be a critical. - if (this.isInhuman && this.total <= this.target / 5) { - isCritical = true; - } - - return isCritical; - } - - /** - * Determines if a roll succeeded. - * If roll has not been evaluated, return null. - * - * @returns {null|Boolean} - */ - get isSuccess() { - // If roll isn't evaluated, return null. - if (!this.total) { - return null; - } - - // A roll of 100 always (critically) fails, even for inhuman rolls. - if (this.total === 100) return false; - return this.total <= this.effectiveTarget; - } - - /** - * Actual target for the roll accounting for modifier if present. - * Floored to 1 if a negative modifier would bring it below 1. - * Capped at 99 unless it is an inhuman stat test. - * Also worth noting, per page 47 of the Agent's Handbook, Exhaustion penalties - * affect not only skill and stat tests, but SAN tests as well... - * - * @returns {null|integer} - */ - get effectiveTarget() { - let target = 1; - - const { isExhausted, exhaustedCheckPenalty } = this.exhausted; - - if (!this.target || Number.isNaN(this.target)) { - return null; - } - - target = parseInt(this.target); - - if (isExhausted) { - target += exhaustedCheckPenalty; - } - - if (this.modifier && !Number.isNaN(this.modifier)) { - const modifier = parseInt(this.modifier); - - target += modifier; - - // per agent's handbook (pg.43), a negative modifier can't lower a target below 1% - target = Math.max(target, 1); - } - - // an 'inhuman' stat test can exceed 99% as a target, but skill tests otherwise cannot (agents handbook pg.43) - if (!this.isInhuman) { - target = Math.min(target, 99); - } - - return target; - } -} - -export class DGLethalityRoll extends DGPercentileRoll { - /** - * See constructor for DGPercentileRoll. This theoretically could be done in the parent class' - * constructor, but since Lethality rolls needs its own class for custom methods anyway, - * we will set the target and localized key here. - * - * @param {String} formula - * @param {Object} data - * @param {Object} options - */ - constructor(formula, data, options) { - super(formula, data, options); - this.target = options.item.system.lethality; - this.localizedKey = game.i18n.localize("DG.ItemWindow.Weapons.Lethality"); - } - - /** - * Prepares data for a chat message and then passes that data - * to a method that actually creates a ChatMessage. - * - * Lays out and styles message based on outcome of the roll. - * - * Overrides `DGPercentileRoll.toChat()` - * - * @returns {Promise} - the created chat message. - * @override - */ - async toChat() { - let resultString = ""; - let styleOverride = ""; - if (this.total <= this.target) { - resultString = `${game.i18n.localize("DG.Roll.Lethal").toUpperCase()}`; - styleOverride = "color: red"; - } else { - resultString = `${game.i18n.localize("DG.Roll.Failure")}`; - } - - const { nonLethalDamage } = this; - let label = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n - .localize("DG.Roll.Lethality") - .toUpperCase()} ${game.i18n.localize( - "DG.Roll.For", - )} ${this.item.name.toUpperCase()} ${game.i18n.localize( - "DG.Roll.Target", - )} ${this.target + this.modifier}`; - if (this.modifier) { - label += ` (${DGUtils.formatStringWithLeadingPlus(this.modifier)}%)`; - } - - let html = ""; - html += `
`; - html += `
`; - html += `
${resultString}
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += ` `; - html += ` d100`; - html += ` `; - html += ` `; - html += ` ${this.total}`; - html += ` `; - html += `
`; - html += `
    `; - html += `
  1. ${this.total}`; - html += `
`; - html += `
`; - html += `
`; - html += ` `; - html += ` 2d10 (d10 + d10)`; - html += ` `; - html += ` `; - html += ` ${nonLethalDamage.total}`; - html += ` `; - html += `
`; - html += `
    `; - html += `
  1. ${nonLethalDamage.die1}
  2. `; - html += `
  3. ${nonLethalDamage.die2}
  4. `; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `

${this.total} (${ - nonLethalDamage.total - } ${game.i18n.localize("DG.Roll.Damage")})

`; - html += `
`; - html += `
`; - - return this.toMessage({ content: html, flavor: label }); - } - - /** - * Calculates the damage for when a lethality roll fails. - * If roll has not been evaluated, return null. - * - * See full rules on page 57 of agent's handbook. - * - * Note, this getter does not actually care if the roll has failed. - * - * @returns {null|Object} - return data about the non-lethal damage. - */ - get nonLethalDamage() { - if (!this.total) { - return null; - } - - // Try to determine what the d100 result would be as if it was two d10's being rolled. - const totalString = this.total.toString(); - const digits = totalString.length; - let die1; - let die2; - switch (digits) { - case 1: - // If one digit in the result, one die is a 10, and the other is the result. - [die1, die2] = [10, this.total]; - break; - case 2: - // If two digits in the result, each die is the value of one of the digits. If one of those digits is 0, make it 10. - [die1, die2] = totalString - .split("") - .map((digit) => parseInt(digit)) - .map((digit) => digit || 10); - break; - case 3: - // If three digits in the result (aka result === 100), each die is a 10. - [die1, die2] = [10, 10]; - break; - default: - break; - } - - const total = die1 + die2; - return { die1, die2, total }; - } -} - -export class DGDamageRoll extends DGRoll { - /** - * Prepares data for a chat message and then passes that data - * to a method that actually creates a ChatMessage. - * - * @returns {Promise} - the created chat message. - * @override - */ - async toChat() { - let label = this.formula; - try { - label = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n - .localize("DG.Roll.Damage") - .toUpperCase()} ${game.i18n.localize("DG.Roll.For")} ${ - this.item.name - } (${ - this.item.system.armorPiercing - } armor penetration)`; - } catch (ex) { - // console.log(ex); - label = `Rolling DAMAGE for ${label.toUpperCase()}`; - } - return this.toMessage({ content: this.total, flavor: label }); - } - - async showDialog() { - const template = - "systems/deltagreen/templates/dialog/modify-damage-roll.html"; - const backingData = { - data: { - label: this.item?.name, - originalFormula: this.formula, - outerModifier: "2 * ", - innerModifier: "+ 0", - }, - }; - - const content = await renderTemplate(template, backingData); - return new Promise((resolve, reject) => { - new DialogV2({ - content, - window: { - title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"), - }, - buttons: [ - { - default: true, - label: game.i18n.translations.DG.Roll.Roll, - action: "roll", - callback: (event, button, dialog) => { - try { - const outerModifier = dialog.element.querySelector( - "[name='outerModifier']", - )?.value; // this is text as a heads up - let innerModifier = dialog.element.querySelector( - "[name='innerModifier']", - )?.value; // this is text as a heads up - const modifiedBaseRoll = dialog.element.querySelector( - "[name='originalFormula']", - )?.value; // this is text as a heads up - const messageMode = dialog.element.querySelector( - "[name='targetRollMode']", - )?.value; - - if (innerModifier.replace(" ", "") === "+0") { - innerModifier = ""; - } - - let newFormula = ""; - if (outerModifier.trim() !== "") { - newFormula += `${outerModifier}(${modifiedBaseRoll}${innerModifier.trim()})`; - } else { - newFormula += modifiedBaseRoll + innerModifier.trim(); - } - - resolve({ newFormula, messageMode }); - } catch (ex) { - reject(console.log(ex)); - } - }, - }, - ], - }).render(true); - }); - } -} - -export class DGSanityDamageRoll extends DGRoll { - /** - * Prepares data for a chat message and then passes that data - * to a method that actually creates a ChatMessage. - * - * @returns {Promise} - the created chat message. - * @override - */ - async toChat() { - const [lowDie, highDie] = this.terms[0].terms.map((formula) => { - return Roll.parse(formula)[0] || { faces: parseInt(formula), number: 1 }; - }); - - const [lowResult, highResult] = this.damageResults; - - const flavor = `Rolling ${DGUtils.localizeWithFallback( - "DG.Generic.SanDamage", - "SAN DAMAGE", - )} For ${lowDie.formula} / ${highDie.formula}`; - - let html = ""; - html += `
`; - html += `
`; - html += `
${lowDie.formula} / ${highDie.formula}
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += ` `; - html += ` ${lowDie.formula}`; - html += ` `; - html += ` `; - html += ` ${lowResult}`; - html += ` `; - html += `
`; - html += `
    `; - html += `
  1. ${lowResult}`; - html += `
`; - html += `
`; - html += `
`; - html += ` `; - html += ` ${highDie.formula}`; - html += ` `; - html += ` `; - html += ` ${highResult}`; - html += ` `; - html += `
`; - html += `
    `; - html += `
  1. ${highResult}
  2. `; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `

${lowResult} / ${highResult}

`; - html += `
`; - html += `
`; - return this.toMessage({ content: html, flavor }); - } - - /** - * Returns the two results for a sanity damage roll. - * - * Returns null if the roll has not been evaluated. - * - * @returns {null|Array} - Array of result numbers - */ - get damageResults() { - if (!this.total) return null; - - const [lowResult, highResult] = this.terms[0].results; - return [lowResult?.result, highResult?.result]; - } -} +import { + DGRoll, + DGPercentileRoll, + DGLethalityRoll, + DGDamageRoll, + DGSanityDamageRoll, +} from "./roll-classes.js"; + +export { + DGRoll, + DGPercentileRoll, + DGLethalityRoll, + DGDamageRoll, + DGSanityDamageRoll, +}; /** * Build a Delta Green roll from a click target's dataset. @@ -881,6 +44,7 @@ export function createDGRollFromDataset( case "sanity": case "special-training": case "weapon": + case "luck": roll = new DGPercentileRoll("1D100", {}, rollOptions); break; case "lethality": @@ -948,5 +112,5 @@ export async function processDGRoll(event, roll) { } } await roll.evaluate(); - roll.toChat(); + await roll.toChat(); } diff --git a/module/roll/skill-improvement-roll.js b/module/roll/skill-improvement-roll.js new file mode 100644 index 0000000..e295106 --- /dev/null +++ b/module/roll/skill-improvement-roll.js @@ -0,0 +1,106 @@ +import DG from "../config.js"; + +/** + * @param {"1"|"d3"|"d4"|"d4-1"} baseFormula + * @returns {string} + */ +export function getSkillImprovementFormulaAsPercent(baseFormula) { + const flat = baseFormula === "1"; + const formula = flat ? "1" : `1${baseFormula}`; + return `${formula}%`.toUpperCase(); +} + +/** + * Generates and evaluates skill improvement rolls for failed skills. + * + * @param {Actor} actor + * @param {string} baseFormula + * @param {object[]} failedSkills + * @param {object[]} failedTypedSkills + * @returns {Promise>} + */ +export async function evaluateSkillImprovementRolls( + actor, + baseFormula, + failedSkills, + failedTypedSkills, +) { + const totalFailures = failedSkills.length + failedTypedSkills.length; + + if (!Object.keys(DG.skillImprovementFormulas).includes(baseFormula)) { + throw new Error(`Unknown roll formula: ${baseFormula}`); + } + + const resultObj = {}; + if (baseFormula === "1") { + return resultObj; + } + + const rollPromises = []; + for (let i = 0; i < totalFailures; i++) { + rollPromises.push(new Roll(baseFormula, actor.system).evaluate()); + } + const rolls = await Promise.all(rollPromises); + + [...failedSkills, ...failedTypedSkills].forEach((skill, index) => { + resultObj[skill.key] = rolls[index].total; + }); + + return resultObj; +} + +/** + * @param {object} params + * @param {Actor} params.actor + * @param {TokenDocument|null} [params.token] + * @param {string} params.baseFormula + * @param {object[]} params.failedSkills + * @param {object[]} params.failedTypedSkills + * @param {Record} params.resultObj + * @returns {Promise} + */ +export async function createSkillImprovementChatMessage({ + actor, + token = null, + baseFormula, + failedSkills, + failedTypedSkills, + resultObj, +}) { + const localizeFailedSkills = (skillsArray) => { + return skillsArray.map((skill) => { + const increment = resultObj[skill.key] ?? 1; + const label = + skill.label ?? game.i18n.localize(`DG.Skills.${skill.key}`); + const groupLabel = skill.group + ? `${game.i18n.localize( + `DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`, + )} (${label})` + : label; + + return `${groupLabel}: +${increment}%`; + }); + }; + + const failedSkillNames = localizeFailedSkills(failedSkills); + const failedTypedSkillNames = localizeFailedSkills(failedTypedSkills); + + const content = [...failedSkillNames, ...failedTypedSkillNames].join(", "); + const flavor = game.i18n.format( + "DG.Skills.ApplySkillImprovements.ChatFlavor", + { + formula: getSkillImprovementFormulaAsPercent(baseFormula), + }, + ); + + return ChatMessage.create({ + speaker: ChatMessage.getSpeaker({ + actor, + token, + alias: actor.name, + }), + content, + flavor, + messageMode: game.settings.get("core", "messageMode"), + }); +} diff --git a/module/settings.js b/module/settings.js index 91c4a2e..598edc4 100644 --- a/module/settings.js +++ b/module/settings.js @@ -337,47 +337,11 @@ export default function registerSystemSettings() { }, }); - // obsolete - will be removed at some point - game.settings.register("deltagreen", "characterSheetFont", { - name: "World Font Choice", - hint: "Choose font style for use throughout this world.", - scope: "world", // This specifies a world-stored setting - config: false, // This specifies that the setting appears in the configuration view - type: String, - choices: { - // If choices are defined, the resulting setting will be a select menu - SpecialElite: "Special Elite (Classic Typewriters Font)", - Martel: "Martel (Clean Modern Font)", - Signika: "Signika (Foundry Default Font)", - TypeWriterCondensed: - "Condensed Typewriter (Modern, Small Typewriter Font)", - PublicSans: "Public Sans (US Government-style sans serif font)", - // "atwriter": "Another Typewriter (Alternate Old-style Typewriter Font)" - }, - default: "SpecialElite", // The default value for the setting - onChange: (value) => { - // A callback function which triggers when the setting is changed - // console.log(value) - }, - }); - - // obsolete - will be removed at some point - game.settings.register("deltagreen", "characterSheetBackgroundImageSetting", { - name: "World Sheet Background Image", - hint: "Choose background image for use throughout this world. (Refresh page to see change.)", - scope: "world", // This specifies a world-stored setting - config: false, // This specifies that the setting appears in the configuration view - type: String, - choices: { - // If choices are defined, the resulting setting will be a select menu - OldPaper1: "Old Dirty Paper (Good with Special Elite Font)", - IvoryPaper: "Ivory White Paper (Good with Martel Font)", - DefaultParchment: "Default Parchment (Good with Signika Font)", - }, - default: "OldPaper1", // The default value for the setting - onChange: (value) => { - // A callback function which triggers when the setting is changed - // console.log(value) - }, + game.settings.register(DG.ID, "schemaMigrationVersion", { + name: "Schema Migration Version", + scope: "world", + config: false, + type: Number, + default: 0, }); } diff --git a/module/sheets/agent-sheet-v2.js b/module/sheets/agent-sheet-v2.js deleted file mode 100644 index 68374a8..0000000 --- a/module/sheets/agent-sheet-v2.js +++ /dev/null @@ -1,283 +0,0 @@ -import DGAgentSheet from "./agent-sheet.js"; -import ActorEditStatForm from "./edit-stats.js"; - -/** @extends {DGAgentSheet} */ -export default class DGAgentSheetV2 extends DGAgentSheet { - static DEFAULT_OPTIONS = /** @type {const} */ ({ - classes: ["actor-sheet-v2"], - position: { width: 958, height: 700 }, - actions: { - openStatsEdit: this._openStatsEdit, - toggleCustomSkillsEdit: function toggleCustomSkillsEdit(event) { - event.preventDefault(); - event.stopPropagation(); - this._customSkillsEditMode = !this._customSkillsEditMode; - this._syncCustomSkillsEditModeUi(); - return this.render(); - }, - }, - }); - - /** @type {boolean} */ - _customSkillsEditMode = false; - - /** @override */ - static TABS = /** @type {const} */ ({ - primary: { - initial: "skills", - labelPrefix: "DG.Navigation.Agent", - tabs: [ - { id: "skills" }, - { id: "combat" }, - { id: "motivations" }, - { id: "gear" }, - { id: "personal" }, - { id: "about", icon: "fas fa-question-circle", label: "" }, - ], - }, - }); - - /** @override */ - static PARTS = /** @type {const} */ ({ - leftBar: { - template: `${this.TEMPLATE_PATH}/parts/left-bar.html`, - templates: [`${this.TEMPLATE_PATH}/partials/sanity-partial.html`], - }, - tabs: this.BASE_PARTS.tabs, - rightBar: { - template: `${this.TEMPLATE_PATH}/parts/right-bar.html`, - templates: [ - `${this.TEMPLATE_PATH}/parts/skills-tab-v2.html`, - `${this.TEMPLATE_PATH}/parts/combat-tab-v2.html`, - `${this.TEMPLATE_PATH}/parts/motivations-tab-v2.html`, - `${this.TEMPLATE_PATH}/parts/gear-tab-v2.html`, - `${this.TEMPLATE_PATH}/parts/personal-tab-v2.html`, - `${this.TEMPLATE_PATH}/parts/about-tab.html`, - `${this.TEMPLATE_PATH}/partials/custom-skills-partial-v2.html`, - `${this.TEMPLATE_PATH}/partials/bonds-section-partial.html`, - `${this.TEMPLATE_PATH}/partials/weapons-section-partial.html`, - `${this.TEMPLATE_PATH}/partials/armor-section-partial.html`, - `${this.TEMPLATE_PATH}/partials/injuries-section-partial.html`, - `${this.TEMPLATE_PATH}/partials/other-gear-section-partial.html`, - `${this.TEMPLATE_PATH}/partials/notes-partial.html`, - ], - }, - }); - - /** @override */ - _configureRenderOptions(options) { - super._configureRenderOptions(options); - - if (!this.actor.limited) return; - - options.parts = ["leftBar", "tabs", "rightBar"]; - } - - /** @override */ - _prepareTabs(group) { - const tabs = super._prepareTabs(group); - - if (!this.actor.limited || this.actor.type !== "agent") return tabs; - - return { - personal: { ...tabs.personal, active: true, cssClass: "active" }, - }; - } - - /** @override */ - async _prepareContext(options) { - const context = await super._prepareContext(options); - - if (this.actor.type !== "agent") return context; - - context.typedSkillColumns = this._prepareTypedSkillColumns(); - context.customSkillsEditMode = this._customSkillsEditMode; - - return context; - } - - /** @override */ - async _onRender(context, options) { - await super._onRender(context, options); - this._syncCustomSkillsEditModeUi(); - } - - /** @returns {void} */ - _syncCustomSkillsEditModeUi() { - this.element?.classList.toggle( - "custom-skills-edit-mode", - Boolean(this._customSkillsEditMode), - ); - } - - /** - * Packs skill groups into columns without splitting a group across columns. - * Respects the world "Sort Skills By Column" setting (same as core skills). - * - * @param {object[]} groups - Skill groups with skills and rowCount (sorted A–Z). - * @param {number} numCols - Number of columns. - * @returns {object[][]} Column arrays of render blocks. - */ - _packSkillGroupsIntoColumns(groups, numCols) { - const sortByColumn = game.settings.get("deltagreen", "sortSkills"); - if (sortByColumn) { - return this._packSkillGroupsByColumn(groups, numCols); - } - return this._packSkillGroupsByRow(groups, numCols); - } - - /** - * @param {object} column - * @param {object} group - * @returns {void} - */ - _appendSkillGroupToColumn(column, group) { - column.blocks.push({ - type: "groupHeader", - group: group.group, - label: group.label, - ...(group.isSpecialTraining ? { isSpecialTraining: true } : {}), - }); - - const skillType = group.isSpecialTraining ? "training" : "typedSkill"; - for (const skill of group.skills) { - column.blocks.push({ ...skill, type: skillType }); - } - - column.height += group.rowCount; - } - - /** - * Column-first packing (read down each column, then the next). - * - * @param {object[]} groups - * @param {number} numCols - * @returns {object[][]} - */ - _packSkillGroupsByColumn(groups, numCols) { - const columns = Array.from({ length: numCols }, () => ({ - blocks: [], - height: 0, - })); - - if (groups.length === 0) { - return columns.map((col) => col.blocks); - } - - const totalHeight = groups.reduce((sum, group) => sum + group.rowCount, 0); - const targetHeight = totalHeight / numCols; - let colIndex = 0; - - for (const group of groups) { - const col = columns[colIndex]; - - if ( - colIndex < numCols - 1 && - col.blocks.length > 0 && - col.height + group.rowCount > targetHeight - ) { - colIndex += 1; - } - - this._appendSkillGroupToColumn(columns[colIndex], group); - } - - return columns.map((col) => col.blocks); - } - - /** - * Row-first packing (groups round-robin across columns, like core skill row sort). - * - * @param {object[]} groups - * @param {number} numCols - * @returns {object[][]} - */ - _packSkillGroupsByRow(groups, numCols) { - const columns = Array.from({ length: numCols }, () => ({ - blocks: [], - height: 0, - })); - - for (let i = 0; i < groups.length; i++) { - this._appendSkillGroupToColumn(columns[i % numCols], groups[i]); - } - - return columns.map((col) => col.blocks); - } - - /** - * Builds grouped typed-skill columns for the V2 skills tab. - * - * @returns {object[][]} - */ - _prepareTypedSkillColumns() { - const groupsMap = new Map(); - - for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) { - const groupKey = skill.group; - if (!groupsMap.has(groupKey)) { - groupsMap.set(groupKey, { - group: groupKey, - label: game.i18n.localize(`DG.TypeSkills.${groupKey}`), - skills: [], - rowCount: 0, - }); - } - - groupsMap.get(groupKey).skills.push({ - ...skill, - key, - actorType: this.actor.type, - }); - } - - const groups = [...groupsMap.values()].map((group) => { - group.skills.sort((a, b) => - a.label.localeCompare(b.label, game.i18n.lang), - ); - group.rowCount = 1 + group.skills.length; - return group; - }); - - groups.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); - - const trainings = this._prepareSpecialTrainingBlocks(); - if (trainings.length > 0) { - groups.push({ - group: "specialTraining", - isSpecialTraining: true, - label: game.i18n.localize("DG.Sheet.BlockHeaders.SpecialTraining"), - skills: trainings, - rowCount: 1 + trainings.length, - }); - } - - return this._packSkillGroupsIntoColumns(groups, 3); - } - - /** - * Sorted special-training blocks for the skills tab (single-column list). - * - * @returns {object[]} - */ - _prepareSpecialTrainingBlocks() { - const trainings = this._prepareSpecialTrainings().map((training) => ({ - ...training, - sortLabel: training.name.toUpperCase(), - actorType: this.actor.type, - })); - - trainings.sort((a, b) => - a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), - ); - - return trainings; - } - - static _openStatsEdit() { - const form = new ActorEditStatForm({ - actor: this.actor, - }); - form.render(true); - } -} diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js index d98b059..dbe7b4a 100644 --- a/module/sheets/agent-sheet.js +++ b/module/sheets/agent-sheet.js @@ -1,5 +1,12 @@ import DG, { BASE_TEMPLATE_PATH } from "../config.js"; +import { packSkillGroupsIntoColumns } from "../utils/skill-layout.js"; +import { + createSkillImprovementChatMessage, + evaluateSkillImprovementRolls, + getSkillImprovementFormulaAsPercent, +} from "../roll/skill-improvement-roll.js"; import DGActorSheet from "./base-actor-sheet.js"; +import ActorEditStatForm from "../applications/edit-stats.js"; const { renderTemplate } = foundry.applications.handlebars; @@ -7,15 +14,28 @@ const { renderTemplate } = foundry.applications.handlebars; export default class DGAgentSheet extends DGActorSheet { /** @override */ static DEFAULT_OPTIONS = /** @type {const} */ ({ + classes: ["agent-sheet"], + position: { width: 958, height: 700 }, actions: { // Resets. clearBondDamage: DGAgentSheet._clearBondDamage, resetBreakingPoint: DGAgentSheet._resetBreakingPoint, // Other actions. applySkillImprovements: DGAgentSheet._processSkillImprovements, + openStatsEdit: DGAgentSheet._openStatsEdit, + toggleCustomSkillsEdit: function toggleCustomSkillsEdit(event) { + event.preventDefault(); + event.stopPropagation(); + this._customSkillsEditMode = !this._customSkillsEditMode; + this._syncCustomSkillsEditModeUi(); + return this.render(); + }, }, }); + /** @type {boolean} */ + _customSkillsEditMode = false; + /** @override */ static TABS = /** @type {const} */ ({ primary: { @@ -23,11 +43,10 @@ export default class DGAgentSheet extends DGActorSheet { labelPrefix: "DG.Navigation.Agent", tabs: [ { id: "skills" }, - { id: "physical" }, + { id: "combat" }, { id: "motivations" }, { id: "gear" }, - { id: "bio" }, - { id: "bonds" }, + { id: "personal" }, { id: "about", icon: "fas fa-question-circle", label: "" }, ], }, @@ -35,33 +54,88 @@ export default class DGAgentSheet extends DGActorSheet { /** @override */ static PARTS = /** @type {const} */ ({ - header: this.BASE_PARTS.header, + leftBar: { + template: `${this.TEMPLATE_PATH}/parts/left-bar.html`, + templates: [`${this.TEMPLATE_PATH}/partials/sanity-partial.html`], + }, tabs: this.BASE_PARTS.tabs, - skills: this.BASE_PARTS.skills, - physical: { - template: `${this.TEMPLATE_PATH}/parts/physical-tab.html`, + rightBar: { + template: `${this.TEMPLATE_PATH}/parts/right-bar.html`, templates: [ - `${this.TEMPLATE_PATH}/partials/attributes-grid-partial.html`, + `${this.TEMPLATE_PATH}/parts/skills-tab-agent.html`, + `${this.TEMPLATE_PATH}/parts/combat-tab-agent.html`, + `${this.TEMPLATE_PATH}/parts/motivations-tab-agent.html`, + `${this.TEMPLATE_PATH}/parts/gear-tab-agent.html`, + `${this.TEMPLATE_PATH}/parts/personal-tab-agent.html`, + `${this.TEMPLATE_PATH}/parts/about-tab.html`, + `${this.TEMPLATE_PATH}/partials/custom-skills-partial-agent.html`, + `${this.TEMPLATE_PATH}/partials/bonds-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/weapons-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/armor-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/injuries-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/other-gear-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/notes-partial.html`, ], - scrollable: [""], }, - motivations: { - template: `${this.TEMPLATE_PATH}/parts/motivations-tab.html`, - }, - gear: this.BASE_PARTS.gear, - bio: { - template: `${this.TEMPLATE_PATH}/parts/bio-tab.html`, - templates: [`${this.TEMPLATE_PATH}/partials/notes-partial.html`], - scrollable: [""], - }, - bonds: { - template: `${this.TEMPLATE_PATH}/parts/bonds-tab.html`, - scrollable: [""], - }, - about: this.BASE_PARTS.about, }); - /* -------------------------------------------- */ + /** @override */ + _configureRenderOptions(options) { + super._configureRenderOptions(options); + + if (!this.actor.limited) return; + + options.parts = ["leftBar", "tabs", "rightBar"]; + } + + /** @override */ + _prepareTabs(group) { + const tabs = super._prepareTabs(group); + + if (!this.actor.limited || this.actor.type !== "agent") return tabs; + + return { + personal: { ...tabs.personal, active: true, cssClass: "active" }, + }; + } + + /** @override */ + async _prepareContext(options) { + const context = await super._prepareContext(options); + + if (this.actor.type !== "agent") return context; + + context.typedSkillColumns = this._prepareTypedSkillColumns(); + context.customSkillsEditMode = this._customSkillsEditMode; + + return context; + } + + /** @override */ + _sortCustomSkills() { + // Agent sheet uses typedSkillColumns instead of sortedCustomSkills. + } + + /** @override */ + async _onRender(context, options) { + await super._onRender(context, options); + this._syncCustomSkillsEditModeUi(); + } + + /** @returns {void} */ + _syncCustomSkillsEditModeUi() { + this.element?.classList.toggle( + "custom-skills-edit-mode", + Boolean(this._customSkillsEditMode), + ); + } + + static _clearBondDamage() { + for (const bond of this.actor.itemTypes.bond) { + if (!bond.system.hasBeenDamagedSinceLastHomeScene) continue; + bond.update({ "system.hasBeenDamagedSinceLastHomeScene": false }); + } + } /** * Resets the actor's current breaking point by recalculating it as the difference @@ -93,6 +167,86 @@ export default class DGAgentSheet extends DGActorSheet { this.actor.update(dataToUpdate); } + /** + * Builds grouped typed-skill columns for the skills tab. + * + * @returns {object[][]} + */ + _prepareTypedSkillColumns() { + const groupsMap = new Map(); + + for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) { + const groupKey = skill.group; + if (!groupsMap.has(groupKey)) { + groupsMap.set(groupKey, { + group: groupKey, + label: game.i18n.localize(`DG.TypeSkills.${groupKey}`), + skills: [], + rowCount: 0, + }); + } + + groupsMap.get(groupKey).skills.push({ + ...skill, + key, + actorType: this.actor.type, + }); + } + + const groups = [...groupsMap.values()].map((group) => { + group.skills.sort((a, b) => + a.label.localeCompare(b.label, game.i18n.lang), + ); + group.rowCount = 1 + group.skills.length; + return group; + }); + + groups.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); + + const trainings = this._prepareSpecialTrainingBlocks(); + if (trainings.length > 0) { + groups.push({ + group: "specialTraining", + isSpecialTraining: true, + label: game.i18n.localize("DG.Sheet.BlockHeaders.SpecialTraining"), + skills: trainings, + rowCount: 1 + trainings.length, + }); + } + + return packSkillGroupsIntoColumns( + groups, + 3, + game.settings.get("deltagreen", "sortSkills"), + ); + } + + /** + * Sorted special-training blocks for the skills tab (single-column list). + * + * @returns {object[]} + */ + _prepareSpecialTrainingBlocks() { + const trainings = this._prepareSpecialTrainings().map((training) => ({ + ...training, + sortLabel: training.name.toUpperCase(), + actorType: this.actor.type, + })); + + trainings.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + + return trainings; + } + + static _openStatsEdit() { + const form = new ActorEditStatForm({ + actor: this.actor, + }); + form.render(true); + } + /** * Runs through the whole process of improving skills, * i.e., prompting the user, rolling, and creating the chat card. @@ -126,18 +280,21 @@ export default class DGAgentSheet extends DGActorSheet { if (!prompt) return null; - const resultObj = await this._createSkillImprovementRolls( + const resultObj = await evaluateSkillImprovementRolls( + this.actor, baseRollFormula, failedSkills, failedTypedSkills, ); - await this._createSkillImprovementChatCard( - baseRollFormula, + await createSkillImprovementChatMessage({ + actor: this.actor, + token: this.token, + baseFormula: baseRollFormula, failedSkills, failedTypedSkills, resultObj, - ); + }); return this._applySkillImprovements( failedSkills, @@ -198,8 +355,7 @@ export default class DGAgentSheet extends DGActorSheet { `${BASE_TEMPLATE_PATH}/dialog/apply-skill-improvements.html`, { failedSkillNames, - baseFormula: - DGAgentSheet._getSkillImprovementFormulaAsPercent(baseFormula), + baseFormula: getSkillImprovementFormulaAsPercent(baseFormula), }, ); @@ -219,110 +375,6 @@ export default class DGAgentSheet extends DGActorSheet { }); } - /** - * Generates and evaluates the rolls for skill improvements based on failed skills. - * - * @param {SkillImprovementFormula} baseFormula - The formula used to calculate skill improvements. - * @param {FailedSkill[]} failedSkills - An array of failed skills. - * @param {FailedTypedSkill[]} failedTypedSkills - An array of failed typed skills. - * - * @returns {Promise<{roll: Roll|undefined, resultObj: ResultObj}>} - An object containing the roll result and a map of skill keys to improvement values. - * - * @throws {Error} - Throws an error if the baseFormula is unknown. - */ - async _createSkillImprovementRolls( - baseFormula, - failedSkills, - failedTypedSkills, - ) { - const totalFailures = failedSkills.length + failedTypedSkills.length; - - if (!Object.keys(DG.skillImprovementFormulas).includes(baseFormula)) { - throw new Error(`Unknown roll formula: ${baseFormula}`); - } - - const rollPromises = []; - const resultObj = {}; - if (baseFormula !== "1") { - for (let i = 0; i < totalFailures; i++) { - rollPromises.push(new Roll(baseFormula, this.actor.system).evaluate()); - } - const rolls = await Promise.all(rollPromises); - - [...failedSkills, ...failedTypedSkills].forEach((skill, index) => { - resultObj[skill.key] = rolls[index].total; - }); - } - - return resultObj; - } - - /** - * Create a chat card to record skill improvements. - * - * @param {FailedSkill[]} failedSkills - array of failed skills - * @param {FailedTypedSkill[]} failedTypedSkills - array of failed typed skills - * @param {Roll|undefined} roll - the improvement roll - * @param {ResultObj} resultObj - * - * @returns {Promise} - */ - _createSkillImprovementChatCard( - baseFormula, - failedSkills, - failedTypedSkills, - resultObj, - ) { - const localizeFailedSkills = (skillsArray) => { - return skillsArray.map((skill) => { - const increment = resultObj[skill.key] ?? 1; - const label = - skill.label ?? game.i18n.localize(`DG.Skills.${skill.key}`); // fallback for regular skills - const groupLabel = skill.group - ? `${game.i18n.localize( - `DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`, - )} (${label})` - : label; - - return `${groupLabel}: +${increment}%`; - }); - }; - - const failedSkillNames = localizeFailedSkills(failedSkills); - const failedTypedSkillNames = localizeFailedSkills(failedTypedSkills); - - // Prepare chat data - const content = [...failedSkillNames, ...failedTypedSkillNames].join(", "); - const flavor = game.i18n.format( - "DG.Skills.ApplySkillImprovements.ChatFlavor", - { - formula: DGAgentSheet._getSkillImprovementFormulaAsPercent(baseFormula), - }, - ); - - const chatData = { - speaker: ChatMessage.getSpeaker({ - actor: this.actor, - token: this.token, - alias: this.actor.name, - }), - content, - flavor, - messageMode: game.settings.get("core", "messageMode"), - }; - - return ChatMessage.create(chatData); - } - - /** - * Updates the actor's skills / typed skills with the improvements, - * persisting the changes to the database. - * - * @param {FailedSkill[]} failedSkills - array of failed skills that need to be updated - * @param {FailedTypedSkill[]} failedTypedSkills - array of failed skills that need to be updated - * @param {ResultObj} resultObj - * @returns {Promise} - the update promise - */ _applySkillImprovements(failedSkills, failedTypedSkills, resultObj) { const updateSkills = (skillsArray, updatedTarget) => { skillsArray.forEach((skill) => { @@ -344,17 +396,4 @@ export default class DGAgentSheet extends DGActorSheet { system: { skills: updatedSkills, typedSkills: updatedTypedSkills }, }); } - - /** - * Takes a base formula and returns a string representing the skill improvement formula as a percentage. - * TODO: Move to the roll class itself, probably even a "SkillImprovementRoll". - * - * @param {SkillImprovementFormula} baseFormula - the base formula to convert - * @returns {string} the skill improvement formula as a percentage (e.g. "1D3%" or "1%") - */ - static _getSkillImprovementFormulaAsPercent(baseFormula) { - const flat = baseFormula === "1"; - const formula = flat ? "1" : `1${baseFormula}`; - return `${formula}%`.toUpperCase(); - } } diff --git a/module/sheets/base-actor-sheet.js b/module/sheets/base-actor-sheet.js index 5d988c4..96a675b 100644 --- a/module/sheets/base-actor-sheet.js +++ b/module/sheets/base-actor-sheet.js @@ -1,30 +1,35 @@ -import DG from "../config.js"; -import { ACTOR_RICH_TEXT_BY_TYPE } from "../config/rich-text-fields.js"; -import { - DGPercentileRoll, - createDGRollFromDataset, - processDGRoll, -} from "../roll/roll.js"; import DGSheetMixin from "./base-sheet.js"; +import composeMixins from "./mixins/compose-mixins.js"; +import ItemListMixin from "./mixins/item-list-mixin.js"; +import RollSheetMixin from "./mixins/roll-sheet-mixin.js"; +import SkillPrepMixin from "./mixins/skill-prep-mixin.js"; +import SpecialTrainingMixin from "./mixins/special-training-mixin.js"; +import TypedSkillMixin from "./mixins/typed-skill-mixin.js"; const { ActorSheetV2 } = foundry.applications.sheets; -const { DialogV2 } = foundry.applications.api; -export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { +const ComposedActorSheetBase = composeMixins( + SpecialTrainingMixin, + TypedSkillMixin, + ItemListMixin, + SkillPrepMixin, + RollSheetMixin, + DGSheetMixin, +)(ActorSheetV2); + +/** @extends {ComposedActorSheetBase} */ +export default class DGActorSheet extends ComposedActorSheetBase { /** @override */ static DEFAULT_OPTIONS = /** @type {const} */ ({ position: { width: 750, height: 770 }, actions: { - // Skill/Item actions. itemAction: this._onItemAction, typedSkillAction: this._onTypedSkillAction, specialTrainingAction: this._onSpecialTrainingAction, roll: this._onRoll, rollLuck: this._onRollLuck, - // Toggles. toggle: this._toggleGeneric, toggleItemSortMode: this._toggleItemSortMode, - // Other actions. browsePack: this._browsePack, }, }); @@ -43,7 +48,7 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { ], }, tabs: { - template: `templates/generic/tab-navigation.hbs`, // From FoundryVTT + template: `templates/generic/tab-navigation.hbs`, }, skills: { template: `${this.TEMPLATE_PATH}/parts/skills-tab.html`, @@ -63,66 +68,32 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { }, }); - /** @override */ - async _processSubmitData(event, form, submitData, options) { - const submittedData = foundry.utils.expandObject(submitData); - - if (this.actor.type !== "unnatural") - return super._processSubmitData(event, form, submittedData, options); - - // we need to update max WP if POW has changed (only for unnatural, but doesn't hurt others) - submittedData.system.wp.maxNeedsUpdate = - submittedData.system?.statistics.pow.value !== - this.actor.system.statistics.pow.value; - - // we need to update max HP if STR or CON have changed (only for unnatural, but doesn't hurt others) - submittedData.system.health.maxNeedsUpdate = - submittedData.system?.statistics.str.value !== - this.actor.system.statistics.str.value || - submittedData.system?.statistics.con.value !== - this.actor.system.statistics.con.value; - - return super._processSubmitData(event, form, submittedData, options); - } - /** @override */ async _prepareContext(options) { const context = await super._prepareContext(options); - // Prepare items. this._prepareCharacterItems(context); context.showHyperGeometrySection = this.shouldShowHyperGeometrySection( this.actor, ); - // Prepare subname info placeholder. context.subnameInfoPlaceholder = this._prepareSubnameInfoPlaceholder(); - await this._prepareRichTextContext( - context, - ACTOR_RICH_TEXT_BY_TYPE[this.actor.type] ?? [], - ); - - // Early return if this is a vehicle. if (this.actor.type === "vehicle") return context; - // Provide the sheet context sorted skills. this._sortSkills(); this._sortCustomSkills(); - // Skill tooltip display setting context.skillTooltipDisplay = game.settings.get( "deltagreen", "skillTooltipDisplay", ); if (context.skillTooltipDisplay !== "never") { - // Setup tooltips this._prepareSkillTooltips(); } - // Handle private sanity setting, override for GMs. const keepSanityPrivate = game.settings.get( "deltagreen", "keepSanityPrivate", @@ -133,30 +104,26 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { context.currentSan = hideSan ? "???" : this.actor.system.sanity.value; context.keepSanityPrivate = keepSanityPrivate; - // Set sanity block per actor type. context.sanityInputs = await foundry.applications.handlebars.renderTemplate( `${DGActorSheet.TEMPLATE_PATH}/partials/sanity-${this.actor.type}.html`, context, ); - // Set title for Physical Attributes. context.physicalAttributesTitle = game.i18n.localize( "DG.Sheet.BlockHeaders.Statistics", ); - // Whether to append the notes section to the skills. context.showNotesInSkills = this.actor.type !== "agent"; return context; } - /** @override - Manipulate which PARTS are rendered. */ + /** @override */ _configureRenderOptions(options) { super._configureRenderOptions(options); if (!this.actor.limited) return; - // Hide most PARTS on limited sheets. options.parts = ["header"]; if (this.actor.type === "agent") options.parts.push("tabs", "bio"); @@ -165,18 +132,15 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { /** @override */ async _onFirstRender(context, options) { await super._onFirstRender(context, options); - this._setRightClickListeners(); } - /** @override - Manipulate which TABS are rendered. */ + /** @override */ _prepareTabs(group) { const tabs = super._prepareTabs(group); - // Hide most TABS on limited sheets. if (!this.actor.limited || this.actor.type !== "agent") return tabs; - // Override settings to activate bio tab (only tab shown). return { bio: { ...tabs.bio, active: true, cssClass: "active" } }; } @@ -185,23 +149,16 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { await super._onRender(context, options); const { element } = this; - // Reset height if this is a limited sheet. if (this.actor.limited) { this.setPosition({ height: "auto" }); this.setPosition({ height: Number.parseInt(this.element.style.height) }); } - const handler = (ev) => this._onDragStart(ev); - element.querySelectorAll("li.item").forEach((li) => { - if (li.classList.contains("inventory-header")) return; - li.setAttribute("draggable", true); - li.addEventListener("dragstart", handler, false); - }); - - this._tooltipsSettings(this.element); + this._setupItemDragListeners(); + this._tooltipsSettings(element); } - /** @override - Add buttons to the header controls. */ + /** @override */ _getHeaderControls() { const controls = super._getHeaderControls(); controls.push({ @@ -211,1164 +168,4 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { }); return controls; } - - /** @override */ - _onDragStart(event) { - // Most of this is the standard Foundry implementation of _onDragStart - const li = event.currentTarget; - if (event.target.classList.contains("content-link")) return; - - // Create drag data - let dragData; - - // Owned Items - if (li.dataset.itemId) { - const item = this.actor.items.get(li.dataset.itemId); - dragData = item.toDragData(); - } - - // Active Effect - if (li.dataset.effectId) { - const effect = this.actor.effects.get(li.dataset.effectId); - dragData = effect.toDragData(); - } - - if (!dragData) return; - - // this is custom, grab item data for creating item macros on the hotbar - if (li.dataset.itemId) { - const item = this.actor.items.get(li.dataset.itemId); - dragData.itemData = item; - } - - // Set data transfer - event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); - } - - /** @override */ - async _onDrop(event) { - super._onDrop(event); - // If alt key is held down, we will delete the original document. - if (event.altKey) { - // This is from Foundry. It will get the item data from the event. - const TextEditor = foundry.applications.ux.TextEditor.implementation; - const dragData = TextEditor.getDragEventData(event); - // Make sure that we are dragging an item, otherwise this doesn't make sense. - if (dragData.type === "Item") { - const item = fromUuidSync(dragData.uuid); - await item.delete(); - } - } - } - - /* --------- Context Preparation Functions --------- */ - - /** - * Prepares the subname info placeholder based on the actor type. - * Returns a localized string that acts as a placeholder for different - * types of actors, such as "unnatural", "vehicle", or defaulting to - * "agent" type. - * - * @returns {string} The localized placeholder string for the actor's subname info. - */ - _prepareSubnameInfoPlaceholder() { - let subnameInfoPlaceholder = ""; - switch (this.actor.type) { - case "unnatural": - subnameInfoPlaceholder = - "DG.UnnaturalSheet.ShortDescriptionPlaceholder"; - break; - case "vehicle": - subnameInfoPlaceholder = "DG.VehicleSheet.DescriptionPlaceHolder"; - break; - default: - subnameInfoPlaceholder = "DG.AgentSheet.ProfessionPlaceholder"; - break; - } - return game.i18n.localize(subnameInfoPlaceholder); - } - - /** - * Sorts the skills on the actor sheet based on the appropriate localized entry. - * If the localized entry is not found, the sort label is the skill key. - * - * @returns {void} - */ - _sortSkills() { - // fill an array that is sorted based on the appropriate localized entry - const sortedSkills = []; - for (const [key, skill] of Object.entries(this.actor.system.skills)) { - skill.key = key; - - if (game.i18n.lang === "ja") { - skill.sortLabel = game.i18n.localize(`DG.Skills.ruby.${key}`); - } else { - skill.sortLabel = game.i18n.localize(`DG.Skills.${key}`); - } - - if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) { - skill.sortLabel = skill.key; - } - - // if the actor is an NPC or Unnatural, and they have 'hide untrained skills' active, - // it will break the sorting logic, so we have to skip over these - if ( - !( - (this.actor.type === "npc" || this.actor.type === "unnatural") && - this.actor.system.showUntrainedSkills && - skill.proficiency < 1 - ) - ) { - sortedSkills.push(skill); - } - } - - sortedSkills.sort((a, b) => { - return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang); - }); - - // if sorting by columns, re-arrange the array to be columns first, then rows - if (game.settings.get("deltagreen", "sortSkills")) { - const columnSortedSkills = this.reorderForColumnSorting(sortedSkills, 3); - this.actor.system.sortedSkills = columnSortedSkills; - } else { - this.actor.system.sortedSkills = sortedSkills; - } - } - - /** - * Sorts the custom skills on the actor sheet based on the appropriate localized entry. - * If the localized entry is not found, the sort label is the skill key. - * If sorting by columns, re-arranges the array to be columns first, then rows. - * - * @returns {void} - */ - _sortCustomSkills() { - // Prepare a simplified version of the special training for display on sheet. - const specialTrainings = this._prepareSpecialTrainings(); - - // try to make a combined array of both typed skills and special trainings, - // so that it can be sorted together neatly on the sheet - const sortedCustomSkills = []; - - for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) { - skill.type = "typeSkill"; - skill.key = key; - skill.sortLabel = `${skill.group}.${skill.label}`; - skill.sortLabel = skill.sortLabel.toUpperCase(); - skill.actorType = this.actor.type; - - if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) { - skill.sortLabel = skill.key; - } - - sortedCustomSkills.push(skill); - } - - for (let i = 0; i < specialTrainings.length; i++) { - const training = specialTrainings[i]; - - training.type = "training"; - training.sortLabel = training.name.toUpperCase(); - training.actorType = this.actor.type; - - sortedCustomSkills.push(training); - } - - sortedCustomSkills.sort((a, b) => { - return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang); - }); - - if (game.settings.get("deltagreen", "sortSkills")) { - const columnSortedSkills = this.reorderForColumnSorting( - sortedCustomSkills, - 2, - ); - - this.actor.system.sortedCustomSkills = columnSortedSkills; - } else { - this.actor.system.sortedCustomSkills = sortedCustomSkills; - } - } - - /** - * Sets up the list of sorted skills with their respective tooltips. - * - * @returns {void} - */ - _prepareSkillTooltips() { - for (const skill of Object.values(this.actor.system.sortedSkills)) { - skill.tooltip = game.i18n.localize(`DG.Skills.Tooltip.${skill.key}`); - if (!skill.proficiency) { - skill.tooltip = skill.tooltip.concat( - "

", - game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"), - ); - } - } - } - - /** - * Prepares a simplified version of the special training for display on sheet. - * - * It takes the special training from the actor's system and converts the - * machine-readable name to a human-readable one. It also adds the proficiency - * of the special training to the object. - * - * @returns {object[]} An array of simplified special training objects. - */ - _prepareSpecialTrainings() { - return this.actor.system.specialTraining.map((training) => { - const simplifiedTraining = { - name: training.name, - id: training.id, - key: training.attribute, - }; - // Convert the machine-readable name to a human-readable one. - switch (true) { - // Stats - case DG.statistics.includes(training.attribute): - simplifiedTraining.attribute = `${training.attribute.toUpperCase()}x5`; - simplifiedTraining.targetNumber = - this.actor.system.statistics[training.attribute].x5; - break; - // Skills - case DG.skills.includes(training.attribute): - simplifiedTraining.attribute = - this.actor.system.skills[training.attribute].label; - simplifiedTraining.targetNumber = - this.actor.system.skills[training.attribute].proficiency; - break; - // Typed Skills - default: - simplifiedTraining.attribute = - this.actor.system.typedSkills[training.attribute].label; - simplifiedTraining.targetNumber = - this.actor.system.typedSkills[training.attribute].proficiency; - break; - } - return simplifiedTraining; - }); - } - - /** - * Listens for right click events on the actor sheet and executes a regular roll, - * or luck roll, depending on the action. - * - * @returns {void} - */ - _setRightClickListeners() { - const { element } = this; - element.addEventListener("contextmenu", (event) => { - event.preventDefault(); - const target = event.target.closest( - "[data-action='roll'],[data-action='rollLuck']", - ); - if (!target) return; - - // Call _onRollLuck if luckRoll is the dispatched action. - if (target.dataset.action === "rollLuck") { - DGActorSheet._onRollLuck.call(this, event, target); - return; - } - - // Otherwise, call _onRoll function. - DGActorSheet._onRoll.call(this, event, target); - }); - } - - // some handlers may wish to avoid leading players to think they should be seeking out magic - // so control whether an actor sheet shows the hypergeometry (rituals and tomes) section - shouldShowHyperGeometrySection(actor) { - // always show for GM - if (game.user.isGM) { - return true; - } - - // check system setting to see if it should always be shown - if ( - game.settings.get( - "deltagreen", - "alwaysShowHypergeometrySectionForPlayers", - ) - ) { - return true; - } - - // otherwise only show if an actor has an item of that type added to their sheet. - for (const i of actor.items) { - if (i.type === "tome" || i.type === "ritual") { - return true; - } - } - - return false; - } - - /** - * Organize and classify Items for Character sheets. - * - * @param {Object} actorData The actor to prepare. - * - * @return {undefined} - */ - _prepareCharacterItems() { - const { actor } = this; - - // Initialize containers. - const armor = []; - const weapons = []; - const gear = []; - const tomes = []; - const rituals = []; - - // Iterate through items, allocating to containers - // let totalWeight = 0; - for (const i of actor.items) { - // Append to armor. - if (i.type === "armor") { - armor.push(i); - } - // Append to weapons. - else if (i.type === "weapon") { - weapons.push(i); - } else if (i.type === "gear") { - gear.push(i); - } else if (i.type === "tome") { - tomes.push(i); - } else if (i.type === "ritual") { - rituals.push(i); - } - } - - if (actor.system.settings.sorting.armorSortAlphabetical) { - armor.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - armor.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.weaponSortAlphabetical) { - weapons.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - weapons.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.gearSortAlphabetical) { - gear.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - gear.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.tomeSortAlphabetical) { - tomes.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - tomes.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.ritualSortAlphabetical) { - rituals.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - rituals.sort((a, b) => { - return a.sort - b.sort; - }); - } - - // Assign and return - actor.armor = armor; - actor.weapons = weapons; - actor.gear = gear; - actor.rituals = rituals; - actor.tomes = tomes; - } - - reorderForColumnSorting(arr, numCols) { - const numRows = Math.ceil(arr.length / numCols); // Compute required rows - const reordered = new Array(arr.length); - - // Determine how many elements each column gets - const baseRowCount = Math.floor(arr.length / numCols); // Minimum rows per column - const extraColumns = arr.length % numCols; // Some columns get an extra row - - const colHeights = new Array(numCols).fill(baseRowCount); - for (let i = 0; i < extraColumns; i++) { - colHeights[i] += 1; // Give extra elements to the first N columns - } - - let index = 0; // move through alphabetical array, keeping track of what we've resorted already - - for (let col = 0; col < numCols; col++) { - // need to check if this is a column that has more rows than the others or not - const rowCount = colHeights[col]; - - // loop down the column, filling out it's values from the alphabetical array - for (let row = 0; row < rowCount; row++) { - // calculate the new position for this value by column - const newIndex = numCols * row + col; - - if (newIndex < arr.length) { - reordered[newIndex] = arr[index]; - index += 1; - } - } - } - - return reordered; - } - - static _onItemAction(event, target) { - const li = target.closest(".item"); - const { itemId } = li.dataset; - const { actionType, itemType } = target.dataset; - - switch (actionType) { - case "create": - this._onItemCreate(itemType); - break; - case "edit": { - const item = this.actor.items.get(itemId); - item.sheet.render(true); - break; - } - case "delete": { - this.actor.deleteEmbeddedDocuments("Item", [itemId]); - break; - } - default: - break; - } - } - - /** - * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset - * @param {String} type The originating click event - * @private - */ - _onItemCreate(type) { - // Initialize a default name. - const name = game.i18n.format("DG.FallbackText.newItem", { - type: game.i18n.localize(`TYPES.Item.${type}`), - }); - - // Prepare the item object. - const itemData = { - name, - type, - system: {}, - }; - - if (type === "weapon") { - // itemData.system.skill = "firearms"; //default skill to firearms, since that will be most common - // itemData.system.expense = "Standard"; - } else if (type === "armor") { - // itemData.system.armor = 3; - // itemData.system.expense = "Standard"; - } else if (type === "bond") { - // try to default bonds for an agent to their current CHA - itemData.system.score = this.actor.system.statistics.cha.value; // Can vary, but at character creation starting bond score is usually agent's charisma - // itemData.img = "icons/svg/mystery-man.svg" - } - - // create the item - return this.actor.createEmbeddedDocuments("Item", [itemData]); - } - - static _onTypedSkillAction(event, target) { - const { actionType, typedskill } = target.dataset; - switch (actionType) { - case "create": - this._showNewTypeSkillDialog(); - break; - case "edit": - this._showNewEditTypeSkillDialog(typedskill); - break; - case "delete": - this.actor.update({ [`system.typedSkills.-=${typedskill}`]: null }); - break; - default: - break; - } - } - - _showNewEditTypeSkillDialog(targetSkill) { - // TO DO: BUILD DIALOG TO CAPTURE UPDATED DATA - - const { typedSkills } = this.actor.system; - const currentLabel = typedSkills[targetSkill].label; - const currentGroup = typedSkills[targetSkill].group; - - let htmlContent = `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - new DialogV2({ - content: htmlContent, - window: { - title: - game.i18n.translations.DG?.Skills?.EditTypedOrCustomSkill ?? - "Edit Typed or Custom Skill", - }, - buttons: [ - { - default: true, - action: "add", - label: game.i18n.translations.DG?.Skills?.EditSkill ?? "Edit Skill", - callback: (event, button, dialog) => { - const newTypeSkillLabel = dialog.element.querySelector( - "[name='new-type-skill-label']", - )?.value; - const newTypeSkillGroup = dialog.element.querySelector( - "[name='new-type-skill-group']", - )?.value; - this._updateTypedSkill( - targetSkill, - newTypeSkillLabel, - newTypeSkillGroup, - ); - }, - }, - ], - }).render(true); - } - - _showNewTypeSkillDialog() { - let htmlContent = ""; - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - new DialogV2({ - content: htmlContent, - window: { - title: - game.i18n.translations.DG?.Skills?.AddTypedOrCustomSkill ?? - "Add Typed or Custom Skill", - }, - default: "add", - buttons: [ - { - default: true, - action: "add", - label: game.i18n.translations.DG?.Skills?.AddSkill ?? "Add Skill", - callback: (event, button, dialog) => { - const newTypeSkillLabel = dialog.element.querySelector( - "[name='new-type-skill-label']", - )?.value; - const newTypeSkillGroup = dialog.element.querySelector( - "[name='new-type-skill-group']", - )?.value; - this._addNewTypedSkill(newTypeSkillLabel, newTypeSkillGroup); - }, - }, - ], - }).render(true); - } - - _addNewTypedSkill(newSkillLabel, newSkillGroup) { - const updatedData = foundry.utils.duplicate(this.actor.system); - const { typedSkills } = updatedData; - - const d = new Date(); - - const newSkillPropertyName = - d.getFullYear().toString() + - (d.getMonth() + 1).toString() + - d.getDate().toString() + - d.getHours().toString() + - d.getMinutes().toString() + - d.getSeconds().toString(); - // console.log(newSkillPropertyName); - typedSkills[newSkillPropertyName] = { - label: newSkillLabel, - group: newSkillGroup, - proficiency: 10, - failure: false, - }; - - updatedData.typedSkills = typedSkills; - - this.actor.update({ system: updatedData }); - } - - _updateTypedSkill(targetSkill, newSkillLabel, newSkillGroup) { - if ( - newSkillLabel !== null && - newSkillLabel !== "" && - newSkillGroup !== null && - newSkillGroup !== "" - ) { - const updatedData = foundry.utils.duplicate(this.actor.system); - - updatedData.typedSkills[targetSkill].label = newSkillLabel; - updatedData.typedSkills[targetSkill].group = newSkillGroup; - - this.actor.update({ system: updatedData }); - } - } - - static _onSpecialTrainingAction(event, target) { - const { actionType, id } = target.dataset; - switch (actionType) { - case "delete": - { - const specialTrainingArray = foundry.utils.duplicate( - this.actor.system.specialTraining, - ); - - // Get the index of the training to be deleted - const index = specialTrainingArray.findIndex( - (training) => training.id === id, - ); - - specialTrainingArray.splice(index, 1); - this.actor.update({ "system.specialTraining": specialTrainingArray }); - } - break; - default: - this._showSpecialTrainingDialog(actionType, id); - break; - } - } - - async _showSpecialTrainingDialog(action, targetID) { - const specialTraining = this.actor.system.specialTraining.find( - (training) => training.id === targetID, - ); - - // Define the option groups for our drop-down menu. - const optionGroups = { - stats: game.i18n.localize( - "DG.SpecialTraining.Dialog.DropDown.Statistics", - ), - skills: game.i18n.localize("DG.SpecialTraining.Dialog.DropDown.Skills"), - typedSkills: game.i18n.localize( - "DG.SpecialTraining.Dialog.DropDown.CustomSkills", - ), - }; - - // Prepare simplified stat list - const statList = Object.entries(this.actor.system.statistics).map( - ([key, stat]) => ({ - value: key, - group: optionGroups.stats, - label: game.i18n.localize(`DG.Attributes.${key}`), - targetNumber: stat.value * 5, - }), - ); - - // Prepare simplified skill list - const skillList = Object.entries(this.actor.system.skills).map( - ([key, skill]) => ({ - value: key, - group: optionGroups.skills, - label: game.i18n.localize(`DG.Skills.${key}`), - targetNumber: skill.proficiency, - }), - ); - - // Prepare simplified typed/custom skill list - const typedSkillList = Object.entries(this.actor.system.typedSkills).map( - ([key, skill]) => ({ - value: key, - group: optionGroups.typedSkills, - label: `${game.i18n.localize(`DG.TypeSkills.${skill.group}`)} (${ - skill.label - })`, - targetNumber: skill.proficiency, - }), - ); - - // Prepare the Select element - const selectElement = foundry.applications.fields.createSelectInput({ - name: "special-training-skill", - options: [...statList, ...skillList, ...typedSkillList], - groups: Object.values(optionGroups), - }).outerHTML; - - // Prepare the template to feed to Dialog. - const { renderTemplate } = foundry.applications.handlebars; - const content = await renderTemplate( - "systems/deltagreen/templates/dialog/special-training.html", - { - name: specialTraining?.name || "", - selectElement, - currentAttribute: specialTraining?.attribute || "", - statList, - skillList, - typedSkillList, - }, - ); - - const buttonLabel = game.i18n.localize( - `DG.SpecialTraining.Dialog.${action.capitalize()}SpecialTraining`, - ); - - // Prepare and render dialog with above template. - new DialogV2({ - content, - window: { - title: game.i18n.localize("DG.SpecialTraining.Dialog.Title"), - }, - default: "confirm", - buttons: [ - { - default: true, - action: "confirm", - label: buttonLabel, - callback: (event, button, dialog) => { - const specialTrainingLabel = dialog.element.querySelector( - "[name='special-training-label']", - )?.value; - const specialTrainingAttribute = dialog.element.querySelector( - "[name='special-training-skill']", - )?.value; - if (action === "create") - this._createSpecialTraining( - specialTrainingLabel, - specialTrainingAttribute, - ); - if (action === "edit") - this._editSpecialTraining( - specialTrainingLabel, - specialTrainingAttribute, - targetID, - ); - }, - }, - ], - }).render(true); - } - - _createSpecialTraining(label, attribute) { - const specialTrainingArray = foundry.utils.duplicate( - this.actor.system.specialTraining, - ); - specialTrainingArray.push({ - name: label, - attribute, - id: foundry.utils.randomID(), - }); - this.actor.update({ "system.specialTraining": specialTrainingArray }); - } - - _editSpecialTraining(label, attribute, id) { - const specialTrainingArray = foundry.utils.duplicate( - this.actor.system.specialTraining, - ); - const specialTraining = specialTrainingArray.find( - (training) => training.id === id, - ); - specialTraining.name = label; - specialTraining.attribute = attribute; - this.actor.update({ "system.specialTraining": specialTrainingArray }); - } - - /** - * Handle clickable rolls. - * - * @param {Event} event The originating click event - * @async - * @private - */ - static async _onRoll(event, target) { - if (target.classList.contains("not-rollable") || event.which === 2) return; - - const item = this.actor.items.get(target.dataset.iid); - const roll = createDGRollFromDataset(target.dataset, { - actor: this.actor, - item, - element: target, - sanityDamageSource: "actor", - }); - await this.processRoll(event, roll); - } - - // This only exists to give a chance to activate the modifier dialogue if desired - // Cannot seem to trigger the event on a right-click, so unfortunately only applies to a shift-click currently. - static async _onRollLuck(event) { - // probably don't want rolls to trigger from a middle mouse click so just kill it here - if (event.which === 2) return; - - const rollOptions = { - rollType: "luck", - key: "luck", - actor: this.actor, - }; - - // Create a default 1d100 roll just in case. - const roll = new DGPercentileRoll("1D100", {}, rollOptions); - // Open dialog if user requests it. - if (event.shiftKey || event.which === 3) { - const dialogData = await roll.showDialog(); - if (!dialogData) return; - roll.modifier += dialogData.targetModifier; - if (dialogData.messageMode) { - roll.options.messageMode = dialogData.messageMode; - } - } - // Evaluate the roll. - await roll.evaluate(); - // Send the roll to chat. - roll.toChat(); - } - - /** - * Show a dialog for the roll and then send to chat. - * Broke this logic out from `_onRoll()` so that other files can call it, - * namely the macro logic. - * - * TODO: Move this logic to the roll.js. - * - * @param {Event} event The originating click event - * @param {Event} roll The roll to show a dialog for and then send to chat. - * @async - */ - async processRoll(event, roll) { - return processDGRoll(event, roll); - } - - /** Handler for generic toggle events */ - static _toggleGeneric(event, target) { - const { prop } = target.dataset; - const itemId = target.closest("[data-item-id]")?.dataset.itemId; - this.toggle(prop, itemId); - } - - /** Handler for item-sort-mode toggle events */ - static _toggleItemSortMode(event, target) { - const itemType = target.dataset.gearType; - const propString = `${itemType}SortAlphabetical`; - const targetProp = `system.settings.sorting.${propString}`; - this.toggle(targetProp); - } - - static _browsePack(event, target) { - const { packType } = target.dataset; - switch (packType) { - case "weapon": { - new DialogV2({ - window: { title: "Select Compendium" }, - buttons: [ - { - action: "firearms", - label: game.i18n.localize("DG.Gear.WeaponTypes.Firearms"), - icon: '', - callback: () => - game.packs - .find((k) => k.collection === "deltagreen.firearms") - .render(true), - }, - { - action: "melee", - label: game.i18n.localize("DG.Gear.WeaponTypes.Melee"), - icon: '', - callback: () => - game.packs - .find( - (k) => k.collection === "deltagreen.hand-to-hand-weapons", - ) - .render(true), - }, - ], - }).render(true); - break; - } - default: - game.packs - .find((k) => k.collection === `deltagreen.${packType}`) - .render(true); - break; - } - } - - /** - * Toggle a boolean property on an item or actor. - * - * @param {string} prop The property to toggle. - * @param {string} [itemId] The ID of item on which to apply toggle, if it exists. - */ - toggle(prop, itemId) { - const item = this.actor.items.get(itemId); - const targetDoc = item ?? this.actor; - - const currentVal = foundry.utils.getProperty(targetDoc, prop); - targetDoc.update({ [prop]: !currentVal }); - } - - /** - * Require Shift while hovering to show tooltips. - * Supports either data-tooltip (preferred, may contain HTML) or title (plain text). - */ - _tooltipsSettings(root) { - const mode = game.settings.get("deltagreen", "skillTooltipDisplay"); - - // If not explicitly hoverShift or never, do nothing. - if (mode !== "hoverShift" && mode !== "never") return; - - // Query elements with either data-tooltip OR title - const nodes = root.querySelectorAll("[data-tooltip],[title]"); - - // If mode is "never": strip native attributes so no tooltips (native or custom) can appear. - if (mode === "never") { - nodes.forEach((el) => { - if (el.dataset.shiftTooltipInstalled === "true") return; - el.removeAttribute("data-tooltip"); - el.removeAttribute("title"); - el.dataset.shiftTooltipInstalled = "true"; - }); - return; - } - - // mode === "hoverShift": install Shift-to-show behavior using Foundry's tooltip - nodes.forEach((el) => { - if (el.dataset.shiftTooltipInstalled === "true") return; - - // Prefer data-tooltip, else use title - let html = el.getAttribute("data-tooltip"); - let isHtml = true; - - if (!html) { - const title = el.getAttribute("title"); - if (title) { - html = foundry.utils.escapeHTML(title); // treat title as plain text - isHtml = false; - } - } - - if (!html) return; // nothing usable - - // Remove native attributes so default tooltips don’t trigger - el.removeAttribute("data-tooltip"); - el.removeAttribute("title"); - el.dataset.shiftTooltipInstalled = "true"; - - // Always pass through { html } so
etc. render - const opts = isHtml ? { html } : { text: html }; - - const show = () => game.tooltip.activate(el, opts); - const hide = () => game.tooltip.deactivate(); - - const onKey = (ev) => { - if (ev.key !== "Shift") return; - if (!document.body.contains(el)) { - window.removeEventListener("keydown", onKey); - window.removeEventListener("keyup", onKey); - return; - } - if (ev.type === "keydown") show(); - else hide(); - }; - - const onEnter = (ev) => { - if (ev.shiftKey) show(); - window.addEventListener("keydown", onKey); - window.addEventListener("keyup", onKey); - }; - - const onLeave = () => { - hide(); - window.removeEventListener("keydown", onKey); - window.removeEventListener("keyup", onKey); - }; - - el.addEventListener("pointerenter", onEnter, { passive: true }); - el.addEventListener("pointerleave", onLeave, { passive: true }); - }); - } } diff --git a/module/sheets/base-item-sheet.js b/module/sheets/base-item-sheet.js index a9db633..69782d1 100644 --- a/module/sheets/base-item-sheet.js +++ b/module/sheets/base-item-sheet.js @@ -1,5 +1,4 @@ import { BASE_TEMPLATE_PATH } from "../config.js"; -import { getItemRichTextFields } from "../config/rich-text-fields.js"; import DGSheetMixin from "./base-sheet.js"; import { createDGRollFromDataset, processDGRoll } from "../roll/roll.js"; @@ -183,10 +182,6 @@ export default class DGItemSheet extends DGSheetMixin(ItemSheetV2) { } context.owner = this.document.isOwner; - await this._prepareRichTextContext( - context, - getItemRichTextFields(this.item.type), - ); return context; } @@ -235,13 +230,4 @@ export default class DGItemSheet extends DGSheetMixin(ItemSheetV2) { const revealed = !this.item.system.revealed; this.item.update({ "system.revealed": revealed }); } - - /** - * @param {Event|object} event - * @param {Roll} roll - * @returns {Promise} - */ - async processRoll(event, roll) { - return processDGRoll(event, roll); - } } diff --git a/module/sheets/base-sheet.js b/module/sheets/base-sheet.js index 172804a..711edce 100644 --- a/module/sheets/base-sheet.js +++ b/module/sheets/base-sheet.js @@ -1,5 +1,6 @@ import DG, { BASE_TEMPLATE_PATH } from "../config.js"; -import { prepareRichTextContext } from "./rich-text.js"; +import { getRichTextFieldsForPart } from "../config/rich-text-fields.js"; +import { prepareRichTextContext } from "../utils/rich-text.js"; const HbsAppMixin = foundry.applications.api.HandlebarsApplicationMixin; @@ -30,8 +31,32 @@ const DGSheetMixin = (Base) => { // Add the document to the context under a descriptive name (i.e. "actor", "item") context[docName] = this.document; + context.richText = {}; + return context; } + + /** @inheritdoc */ + async _preparePartContext(partId, context, options) { + context = await super._preparePartContext(partId, context, options); + + const documentName = this.document.documentName; + const fieldSpecs = getRichTextFieldsForPart(documentName, partId, { + actorType: this.document.type, + itemType: this.document.type, + showNotesInSkills: context.showNotesInSkills, + }); + + if (fieldSpecs.length) { + Object.assign( + context.richText, + await prepareRichTextContext(this.document, fieldSpecs), + ); + } + + return context; + } + /** @inheritdoc */ async _onFirstRender(context, options) { @@ -64,23 +89,6 @@ const DGSheetMixin = (Base) => { resetPosition() { this.setPosition(this.options.position); } - - /** - * Prepare `context.richText` from schema-driven ProseMirror field specs. - * @param {ApplicationRenderContext} context - * @param {{ path: string, key: string }[]} fieldSpecs - * @returns {Promise} - */ - async _prepareRichTextContext(context, fieldSpecs) { - if (!fieldSpecs?.length) { - context.richText = {}; - return; - } - context.richText = await prepareRichTextContext( - this.document, - fieldSpecs, - ); - } }; }; diff --git a/module/sheets/mixins/compose-mixins.js b/module/sheets/mixins/compose-mixins.js new file mode 100644 index 0000000..5c8a686 --- /dev/null +++ b/module/sheets/mixins/compose-mixins.js @@ -0,0 +1,7 @@ +/** + * @param {...Function} mixins + * @returns {Function} + */ +export default function composeMixins(...mixins) { + return (Base) => mixins.reduceRight((acc, mixin) => mixin(acc), Base); +} diff --git a/module/sheets/mixins/item-list-mixin.js b/module/sheets/mixins/item-list-mixin.js new file mode 100644 index 0000000..c0aabae --- /dev/null +++ b/module/sheets/mixins/item-list-mixin.js @@ -0,0 +1,239 @@ +const { DialogV2 } = foundry.applications.api; + +/** @param {typeof foundry.applications.api.ApplicationV2} Base */ +export default function ItemListMixin(Base) { + return class extends Base { + /** @param {object} [context] */ + _prepareCharacterItems(context) { + const { actor } = this; + + const armor = []; + const weapons = []; + const gear = []; + const tomes = []; + const rituals = []; + + for (const i of actor.items) { + if (i.type === "armor") { + armor.push(i); + } else if (i.type === "weapon") { + weapons.push(i); + } else if (i.type === "gear") { + gear.push(i); + } else if (i.type === "tome") { + tomes.push(i); + } else if (i.type === "ritual") { + rituals.push(i); + } + } + + const nameSort = (a, b) => { + const x = a.name.toLowerCase(); + const y = b.name.toLowerCase(); + if (x < y) return -1; + if (x > y) return 1; + return 0; + }; + + const sortSort = (a, b) => a.sort - b.sort; + + if (actor.system.settings.sorting.armorSortAlphabetical) { + armor.sort(nameSort); + } else { + armor.sort(sortSort); + } + + if (actor.system.settings.sorting.weaponSortAlphabetical) { + weapons.sort(nameSort); + } else { + weapons.sort(sortSort); + } + + if (actor.system.settings.sorting.gearSortAlphabetical) { + gear.sort(nameSort); + } else { + gear.sort(sortSort); + } + + if (actor.system.settings.sorting.tomeSortAlphabetical) { + tomes.sort(nameSort); + } else { + tomes.sort(sortSort); + } + + if (actor.system.settings.sorting.ritualSortAlphabetical) { + rituals.sort(nameSort); + } else { + rituals.sort(sortSort); + } + + actor.armor = armor; + actor.weapons = weapons; + actor.gear = gear; + actor.rituals = rituals; + actor.tomes = tomes; + } + + /** @returns {void} */ + _setupItemDragListeners() { + const { element } = this; + const handler = (ev) => this._onDragStart(ev); + element.querySelectorAll("li.item").forEach((li) => { + if (li.classList.contains("inventory-header")) return; + li.setAttribute("draggable", true); + li.addEventListener("dragstart", handler, false); + }); + } + + /** @override */ + _onDragStart(event) { + const li = event.currentTarget; + if (event.target.classList.contains("content-link")) return; + + let dragData; + + if (li.dataset.itemId) { + const item = this.actor.items.get(li.dataset.itemId); + dragData = item.toDragData(); + } + + if (li.dataset.effectId) { + const effect = this.actor.effects.get(li.dataset.effectId); + dragData = effect.toDragData(); + } + + if (!dragData) return; + + if (li.dataset.itemId) { + const item = this.actor.items.get(li.dataset.itemId); + dragData.itemData = item; + } + + event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); + } + + /** @override */ + async _onDrop(event) { + super._onDrop(event); + if (event.altKey) { + const TextEditor = foundry.applications.ux.TextEditor.implementation; + const dragData = TextEditor.getDragEventData(event); + if (dragData.type === "Item") { + const item = fromUuidSync(dragData.uuid); + await item.delete(); + } + } + } + + static _onItemAction(event, target) { + const li = target.closest(".item"); + const { itemId } = li.dataset; + const { actionType, itemType } = target.dataset; + + switch (actionType) { + case "create": + this._onItemCreate(itemType); + break; + case "edit": { + const item = this.actor.items.get(itemId); + item.sheet.render(true); + break; + } + case "delete": { + this.actor.deleteEmbeddedDocuments("Item", [itemId]); + break; + } + default: + break; + } + } + + /** @param {String} type */ + _onItemCreate(type) { + const name = game.i18n.format( + game.i18n.translations.DOCUMENT?.New || "DG.FallbackText.newItem", + { + type: game.i18n.localize(`TYPES.Item.${type}`), + }, + ); + + const itemData = { + name, + type, + system: {}, + }; + + if (type === "bond") { + itemData.system.score = this.actor.system.statistics.cha.value; + } + + return this.actor.createEmbeddedDocuments("Item", [itemData]); + } + + /** Handler for generic toggle events */ + static _toggleGeneric(event, target) { + const { prop } = target.dataset; + const itemId = target.closest("[data-item-id]")?.dataset.itemId; + this.toggle(prop, itemId); + } + + /** Handler for item-sort-mode toggle events */ + static _toggleItemSortMode(event, target) { + const itemType = target.dataset.gearType; + const propString = `${itemType}SortAlphabetical`; + const targetProp = `system.settings.sorting.${propString}`; + this.toggle(targetProp); + } + + static _browsePack(event, target) { + const { packType } = target.dataset; + switch (packType) { + case "weapon": { + new DialogV2({ + window: { title: "Select Compendium" }, + buttons: [ + { + action: "firearms", + label: game.i18n.localize("DG.Gear.WeaponTypes.Firearms"), + icon: '', + callback: () => + game.packs + .find((k) => k.collection === "deltagreen.firearms") + .render(true), + }, + { + action: "melee", + label: game.i18n.localize("DG.Gear.WeaponTypes.Melee"), + icon: '', + callback: () => + game.packs + .find( + (k) => k.collection === "deltagreen.hand-to-hand-weapons", + ) + .render(true), + }, + ], + }).render(true); + break; + } + default: + game.packs + .find((k) => k.collection === `deltagreen.${packType}`) + .render(true); + break; + } + } + + /** + * @param {string} prop + * @param {string} [itemId] + */ + toggle(prop, itemId) { + const item = this.actor.items.get(itemId); + const targetDoc = item ?? this.actor; + + const currentVal = foundry.utils.getProperty(targetDoc, prop); + targetDoc.update({ [prop]: !currentVal }); + } + }; +} diff --git a/module/sheets/mixins/roll-sheet-mixin.js b/module/sheets/mixins/roll-sheet-mixin.js new file mode 100644 index 0000000..f6235b8 --- /dev/null +++ b/module/sheets/mixins/roll-sheet-mixin.js @@ -0,0 +1,74 @@ +import { + createDGRollFromDataset, + processDGRoll, +} from "../../roll/roll.js"; + +/** @param {typeof foundry.applications.api.ApplicationV2} Base */ +export default function RollSheetMixin(Base) { + return class extends Base { + /** + * Listens for right click events on the actor sheet and executes a regular roll, + * or luck roll, depending on the action. + * + * @returns {void} + */ + _setRightClickListeners() { + const { element } = this; + element.addEventListener("contextmenu", (event) => { + event.preventDefault(); + const target = event.target.closest( + "[data-action='roll'],[data-action='rollLuck']", + ); + if (!target) return; + + if (target.dataset.action === "rollLuck") { + this.constructor._onRollLuck.call(this, event, target); + return; + } + + this.constructor._onRoll.call(this, event, target); + }); + } + + /** + * Handle clickable rolls. + * + * @param {Event} event The originating click event + * @async + * @private + */ + static async _onRoll(event, target) { + if (target.classList.contains("not-rollable") || event.which === 2) return; + + const item = this.actor.items.get(target.dataset.iid); + const roll = createDGRollFromDataset(target.dataset, { + actor: this.actor, + item, + element: target, + sanityDamageSource: "actor", + }); + await this.processRoll(event, roll); + } + + static async _onRollLuck(event) { + if (event.which === 2) return; + + const roll = createDGRollFromDataset( + { rolltype: "luck", key: "luck" }, + { actor: this.actor }, + ); + await this.processRoll(event, roll); + } + + /** + * Show a dialog for the roll and then send to chat. + * + * @param {Event} event The originating click event + * @param {Roll} roll The roll to show a dialog for and then send to chat. + * @async + */ + async processRoll(event, roll) { + return processDGRoll(event, roll); + } + }; +} diff --git a/module/sheets/mixins/skill-prep-mixin.js b/module/sheets/mixins/skill-prep-mixin.js new file mode 100644 index 0000000..9f070ca --- /dev/null +++ b/module/sheets/mixins/skill-prep-mixin.js @@ -0,0 +1,281 @@ +import DG from "../../config.js"; +import { reorderForColumnSorting } from "../../utils/skill-layout.js"; + +/** @param {typeof foundry.applications.api.ApplicationV2} Base */ +export default function SkillPrepMixin(Base) { + return class extends Base { + /** + * Prepares the subname info placeholder based on the actor type. + * + * @returns {string} + */ + _prepareSubnameInfoPlaceholder() { + let subnameInfoPlaceholder = ""; + switch (this.actor.type) { + case "unnatural": + subnameInfoPlaceholder = + "DG.UnnaturalSheet.ShortDescriptionPlaceholder"; + break; + case "vehicle": + subnameInfoPlaceholder = "DG.VehicleSheet.DescriptionPlaceHolder"; + break; + default: + subnameInfoPlaceholder = "DG.AgentSheet.ProfessionPlaceholder"; + break; + } + return game.i18n.localize(subnameInfoPlaceholder); + } + + /** @returns {void} */ + _sortSkills() { + const sortedSkills = []; + for (const [key, skill] of Object.entries(this.actor.system.skills)) { + skill.key = key; + + if (game.i18n.lang === "ja") { + skill.sortLabel = game.i18n.localize(`DG.Skills.ruby.${key}`); + } else { + skill.sortLabel = game.i18n.localize(`DG.Skills.${key}`); + } + + if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) { + skill.sortLabel = skill.key; + } + + if (this.actor.type === "agent") { + skill.hasPendingImprovement = + skill.proficiency !== skill.targetProficiency; + skill.proficiencyInputClass = skill.hasPendingImprovement + ? "target-proficiency-mod" + : ""; + } + + if ( + !( + (this.actor.type === "npc" || this.actor.type === "unnatural") && + this.actor.system.showUntrainedSkills && + skill.proficiency < 1 + ) + ) { + sortedSkills.push(skill); + } + } + + sortedSkills.sort((a, b) => { + return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang); + }); + + if (game.settings.get("deltagreen", "sortSkills")) { + const columnSortedSkills = reorderForColumnSorting(sortedSkills, 3); + this.actor.system.sortedSkills = columnSortedSkills; + } else { + this.actor.system.sortedSkills = sortedSkills; + } + } + + /** @returns {void} */ + _sortCustomSkills() { + const specialTrainings = this._prepareSpecialTrainings(); + const sortedCustomSkills = []; + + for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) { + skill.type = "typeSkill"; + skill.key = key; + skill.sortLabel = `${skill.group}.${skill.label}`; + skill.sortLabel = skill.sortLabel.toUpperCase(); + skill.actorType = this.actor.type; + + if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) { + skill.sortLabel = skill.key; + } + + sortedCustomSkills.push(skill); + } + + for (let i = 0; i < specialTrainings.length; i++) { + const training = specialTrainings[i]; + + training.type = "training"; + training.sortLabel = training.name.toUpperCase(); + training.actorType = this.actor.type; + + sortedCustomSkills.push(training); + } + + sortedCustomSkills.sort((a, b) => { + return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang); + }); + + if (game.settings.get("deltagreen", "sortSkills")) { + const columnSortedSkills = reorderForColumnSorting( + sortedCustomSkills, + 2, + ); + + this.actor.system.sortedCustomSkills = columnSortedSkills; + } else { + this.actor.system.sortedCustomSkills = sortedCustomSkills; + } + } + + /** @returns {void} */ + _prepareSkillTooltips() { + for (const skill of Object.values(this.actor.system.sortedSkills)) { + skill.tooltip = game.i18n.localize(`DG.Skills.Tooltip.${skill.key}`); + if (!skill.proficiency) { + skill.tooltip = skill.tooltip.concat( + "

", + game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"), + ); + } + } + } + + /** @returns {object[]} */ + _prepareSpecialTrainings() { + return this.actor.system.specialTraining.map((training) => { + const simplifiedTraining = { + name: training.name, + id: training.id, + key: training.attribute, + }; + let attributeLabel; + let attributeTooltip = null; + + switch (true) { + case DG.statistics.includes(training.attribute): + attributeLabel = `${training.attribute.toUpperCase()}x5`; + attributeTooltip = `DG.Attributes.Tooltip.${training.attribute}`; + simplifiedTraining.attribute = attributeLabel; + simplifiedTraining.targetNumber = + this.actor.system.statistics[training.attribute].x5; + break; + case DG.skills.includes(training.attribute): + attributeLabel = game.i18n.localize( + `DG.Skills.${training.attribute}`, + ); + if (attributeLabel === `DG.Skills.${training.attribute}`) { + attributeLabel = + this.actor.system.skills[training.attribute].label; + } + attributeTooltip = `DG.Skills.Tooltip.${training.attribute}`; + simplifiedTraining.attribute = attributeLabel; + simplifiedTraining.targetNumber = + this.actor.system.skills[training.attribute].proficiency; + break; + default: + attributeLabel = + this.actor.system.typedSkills[training.attribute].label; + simplifiedTraining.attribute = attributeLabel; + simplifiedTraining.targetNumber = + this.actor.system.typedSkills[training.attribute].proficiency; + break; + } + + simplifiedTraining.attributeLabel = attributeLabel; + simplifiedTraining.attributeTooltip = attributeTooltip; + simplifiedTraining.displayLabel = `${simplifiedTraining.name} (${attributeLabel}, ${simplifiedTraining.targetNumber}%)`; + + return simplifiedTraining; + }); + } + + shouldShowHyperGeometrySection(actor) { + if (game.user.isGM) { + return true; + } + + if ( + game.settings.get( + "deltagreen", + "alwaysShowHypergeometrySectionForPlayers", + ) + ) { + return true; + } + + for (const i of actor.items) { + if (i.type === "tome" || i.type === "ritual") { + return true; + } + } + + return false; + } + + /** + * Require Shift while hovering to show tooltips. + * + * @param {HTMLElement} root + */ + _tooltipsSettings(root) { + const mode = game.settings.get("deltagreen", "skillTooltipDisplay"); + + if (mode !== "hoverShift" && mode !== "never") return; + + const nodes = root.querySelectorAll("[data-tooltip],[title]"); + + if (mode === "never") { + nodes.forEach((el) => { + if (el.dataset.shiftTooltipInstalled === "true") return; + el.removeAttribute("data-tooltip"); + el.removeAttribute("title"); + el.dataset.shiftTooltipInstalled = "true"; + }); + return; + } + + nodes.forEach((el) => { + if (el.dataset.shiftTooltipInstalled === "true") return; + + let html = el.getAttribute("data-tooltip"); + let isHtml = true; + + if (!html) { + const title = el.getAttribute("title"); + if (title) { + html = foundry.utils.escapeHTML(title); + isHtml = false; + } + } + + if (!html) return; + + el.removeAttribute("data-tooltip"); + el.removeAttribute("title"); + el.dataset.shiftTooltipInstalled = "true"; + + const opts = isHtml ? { html } : { text: html }; + + const show = () => game.tooltip.activate(el, opts); + const hide = () => game.tooltip.deactivate(); + + const onKey = (ev) => { + if (ev.key !== "Shift") return; + if (!document.body.contains(el)) { + window.removeEventListener("keydown", onKey); + window.removeEventListener("keyup", onKey); + return; + } + if (ev.type === "keydown") show(); + else hide(); + }; + + const onEnter = (ev) => { + if (ev.shiftKey) show(); + window.addEventListener("keydown", onKey); + window.addEventListener("keyup", onKey); + }; + + const onLeave = () => { + hide(); + window.removeEventListener("keydown", onKey); + window.removeEventListener("keyup", onKey); + }; + + el.addEventListener("pointerenter", onEnter, { passive: true }); + el.addEventListener("pointerleave", onLeave, { passive: true }); + }); + } + }; +} diff --git a/module/sheets/mixins/special-training-mixin.js b/module/sheets/mixins/special-training-mixin.js new file mode 100644 index 0000000..55d3f69 --- /dev/null +++ b/module/sheets/mixins/special-training-mixin.js @@ -0,0 +1,157 @@ +const { DialogV2 } = foundry.applications.api; + +/** @param {typeof foundry.applications.api.ApplicationV2} Base */ +export default function SpecialTrainingMixin(Base) { + return class extends Base { + static _onSpecialTrainingAction(event, target) { + const { actionType, id } = target.dataset; + switch (actionType) { + case "delete": + { + const specialTrainingArray = foundry.utils.duplicate( + this.actor.system.specialTraining, + ); + + const index = specialTrainingArray.findIndex( + (training) => training.id === id, + ); + + specialTrainingArray.splice(index, 1); + this.actor.update({ + "system.specialTraining": specialTrainingArray, + }); + } + break; + default: + this._showSpecialTrainingDialog(actionType, id); + break; + } + } + + async _showSpecialTrainingDialog(action, targetID) { + const specialTraining = this.actor.system.specialTraining.find( + (training) => training.id === targetID, + ); + + const optionGroups = { + stats: game.i18n.localize( + "DG.SpecialTraining.Dialog.DropDown.Statistics", + ), + skills: game.i18n.localize("DG.SpecialTraining.Dialog.DropDown.Skills"), + typedSkills: game.i18n.localize( + "DG.SpecialTraining.Dialog.DropDown.CustomSkills", + ), + }; + + const statList = Object.entries(this.actor.system.statistics).map( + ([key, stat]) => ({ + value: key, + group: optionGroups.stats, + label: game.i18n.localize(`DG.Attributes.${key}`), + targetNumber: stat.value * 5, + }), + ); + + const skillList = Object.entries(this.actor.system.skills).map( + ([key, skill]) => ({ + value: key, + group: optionGroups.skills, + label: game.i18n.localize(`DG.Skills.${key}`), + targetNumber: skill.proficiency, + }), + ); + + const typedSkillList = Object.entries(this.actor.system.typedSkills).map( + ([key, skill]) => ({ + value: key, + group: optionGroups.typedSkills, + label: `${game.i18n.localize(`DG.TypeSkills.${skill.group}`)} (${ + skill.label + })`, + targetNumber: skill.proficiency, + }), + ); + + const selectElement = foundry.applications.fields.createSelectInput({ + name: "special-training-skill", + options: [...statList, ...skillList, ...typedSkillList], + groups: Object.values(optionGroups), + }).outerHTML; + + const { renderTemplate } = foundry.applications.handlebars; + const content = await renderTemplate( + "systems/deltagreen/templates/dialog/special-training.html", + { + name: specialTraining?.name || "", + selectElement, + currentAttribute: specialTraining?.attribute || "", + statList, + skillList, + typedSkillList, + }, + ); + + const buttonLabel = game.i18n.localize( + `DG.SpecialTraining.Dialog.${action.capitalize()}SpecialTraining`, + ); + + new DialogV2({ + content, + window: { + title: game.i18n.localize("DG.SpecialTraining.Dialog.Title"), + }, + default: "confirm", + buttons: [ + { + default: true, + action: "confirm", + label: buttonLabel, + callback: (event, button, dialog) => { + const specialTrainingLabel = dialog.element.querySelector( + "[name='special-training-label']", + )?.value; + const specialTrainingAttribute = dialog.element.querySelector( + "[name='special-training-skill']", + )?.value; + if (action === "create") + this._createSpecialTraining( + specialTrainingLabel, + specialTrainingAttribute, + ); + if (action === "edit") + this._editSpecialTraining( + specialTrainingLabel, + specialTrainingAttribute, + targetID, + ); + }, + }, + ], + }).render(true); + } + + _createSpecialTraining(label, attribute) { + const specialTrainingArray = foundry.utils.duplicate( + this.actor.system.specialTraining, + ); + specialTrainingArray.push({ + name: label, + attribute, + id: foundry.utils.randomID(), + }); + this.actor.update({ "system.specialTraining": specialTrainingArray }); + } + + _editSpecialTraining(label, attribute, id) { + const specialTrainingArray = foundry.utils.duplicate( + this.actor.system.specialTraining, + ); + const specialTraining = specialTrainingArray.find( + (training) => training.id === id, + ); + specialTraining.name = label; + specialTraining.attribute = attribute; + this.actor.update({ "system.specialTraining": specialTrainingArray }); + } + }; +} diff --git a/module/sheets/mixins/typed-skill-mixin.js b/module/sheets/mixins/typed-skill-mixin.js new file mode 100644 index 0000000..b3a49a5 --- /dev/null +++ b/module/sheets/mixins/typed-skill-mixin.js @@ -0,0 +1,276 @@ +const { DialogV2 } = foundry.applications.api; + +/** @param {typeof foundry.applications.api.ApplicationV2} Base */ +export default function TypedSkillMixin(Base) { + return class extends Base { + static _onTypedSkillAction(event, target) { + const { actionType, typedskill } = target.dataset; + switch (actionType) { + case "create": + this._showNewTypeSkillDialog(); + break; + case "edit": + this._showNewEditTypeSkillDialog(typedskill); + break; + case "delete": + this.actor.update({ [`system.typedSkills.-=${typedskill}`]: null }); + break; + default: + break; + } + } + + _showNewEditTypeSkillDialog(targetSkill) { + const { typedSkills } = this.actor.system; + const currentLabel = typedSkills[targetSkill].label; + const currentGroup = typedSkills[targetSkill].group; + + let htmlContent = `
`; + htmlContent += ` `; + htmlContent += ` `; + htmlContent += `
`; + + htmlContent += `
`; + htmlContent += ` `; + htmlContent += ` `; + htmlContent += `
`; + + new DialogV2({ + content: htmlContent, + window: { + title: + game.i18n.translations.DG?.Skills?.EditTypedOrCustomSkill ?? + "Edit Typed or Custom Skill", + }, + buttons: [ + { + default: true, + action: "add", + label: game.i18n.translations.DG?.Skills?.EditSkill ?? "Edit Skill", + callback: (event, button, dialog) => { + const newTypeSkillLabel = dialog.element.querySelector( + "[name='new-type-skill-label']", + )?.value; + const newTypeSkillGroup = dialog.element.querySelector( + "[name='new-type-skill-group']", + )?.value; + this._updateTypedSkill( + targetSkill, + newTypeSkillLabel, + newTypeSkillGroup, + ); + }, + }, + ], + }).render(true); + } + + _showNewTypeSkillDialog() { + let htmlContent = ""; + + htmlContent += `
`; + htmlContent += ` `; + htmlContent += ` `; + htmlContent += `
`; + + htmlContent += `
`; + htmlContent += ` `; + htmlContent += ` `; + htmlContent += `
`; + + new DialogV2({ + content: htmlContent, + window: { + title: + game.i18n.translations.DG?.Skills?.AddTypedOrCustomSkill ?? + "Add Typed or Custom Skill", + }, + default: "add", + buttons: [ + { + default: true, + action: "add", + label: game.i18n.translations.DG?.Skills?.AddSkill ?? "Add Skill", + callback: (event, button, dialog) => { + const newTypeSkillLabel = dialog.element.querySelector( + "[name='new-type-skill-label']", + )?.value; + const newTypeSkillGroup = dialog.element.querySelector( + "[name='new-type-skill-group']", + )?.value; + this._addNewTypedSkill(newTypeSkillLabel, newTypeSkillGroup); + }, + }, + ], + }).render(true); + } + + _addNewTypedSkill(newSkillLabel, newSkillGroup) { + const updatedData = foundry.utils.duplicate(this.actor.system); + const { typedSkills } = updatedData; + + const d = new Date(); + + const newSkillPropertyName = + d.getFullYear().toString() + + (d.getMonth() + 1).toString() + + d.getDate().toString() + + d.getHours().toString() + + d.getMinutes().toString() + + d.getSeconds().toString(); + + typedSkills[newSkillPropertyName] = { + label: newSkillLabel, + group: newSkillGroup, + proficiency: 10, + failure: false, + }; + + updatedData.typedSkills = typedSkills; + + this.actor.update({ system: updatedData }); + } + + _updateTypedSkill(targetSkill, newSkillLabel, newSkillGroup) { + if ( + newSkillLabel !== null && + newSkillLabel !== "" && + newSkillGroup !== null && + newSkillGroup !== "" + ) { + const updatedData = foundry.utils.duplicate(this.actor.system); + + updatedData.typedSkills[targetSkill].label = newSkillLabel; + updatedData.typedSkills[targetSkill].group = newSkillGroup; + + this.actor.update({ system: updatedData }); + } + } + }; +} diff --git a/module/sheets/unnatural-sheet.js b/module/sheets/unnatural-sheet.js index 7ad7ce1..adddb0a 100644 --- a/module/sheets/unnatural-sheet.js +++ b/module/sheets/unnatural-sheet.js @@ -1,3 +1,4 @@ +import { flagUnnaturalResourceMaxUpdates } from "../utils/actor-submit.js"; import DGNPCSheet from "./npc-sheet.js"; /** @extends {DGNPCSheet} */ @@ -5,19 +6,7 @@ export default class DGUnnaturalSheet extends DGNPCSheet { /** @override */ async _processSubmitData(event, form, submitData, options) { const submittedData = foundry.utils.expandObject(submitData); - - // we need to update max WP if POW has changed (only for unnatural, but doesn't hurt others) - submittedData.system.wp.maxNeedsUpdate = - submittedData.system?.statistics.pow.value !== - this.actor.system.statistics.pow.value; - - // we need to update max HP if STR or CON have changed (only for unnatural, but doesn't hurt others) - submittedData.system.health.maxNeedsUpdate = - submittedData.system?.statistics.str.value !== - this.actor.system.statistics.str.value || - submittedData.system?.statistics.con.value !== - this.actor.system.statistics.con.value; - + flagUnnaturalResourceMaxUpdates(submittedData, this.actor); return super._processSubmitData(event, form, submittedData, options); } } diff --git a/module/utils/actor-submit.js b/module/utils/actor-submit.js new file mode 100644 index 0000000..5b4c4e8 --- /dev/null +++ b/module/utils/actor-submit.js @@ -0,0 +1,16 @@ +/** + * @param {object} submittedData + * @param {Actor} actor + * @returns {void} + */ +export function flagUnnaturalResourceMaxUpdates(submittedData, actor) { + submittedData.system.wp.maxNeedsUpdate = + submittedData.system?.statistics.pow.value !== + actor.system.statistics.pow.value; + + submittedData.system.health.maxNeedsUpdate = + submittedData.system?.statistics.str.value !== + actor.system.statistics.str.value || + submittedData.system?.statistics.con.value !== + actor.system.statistics.con.value; +} diff --git a/module/utils/derived-actor-data.js b/module/utils/derived-actor-data.js new file mode 100644 index 0000000..2d20ac7 --- /dev/null +++ b/module/utils/derived-actor-data.js @@ -0,0 +1,138 @@ +/** + * Shared derived-data helpers for actor TypeDataModels. + */ + +/** + * @param {Item[]} items + * @returns {number} + */ +export function computeEquippedArmorProtection(items) { + let protection = 0; + for (const item of items) { + if (item.type === "armor" && item.system.equipped === true) { + protection += item.system.protection; + } + } + return protection; +} + +/** + * @param {number} strengthValue + * @returns {string} + */ +export function calculateMeleeDamageBonusFormula(strengthValue) { + if (strengthValue < 5) return "-2"; + if (strengthValue < 9) return "-1"; + if (strengthValue > 12 && strengthValue < 17) return "+1"; + if (strengthValue > 16) return "+2"; + return ""; +} + +/** + * @param {object} statistics + * @returns {void} + */ +export function prepareStatisticsX5(statistics) { + for (const statistic of Object.values(statistics)) { + statistic.x5 = statistic.value * 5; + } +} + +/** + * @param {object} skills + * @returns {void} + */ +export function setSkillTargetProficiencies(skills) { + for (const skill of Object.values(skills)) { + skill.targetProficiency = skill.proficiency; + } +} + +/** + * @param {object} sanity + * @param {object} statistics + * @returns {void} + */ +export function initializeSanityIfUnset(sanity, statistics) { + if (sanity.value >= 100) { + sanity.value = statistics.pow.x5; + sanity.currentBreakingPoint = sanity.value - statistics.pow.value; + } +} + +/** + * @param {object} sanity + * @returns {void} + */ +export function clampSanityRitualValue(sanity) { + sanity.ritual = 99 - sanity.value; + + if (sanity.ritual > 99) { + sanity.ritual = 99; + } else if (sanity.ritual < 1) { + sanity.ritual = 1; + } +} + +/** + * @param {object} adaptations + * @returns {void} + */ +export function prepareSanityAdaptations(adaptations) { + adaptations.violence.isAdapted = + adaptations.violence.incident1 && + adaptations.violence.incident2 && + adaptations.violence.incident3; + + adaptations.helplessness.isAdapted = + adaptations.helplessness.incident1 && + adaptations.helplessness.incident2 && + adaptations.helplessness.incident3; +} + +/** + * @param {object} sanity + * @returns {void} + */ +export function prepareBreakingPointHit(sanity) { + sanity.breakingPointHit = sanity.value <= sanity.currentBreakingPoint; +} + +/** + * @param {object} skills + * @returns {void} + */ +export function prepareAgentSkillFlags(skills) { + for (const [key, skill] of Object.entries(skills)) { + skill.cannotBeImprovedByFailure = + key === "unnatural" || key === "luck" || key === "ritual"; + skill.isCalculatedValue = key === "ritual"; + skill.targetProficiency = skill.proficiency; + } +} + +/** + * @param {object} system + * @returns {void} + */ +export function removeLegacyRitualSkill(system) { + try { + delete system.skills.ritual; + } catch { + // Legacy field may be absent or non-configurable. + } +} + +/** + * @param {object} statistics + * @returns {number} + */ +export function calculateHealthMax(statistics) { + try { + return Math.ceil( + (statistics.con.value + statistics.str.value) / 2, + ); + } catch { + return 10; + } +} diff --git a/module/utils/register-helpers.js b/module/utils/register-helpers.js new file mode 100644 index 0000000..c836d47 --- /dev/null +++ b/module/utils/register-helpers.js @@ -0,0 +1,131 @@ +import DGUtils from "./utility-functions.js"; + +export default function registerHandlebarsHelpers() { + // Add Handlebars helpers + Handlebars.registerHelper("localizeWithFallback", (value, fallbackValue) => { + return DGUtils.localizeWithFallback(value, fallbackValue); + }); + + Handlebars.registerHelper("concat", (...args) => { + let outStr = ""; + for (const arg in args) { + if (typeof args[arg] !== "object") { + outStr += args[arg]; + } + } + return outStr; + }); + + Handlebars.registerHelper("toLowerCase", (str) => { + try { + return str.toLowerCase(); + } catch (error) { + return ""; + } + }); + + Handlebars.registerHelper("toUpperCase", (str) => { + try { + return str.toUpperCase(); + } catch (error) { + return ""; + } + }); + + Handlebars.registerHelper("if_eq", (a, b, opts) => { + if (a === b) { + return opts.fn(this); + } + return opts.inverse(this); + }); + + Handlebars.registerHelper("if_not_eq", (a, b, opts) => { + if (a !== b) { + return opts.fn(this); + } + return opts.inverse(this); + }); + + Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => { + if (a > b) { + return trueVal; + } + return falseVal; + }); + + /** Roll visibility modes for dialog selects (v14+ CONFIG.ChatMessage.modes keys). */ + const ROLL_MESSAGE_MODE_KEYS = ["public", "gm", "blind", "self"]; + + Handlebars.registerHelper("getAvailableRollModes", () => { + try { + return Object.fromEntries( + ROLL_MESSAGE_MODE_KEYS.filter((key) => key in CONFIG.ChatMessage.modes).map( + (key) => [key, CONFIG.ChatMessage.modes[key]], + ), + ); + } catch (error) { + return console.log(error); + } + }); + + Handlebars.registerHelper("getDefaultRollMode", () => { + try { + return game.settings.get("core", "messageMode"); + } catch (error) { + return console.log(error); + } + }); + + Handlebars.registerHelper("localizeWeaponSkill", (skill) => { + let label = skill; + + try { + if (skill === "dex") { + label = game.i18n.localize("DG.Attributes.dex"); + } + if (skill === "DG.Skills.custom") { + label = game.i18n.localize("DG.ItemWindow.Custom"); + } else { + label = game.i18n.localize(`DG.Skills.${skill}`); + } + } catch (error) { + console.log(error); + } + + return label; + }); + + Handlebars.registerHelper("keepSanityPrivate", () => { + let setting = false; + + try { + setting = game.settings.get("deltagreen", "keepSanityPrivate"); + + if (game.user.isGM) { + setting = false; + } + } catch (ex) { + setting = false; + } + + return setting; + }); + + Handlebars.registerHelper("playerHasGamemasterPrivileges", () => { + return game.user.isGM; + }); + + Handlebars.registerHelper("showImpossibleLandscapesContent", () => { + let result = false; + const setting = game.settings.get( + "deltagreen", + "showImpossibleLandscapesContent", + ); + + if (game.user.isGM === true && setting === true) { + result = true; + } + + return result; + }); +} diff --git a/module/sheets/rich-text.js b/module/utils/rich-text.js similarity index 100% rename from module/sheets/rich-text.js rename to module/utils/rich-text.js diff --git a/module/utils/skill-layout.js b/module/utils/skill-layout.js new file mode 100644 index 0000000..7f6d669 --- /dev/null +++ b/module/utils/skill-layout.js @@ -0,0 +1,126 @@ +/** + * Reorders a flat array into column-major order for grid display. + * + * @param {object[]} arr + * @param {number} numCols + * @returns {object[]} + */ +export function reorderForColumnSorting(arr, numCols) { + const numRows = Math.ceil(arr.length / numCols); + const reordered = new Array(arr.length); + + const baseRowCount = Math.floor(arr.length / numCols); + const extraColumns = arr.length % numCols; + + const colHeights = new Array(numCols).fill(baseRowCount); + for (let i = 0; i < extraColumns; i++) { + colHeights[i] += 1; + } + + let index = 0; + + for (let col = 0; col < numCols; col++) { + const rowCount = colHeights[col]; + + for (let row = 0; row < rowCount; row++) { + const newIndex = numCols * row + col; + + if (newIndex < arr.length) { + reordered[newIndex] = arr[index]; + index += 1; + } + } + } + + return reordered; +} + +/** + * @param {object} column + * @param {object} group + * @returns {void} + */ +function appendSkillGroupToColumn(column, group) { + column.blocks.push({ + type: "groupHeader", + group: group.group, + label: group.label, + ...(group.isSpecialTraining ? { isSpecialTraining: true } : {}), + }); + + const skillType = group.isSpecialTraining ? "training" : "typedSkill"; + for (const skill of group.skills) { + column.blocks.push({ ...skill, type: skillType }); + } + + column.height += group.rowCount; +} + +/** + * @param {object[]} groups + * @param {number} numCols + * @returns {object[][]} + */ +function packSkillGroupsByColumn(groups, numCols) { + const columns = Array.from({ length: numCols }, () => ({ + blocks: [], + height: 0, + })); + + if (groups.length === 0) { + return columns.map((col) => col.blocks); + } + + const totalHeight = groups.reduce((sum, group) => sum + group.rowCount, 0); + const targetHeight = totalHeight / numCols; + let colIndex = 0; + + for (const group of groups) { + const col = columns[colIndex]; + + if ( + colIndex < numCols - 1 && + col.blocks.length > 0 && + col.height + group.rowCount > targetHeight + ) { + colIndex += 1; + } + + appendSkillGroupToColumn(columns[colIndex], group); + } + + return columns.map((col) => col.blocks); +} + +/** + * @param {object[]} groups + * @param {number} numCols + * @returns {object[][]} + */ +function packSkillGroupsByRow(groups, numCols) { + const columns = Array.from({ length: numCols }, () => ({ + blocks: [], + height: 0, + })); + + for (let i = 0; i < groups.length; i++) { + appendSkillGroupToColumn(columns[i % numCols], groups[i]); + } + + return columns.map((col) => col.blocks); +} + +/** + * Packs skill groups into columns without splitting a group across columns. + * + * @param {object[]} groups + * @param {number} numCols + * @param {boolean} sortByColumn + * @returns {object[][]} + */ +export function packSkillGroupsIntoColumns(groups, numCols, sortByColumn) { + if (sortByColumn) { + return packSkillGroupsByColumn(groups, numCols); + } + return packSkillGroupsByRow(groups, numCols); +} diff --git a/module/other/utility-functions.js b/module/utils/utility-functions.js similarity index 100% rename from module/other/utility-functions.js rename to module/utils/utility-functions.js diff --git a/module/utils/world-migration.js b/module/utils/world-migration.js new file mode 100644 index 0000000..03220a6 --- /dev/null +++ b/module/utils/world-migration.js @@ -0,0 +1,58 @@ +import DG from "../config.js"; + +const MIGRATION_VERSION = 2; +const ACTOR_TYPES_WITH_SKILLS = ["agent", "npc", "unnatural"]; +const OBSOLETE_WORLD_SETTINGS = [ + "characterSheetFont", + "characterSheetBackgroundImageSetting", +]; + +/** + * Run one-time world migrations for the Delta Green system. + * + * @returns {Promise} + */ +export async function runWorldMigration() { + if (!game.user.isGM) return; + + const currentVersion = + game.settings.get(DG.ID, "schemaMigrationVersion") ?? 0; + if (currentVersion >= MIGRATION_VERSION) return; + + let migratedActors = 0; + + const actors = game.actors.filter((actor) => + ACTOR_TYPES_WITH_SKILLS.includes(actor.type), + ); + + for (const actor of actors) { + if (!actor.system.skills?.ritual) continue; + + await actor.update({ + "system.skills.-=ritual": null, + "system.schemaVersion": MIGRATION_VERSION, + }); + migratedActors += 1; + } + + removeObsoleteWorldSettings(); + + await game.settings.set(DG.ID, "schemaMigrationVersion", MIGRATION_VERSION); + + console.log( + `Delta Green | World migration v${MIGRATION_VERSION} complete. Removed legacy ritual skill from ${migratedActors} actor(s).`, + ); +} + +/** + * @returns {void} + */ +function removeObsoleteWorldSettings() { + const worldConfig = game.settings.storage.get("world")?.document?.config; + const dgConfig = worldConfig?.[DG.ID]; + if (!dgConfig) return; + + for (const key of OBSOLETE_WORLD_SETTINGS) { + delete dgConfig[key]; + } +} diff --git a/templates/actor/partials/custom-skills-partial-v2.html b/templates/actor/partials/custom-skills-partial-agent.html similarity index 73% rename from templates/actor/partials/custom-skills-partial-v2.html rename to templates/actor/partials/custom-skills-partial-agent.html index 8dc0953..beb84f2 100644 --- a/templates/actor/partials/custom-skills-partial-v2.html +++ b/templates/actor/partials/custom-skills-partial-agent.html @@ -108,39 +108,18 @@ {{#if_eq block.type "training" }}
- {{#if_not_eq (localize (concat 'DG.Attributes.' block.key)) (concat "DG.Attributes." block.key) }} - - {{else}} - {{#if_not_eq (localize (concat 'DG.Skills.' block.key)) (concat "DG.Skills." block.key) }} - - {{else}} - - {{/if_not_eq}} - {{/if_not_eq}} +
- {{#if_not_eq (localize (concat 'DG.Attributes.' skill.key)) (concat "DG.Attributes." skill.key) }} - - {{else}} - {{#if_not_eq (localize (concat 'DG.Skills.' skill.key)) (concat "DG.Skills." skill.key) }} - - {{else}} - - {{/if_not_eq}} - {{/if_not_eq}} +
diff --git a/templates/actor/parts/bio-tab.html b/templates/actor/parts/bio-tab.html deleted file mode 100644 index 222cfe1..0000000 --- a/templates/actor/parts/bio-tab.html +++ /dev/null @@ -1,98 +0,0 @@ -{{!-- djlint:off H020 --}} - -{{!-- PERSONAL (Biography) Tab --}} -
-
- {{localize "DG.Sheet.BlockHeaders.PersonalInfoCV"}} -
-
- {{localize 'DG.CV.Sex'}} - - - {{localize 'DG.CV.AgeAndDoB'}} - -
- -
- - {{localize 'DG.CV.Employer'}} - - - {{localize 'DG.CV.Nationality'}} - - -
- -
- {{localize 'DG.CV.Education'}} - -
- - {{#if (showImpossibleLandscapesContent) }} - -
- {{localize 'DG.ImpossibleLanscapes.IHaveSeenThe'}} - - {{localize 'DG.ImpossibleLanscapes.YellowSign'}} - - - {{localize 'DG.ImpossibleLanscapes.CorruptionFirstHalf'}} - {{localize 'DG.ImpossibleLanscapes.CorruptionSecondHalf'}} - - -
- -
- {{localize 'DG.ImpossibleLanscapes.Gift'}} - - - {{localize 'DG.ImpossibleLanscapes.Insight'}} - -
- - {{/if}} - - {{> "systems/deltagreen/templates/actor/partials/notes-partial.html"}} -
diff --git a/templates/actor/parts/bonds-tab.html b/templates/actor/parts/bonds-tab.html deleted file mode 100644 index eb347f7..0000000 --- a/templates/actor/parts/bonds-tab.html +++ /dev/null @@ -1,73 +0,0 @@ -
-
- {{localize "DG.Sheet.BlockHeaders.BondsRelationshipsContacts"}} -
- -
-
    -
  1. - -
    - -
    -
    -
    {{localize 'DG.Bonds.Name'}}
    -
    {{localize 'DG.Bonds.Relationship'}}
    -
    {{localize 'DG.Bonds.Score'}}
    - -
  2. - - {{#each actor.items as |item id| }} - {{#if_eq item.type "bond" }} - -
  3. - -
    - -
    - {{item.name}} - {{item.system.relationship}} - {{item.system.score}} - -
  4. - - {{/if_eq}} - {{/each}} -
- -
- {{localizeWithFallback "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene" "Check a damaged Bond's box until the next Home scene ends." }} -
- -
-
diff --git a/templates/actor/parts/combat-tab-v2.html b/templates/actor/parts/combat-tab-agent.html similarity index 100% rename from templates/actor/parts/combat-tab-v2.html rename to templates/actor/parts/combat-tab-agent.html diff --git a/templates/actor/parts/gear-tab-v2.html b/templates/actor/parts/gear-tab-agent.html similarity index 100% rename from templates/actor/parts/gear-tab-v2.html rename to templates/actor/parts/gear-tab-agent.html diff --git a/templates/actor/parts/left-bar.html b/templates/actor/parts/left-bar.html index f445899..5d437a3 100644 --- a/templates/actor/parts/left-bar.html +++ b/templates/actor/parts/left-bar.html @@ -144,6 +144,10 @@ data-dtype="Number" />
+
+ {{localize "DG.Sheet.BlockHeaders.Adaptations"}} +
+
- -
{{> "systems/deltagreen/templates/actor/partials/custom-skills-partial-v2.html" }}
+
{{> "systems/deltagreen/templates/actor/partials/custom-skills-partial-agent.html" }}
{{#if showNotesInSkills }} {{> "systems/deltagreen/templates/actor/partials/notes-partial.html"}} diff --git a/templates/actor/parts/skills-tab.html b/templates/actor/parts/skills-tab.html index 5ef1b96..4584760 100644 --- a/templates/actor/parts/skills-tab.html +++ b/templates/actor/parts/skills-tab.html @@ -29,9 +29,7 @@ -
- - {{selectOptions (getAvailableRollModes) selected=(getDefaultRollMode) localize=true}}
diff --git a/templates/roll/lethality-roll.hbs b/templates/roll/lethality-roll.hbs new file mode 100644 index 0000000..f5167fe --- /dev/null +++ b/templates/roll/lethality-roll.hbs @@ -0,0 +1,30 @@ +
+
+
{{resultString}}
+
+
+
+
+
+ d100 + {{total}} +
+
    +
  1. {{total}}
  2. +
+
+
+ 2d10 (d10 + d10) + {{nonLethalTotal}} +
+
    +
  1. {{die1}}
  2. +
  3. {{die2}}
  4. +
+
+
+
+
+

{{total}} ({{nonLethalTotal}} {{damageLabel}})

+
+
diff --git a/templates/roll/sanity-damage-roll.hbs b/templates/roll/sanity-damage-roll.hbs new file mode 100644 index 0000000..b369b6f --- /dev/null +++ b/templates/roll/sanity-damage-roll.hbs @@ -0,0 +1,29 @@ +
+
+
{{lowFormula}} / {{highFormula}}
+
+
+
+
+
+ {{lowFormula}} + {{lowResult}} +
+
    +
  1. {{lowResult}}
  2. +
+
+
+ {{highFormula}} + {{highResult}} +
+
    +
  1. {{highResult}}
  2. +
+
+
+
+
+

{{lowResult}} / {{highResult}}

+
+