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 0000000..eaaa479 Binary files /dev/null and b/assets/img/green_delta_icon.webp differ diff --git a/css/deltagreen.css b/css/deltagreen.css index 567fee9..cb4016c 100644 --- a/css/deltagreen.css +++ b/css/deltagreen.css @@ -900,11 +900,6 @@ div.horizontal-bar-section-header { min-height: 0; } -div.motivation-editor-container div.editor { - height: 400px; - min-height: 100% !important; -} - .deltagreen .editor-host { align-items: stretch; display: flex; @@ -933,19 +928,6 @@ div.motivation-editor-container div.editor { min-height: 0; } -/* Description fonts follow the selected sheet theme (see .cowboy-style / .program-style). */ -.cowboy-style .editor-host prose-mirror .editor-content, -.cowboy-style .editor-host prose-mirror .editor-content .ProseMirror { - font-family: "SpecialElite", sans-serif !important; -} - -.outlaw-style .editor-host prose-mirror .editor-content, -.outlaw-style .editor-host prose-mirror .editor-content .ProseMirror, -.program-style .editor-host prose-mirror .editor-content, -.program-style .editor-host prose-mirror .editor-content .ProseMirror { - font-family: "PublicSans", sans-serif !important; -} - /* Actor sheets: compact description when not editing. */ .deltagreen:not(.item) .editor-host prose-mirror:not(.active) .editor-content { position: relative; @@ -1509,7 +1491,7 @@ a[data-action="applySkillImprovements"] { text-decoration: line-through; } -.actor-sheet-v2 { +.agent-sheet { .window-content { display: grid; grid-template-columns: 220px 1fr; diff --git a/lang/ca.json b/lang/ca.json index ca67333..ee0ce5f 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -278,6 +278,8 @@ "TYPES.Item.motivation": "Motivació", "TYPES.Item.bond": "Vincle", "TYPES.Item.gear": "Equip", + "TYPES.Item.tome": "Tome", + "TYPES.Item.ritual": "Ritual", "DG.ItemWindow.Ritual.ActivationSanityDamage": "Activation SAN Damage", "DG.ItemWindow.Ritual.ActivationTime": "Activation Time", diff --git a/lang/de.json b/lang/de.json index 1098b87..34608a1 100644 --- a/lang/de.json +++ b/lang/de.json @@ -296,6 +296,8 @@ "TYPES.Item.motivation": "Motivation", "TYPES.Item.bond": "Bindung", "TYPES.Item.gear": "Ausrüstung", + "TYPES.Item.tome": "Buch", + "TYPES.Item.ritual": "Ritual", "DG.ItemWindow.Ritual.ActivationSanityDamage": "Activation SAN Damage", "DG.ItemWindow.Ritual.ActivationTime": "Activation Time", diff --git a/lang/en.json b/lang/en.json index b6842a2..1dd7f9a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -355,7 +355,6 @@ "DG.Sheet.class.item": "Item Sheet", "DG.Sheet.class.agent": "Agent Sheet", - "DG.Sheet.class.agentV2": "Agent Sheet V2", "DG.Sheet.class.npc": "NPC Sheet", "DG.Sheet.class.unnatural": "Unnatural Sheet", "DG.Sheet.class.vehicle": "Vehicle Sheet", diff --git a/module/actor/actor.js b/module/actor/actor.js index fc1690d..fa31369 100644 --- a/module/actor/actor.js +++ b/module/actor/actor.js @@ -3,296 +3,6 @@ * @extends {Actor} */ export default class DeltaGreenActor extends Actor { - /** - * Augment the basic actor data with additional dynamic data. - */ - prepareData() { - super.prepareData(); - - const actorData = this; - - // console.log('actor.js prepareData'); - // console.log(this); - - // Make separate methods for each Actor type (character, npc, etc.) to keep - // things organized. - if (actorData.type === "agent") { - this._prepareAgentData(this); - } else if (actorData.type === "unnatural") { - this._prepareUnnaturalData(this); - } else if (actorData.type === "npc") { - this._prepareNpcData(this); - } else if (actorData.type === "vehicle") { - this._prepareVehicleData(this); - } - } - - /** - * - * @param {*} agent - */ - - _prepareVehicleData(actor) { - const actorData = actor; - - // 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; - } - } - } - - actorData.system.health.protection = protection; - - // console.log(actor); - } - - _prepareNpcData(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; - } - - // 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; - - 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}}

+
+