From c197b83ec504194f6f9212e69312d287f24a440b Mon Sep 17 00:00:00 2001 From: Aaron Lutomski Date: Fri, 29 May 2026 22:51:56 -0700 Subject: [PATCH] Refactor and unify some item styling. Restyle profession item. Restyle skills tab. --- lang/en.json | 24 +- module/README.md | 29 + .../data}/dg-active-effect-data.js | 0 .../documents/dg-active-effect.js | 0 .../runtime/agent-condition-sync.js | 25 + .../runtime/derived.js} | 2 +- .../runtime}/exhaustion-effect.js | 6 +- .../runtime}/stimulant-effect.js | 13 +- .../runtime/submit.js} | 4 +- module/actor/actor.js | 8 +- module/applications/add-profession-dialog.js | 4 +- module/applications/assign-stats-dialog.js | 4 +- .../applications/dg-active-effect-config.js | 2 +- module/applications/dg-dialog.js | 2 +- module/applications/edit-stats.js | 2 +- module/applications/pick-statistics-dialog.js | 2 +- module/applications/profession-setup-flow.js | 2 +- module/applications/roll-stats-dialog.js | 4 +- module/chat/dg-chat-card.js | 2 +- module/{utils => chat}/resource-chat.js | 23 +- module/{config.js => config/index.js} | 0 module/data/actor/agent.js | 4 +- .../data/actor/base/agent-skill-defaults.js | 39 + module/data/actor/base/human-skills.js | 74 +- module/data/actor/npc.js | 4 +- module/data/actor/unnatural.js | 4 +- module/data/actor/vehicle.js | 4 +- .../derived/actor-derived.js} | 0 module/data/item/ritual.js | 2 +- module/data/item/tome.js | 2 +- module/deltagreen.js | 12 +- .../{utils => integrations}/dice-so-nice.js | 0 module/item/item.js | 21 +- module/macros/stat-parser-macro.js | 8 +- module/profession/catalog.js | 223 ++++ module/profession/compute.js | 249 ++++ module/profession/constants.js | 42 + module/profession/display.js | 70 ++ module/profession/index.js | 33 + module/profession/keys.js | 269 +++++ .../stat-setup.js} | 18 +- module/profession/validation.js | 238 ++++ module/roll/classes/dg-damage-roll.js | 47 + module/roll/classes/dg-lethality-roll.js | 116 ++ .../dg-percentile-roll.js} | 335 +---- module/roll/classes/dg-roll.js | 82 ++ module/roll/classes/dg-sanity-damage-roll.js | 56 + module/roll/melee-damage.js | 21 + module/roll/roll.js | 27 +- module/roll/skill-improvement-roll.js | 2 +- module/settings.js | 2 +- module/sheets/agent-sheet.js | 159 +-- module/sheets/base-actor-sheet.js | 4 +- module/sheets/base-item-sheet.js | 2 +- module/sheets/base-sheet.js | 2 +- module/sheets/mixins/effects-tab-mixin.js | 2 +- module/sheets/mixins/profession-item-mixin.js | 4 +- module/sheets/mixins/skill-prep-mixin.js | 4 +- module/utils/profession-skills.js | 1076 ----------------- module/utils/roll-target-tooltip.js | 4 +- module/utils/skill-layout.js | 203 ++-- module/utils/skill-tooltip.js | 2 +- module/utils/world-migration.js | 2 +- package.json | 2 +- scss/_mixins.scss | 29 + scss/{dialogue.scss => damage-modify.scss} | 0 scss/deltagreen/_agent-sheet.scss | 189 ++- scss/deltagreen/_character-sheet-cont.scss | 25 + scss/deltagreen/_chat-cards.scss | 31 + scss/deltagreen/_global.scss | 1 + scss/deltagreen/_item-sheets.scss | 14 + scss/deltagreen/_profession.scss | 171 +-- scss/dg-dialog/_base.scss | 19 +- system.json | 2 +- .../partials/agent-skill-row-partial.html | 81 ++ .../agent-special-training-row-partial.html | 32 + .../partials/custom-skills-partial-agent.html | 153 +-- .../actor/partials/left-bar-sanity-tab.html | 8 +- templates/actor/parts/about-tab.html | 2 +- templates/actor/parts/skills-tab-agent.html | 70 +- templates/item/parts/profession-header.html | 11 +- .../item/parts/profession-skills-tab.html | 149 +-- 82 files changed, 2503 insertions(+), 2112 deletions(-) create mode 100644 module/README.md rename module/{data/active-effect => active-effect/data}/dg-active-effect-data.js (100%) rename module/{ => active-effect}/documents/dg-active-effect.js (100%) create mode 100644 module/active-effect/runtime/agent-condition-sync.js rename module/{utils/active-effect-derived.js => active-effect/runtime/derived.js} (99%) rename module/{utils => active-effect/runtime}/exhaustion-effect.js (95%) rename module/{utils => active-effect/runtime}/stimulant-effect.js (91%) rename module/{utils/active-effect-submit.js => active-effect/runtime/submit.js} (90%) rename module/{utils => chat}/resource-chat.js (70%) rename module/{config.js => config/index.js} (100%) create mode 100644 module/data/actor/base/agent-skill-defaults.js rename module/{utils/derived-actor-data.js => data/derived/actor-derived.js} (100%) rename module/{utils => integrations}/dice-so-nice.js (100%) create mode 100644 module/profession/catalog.js create mode 100644 module/profession/compute.js create mode 100644 module/profession/constants.js create mode 100644 module/profession/display.js create mode 100644 module/profession/index.js create mode 100644 module/profession/keys.js rename module/{utils/profession-stat-setup.js => profession/stat-setup.js} (88%) create mode 100644 module/profession/validation.js create mode 100644 module/roll/classes/dg-damage-roll.js create mode 100644 module/roll/classes/dg-lethality-roll.js rename module/roll/{roll-classes.js => classes/dg-percentile-roll.js} (54%) create mode 100644 module/roll/classes/dg-roll.js create mode 100644 module/roll/classes/dg-sanity-damage-roll.js create mode 100644 module/roll/melee-damage.js delete mode 100644 module/utils/profession-skills.js rename scss/{dialogue.scss => damage-modify.scss} (100%) create mode 100644 templates/actor/partials/agent-skill-row-partial.html create mode 100644 templates/actor/partials/agent-special-training-row-partial.html diff --git a/lang/en.json b/lang/en.json index 37ecb85..15c6f2d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,5 +1,4 @@ { - "DG.SystemName": "Delta Green", "DG.AgentNameFieldTooltip": "LAST NAME, FIRST NAME (AND ALIAS IF APPLICABLE)", "DG.RollLuck": "Roll Luck", "DG.Luck": "Luck", @@ -42,6 +41,8 @@ "DG.Skills.unarmed_combat": "Unarmed Combat", "DG.Skills.unnatural": "Unnatural", "DG.Skills.flight": "Flight", + "DG.Skills.Tooltip.flight": "Fly or maneuver in air using wings or unnatural means.", + "DG.Skills.Tooltip.ritual": "Perform occult rituals; each ritual is a separate skill.", "DG.Skills.Tooltip.accounting": "Spot financial red flags like hidden assets, shell companies, or embezzlement.", "DG.Skills.Tooltip.alertness": "Notice hidden dangers, subtle movements, or quiet sounds before they become threats.", "DG.Skills.Tooltip.anthropology": "Understand the beliefs, behaviors, and taboos of living cultures and societies.", @@ -81,6 +82,7 @@ "DG.Skills.Tooltip.unnatural": "Recognize, research, or recall truths about entities and forces beyond human understanding.", "DG.Skills.AddTypedOrCustomSkill": "Add Typed/Custom Skill", "DG.Skills.ToggleEditDeleteSkills": "Edit/Delete Skills", + "DG.Skills.EditDeleteTypedSkills": "Edit/Delete Typed Skills", "DG.Skills.AddSkill": "Add Skill", "DG.Skills.Apply": "Apply", "DG.Skills.ApplySkillImprovements.Title": "Apply Skill Improvements", @@ -155,6 +157,7 @@ "DG.About.OnlineHelpTooltip": "Launch browser with online GitHub help.", "DG.About.OnlineBugTracker": "Questions, Bug Reports or Feature Requests (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Launch browser with GitHub issue tracker.", + "DG.About.ChangelogTooltip": "Launch browser with the online changelog.", "DG.Roll.Target": "Target: ", "DG.Roll.Success": "Success", "DG.Roll.Failure": "Failure", @@ -164,7 +167,6 @@ "DG.Roll.Lethal": "Lethal", "DG.Roll.For": "For", "DG.Roll.Damage": "Damage", - "DG.Roll.Or": "Or", "DG.Roll.Roll": "Roll", "DG.Physical.Wounds": "Wounds", "DG.Physical.FirstAidLabel": "First Aid Has Already Been Attempted: ", @@ -175,7 +177,6 @@ "DG.Physical.SuppressExhaustion": "Exhaustion Suppressed", "DG.Physical.ExhaustionEffectName": "Exhaustion", "DG.Physical.StimulantsEffectName": "Stimulants", - "DG.Physical.RestBlockedWhileSuppressed": "{name} cannot rest while exhaustion is suppressed (stimulants or manual suppression).", "DG.Physical.ExhaustDialogTitle": "Exhaust Agent", "DG.Physical.RollWillpowerLoss": "Roll Willpower Loss", "DG.Physical.StimulantsDialogTitle": "Take Stimulants", @@ -219,7 +220,6 @@ "DG.Mental.BreakingPoint": "Breaking Point: ", "DG.Mental.Tooltip.BreakingPoint": "When SAN falls to or below this number, Agent gains a long-term mental disorder; set a new Breaking Point to current SAN - POW. Disorders remain even if SAN later rises above a previous Breaking Point.", "DG.Mental.Reset": "Reset", - "DG.Mental.SanityLossIncidentsLabel": "Incidents of SAN Loss Without Going Insane", "DG.Mental.Violence": "Violence", "DG.Mental.Tooltip.Violence": "Adapting to Violence: After losing SAN to violence three times without going insane, you become hardened to it. You automatically succeed SAN rolls against violence, but permanently lose 1D6 CHA and that much from each Bond. Agents Handbook Page 73.", "DG.Mental.Tooltip.Checkbox.Violence": "When you lose SAN due to violence and without going temporarily insane or hitting your breaking point, increase until adapted. Agents Handbook Page 73.", @@ -250,10 +250,8 @@ "DG.CV.NationalityPlaceholder": "Nationality", "DG.CV.Education": "Education", "DG.CV.EducationPlaceholder": "Education or Training", - "DG.Bonds.Bond": "Bond", "DG.Bonds.Name": "Name", "DG.Bonds.Score": "Score", - "DG.Bonds.NewBond": "New Bond", "DG.Tooltip.MaximumHP": "Maximum health is the average of STR + CON, rounded UP.", "DG.Tooltip.MaximumWP": "Maximum WP is equal to POW.", "DG.Tooltip.CurrentSanityPartOne": "Current sanity, current breaking point is ", @@ -280,7 +278,6 @@ "DG.Gear.Expense.Extreme": "Extreme", "DG.ItemWindow.Bonds.Description": "Description/Developments", "DG.ItemWindow.Bonds.ScoreTooltip": "A higher score represents a stronger relationship between the agent and the bond.", - "DG.ItemWindow.Motivations.Attributes": "Attributes", "DG.ItemWindow.Motivations.Motivation": "Motivation", "DG.ItemWindow.Motivations.Description": "Description/Notes", "DG.ItemWindow.Motivations.Disorder": "Disorder", @@ -292,7 +289,6 @@ "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "A critical success on a therapy luck roll can 'cure' a disorder. It may return the next time a breaking point is hit however. See Pages 78-79 of the Agent's Handbook - Therapy.", "DG.ItemWindow.Motivations.AcuteEpisode": "Acute Episode", "DG.ItemWindow.Motivations.AcuteEpisodeTooltip": "While checked, transferred active effects on this motivation apply to the agent.", - "DG.ItemWindow.Gear.Attributes": "Attributes", "DG.ItemWindow.Gear.Description": "Description", "DG.ItemWindow.Gear.NamePlaceholder": "Name", "DG.ItemWindow.Gear.Expense": "Expense", @@ -333,18 +329,14 @@ "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Use this field to add a flat modifier such as '+1' or another dice term such as '+1d4'.", "DG.Roll.Inhuman": "Inhuman", "DG.Tooltip.BreakPoint": "The current breaking point, reset when current sanity falls to or below this value. The starting breaking point is SAN - POW.", - "DG.Tooltip.BreakPointReset": "Reset breaking point to be current SAN - WP.", "DG.Skills.ritual": "Ritual", "DG.RollMode": "Roll Mode", "DG.Bonds.Relationship": "Relationship", - "DG.TypeSkills.Subskills.Painting": "Painting", - "DG.Sheet.BlockHeaders.Attributes": "ATTRIBUTES", "DG.Sheet.BlockHeaders.Statistics": "STATISTICS", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "PHYSICAL DESCRIPTION / NOTES", "DG.Sheet.BlockHeaders.SkillsAndTraining": "SKILLS AND TRAINING", "DG.Sheet.BlockHeaders.SpecialTraining": "SPECIAL TRAINING", "DG.Sheet.BlockHeaders.Injuries": "INJURIES", - "DG.Sheet.BlockHeaders.BreakingPointAndAdaptations": "BREAKING POINT AND ADAPTATIONS", "DG.Sheet.BlockHeaders.Adaptations": "Adaptations", "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVATIONS AND DISORDERS", "DG.Sheet.BlockHeaders.PersonalInfoCV": "PERSONAL INFORMATION / CV", @@ -353,11 +345,8 @@ "DG.AgentSheet.ProfessionPlaceholder": "PROFESSION (RANK IF APPLICABLE)", "DG.AgentSheet.OpenProfessionToEdit": "Click to edit profession", "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Hide Untrained", - "DG.UnnaturalSheet.NotesLabel": "Notes", "DG.UnnaturalSheet.ShortDescriptionPlaceholder": "Description of creature or creature type", "DG.UnnaturalSheet.ShowUntrainedSkillsLabel": "Show Untrained", - "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Skills & Notes", - "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Attacks & Abilities", "DG.NPCSheet.BreakPointAbbreviation": "BP", "DG.ImpossibleLanscapes.IHaveSeenThe": "I HAVE SEEN THE", "DG.ImpossibleLanscapes.YellowSign": "YELLOW SIGN", @@ -365,9 +354,6 @@ "DG.ImpossibleLanscapes.CorruptionSecondHalf": "tion", "DG.ImpossibleLanscapes.Gift": "Gift", "DG.ImpossibleLanscapes.Insight": "Insight", - "DG.VehicleSheet.NotesTabName": "Notes", - "DG.VehicleSheet.GearTabName": "Gear", - "DG.VehicleSheet.NotesLabel": "NOTES", "DG.VehicleSheet.DescriptionPlaceHolder": "Vehicle Description", "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Check a damaged Bond's box until the next Home Scene ends.", "DG.Generic.ClearAll": "Clear Checks", @@ -393,7 +379,6 @@ "TYPES.Item.ritual": "Ritual", "TYPES.Item.profession": "Profession", "DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.", - "DG.Profession.AssignedHeader": "ASSIGNED PROFESSION ITEM", "DG.Profession.Dialog.Title": "Character Creation", "DG.Profession.Dialog.Tab.SkillOptions": "Skill Options", "DG.Profession.Dialog.Tab.BonusSkills": "Bonus Skills", @@ -457,6 +442,7 @@ "DG.Tome.Name": "Name", "DG.Tome.Language": "Language", "DG.Tome.StudyTime": "Study Time", + "DG.ItemWindow.Tome.NamePlaceholder": "Name", "DG.ItemWindow.Tome.Description": "Description", "DG.ItemWindow.Tome.LanguagePlaceholder": "Language", "DG.ItemWindow.Tome.StudyTime": "Study Time", diff --git a/module/README.md b/module/README.md new file mode 100644 index 0000000..a976f0c --- /dev/null +++ b/module/README.md @@ -0,0 +1,29 @@ +# Delta Green system — `module/` layout + +Entry point: [`deltagreen.js`](deltagreen.js) (registered in `system.json`). + +## Folder responsibilities + +| Path | Purpose | +|------|---------| +| [`config/`](config/) | System constants (`DG`, skill list, template paths) | +| [`data/`](data/) | TypeDataModels (`actor/`, `item/`, `derived/`) | +| [`active-effect/`](active-effect/) | Active effect metadata, data model, document class, runtime sync | +| [`profession/`](profession/) | Profession item skill catalog, validation, and character-creation math | +| [`roll/`](roll/) | Dice roll subclasses, roll pipeline, and roll UX. [`roll/roll.js`](roll/roll.js) is the public API. Internal Roll subclasses live in [`roll/classes/`](roll/classes/). | +| [`sheets/`](sheets/) | ApplicationV2 sheets and mixins | +| [`applications/`](applications/) | Standalone dialogs (profession setup, stats, active effect config) | +| [`actor/`](actor/), [`item/`](item/) | Document subclasses | +| [`chat/`](chat/) | Chat cards and inline actions | +| [`integrations/`](integrations/) | Optional module hooks (Dice So Nice) | +| [`utils/`](utils/) | Generic helpers only (Handlebars, migration, skill layout/tooltips) | + +## Import conventions + +- **Roll types and pipeline:** import `DGRoll`, `DGPercentileRoll`, `createDGRollFromDataset`, `processDGRoll`, etc. from [`roll/roll.js`](roll/roll.js) only — not from [`roll/classes/`](roll/classes/). +- **Roll modifier dialogs:** import from [`roll/roll-dialogs.js`](roll/roll-dialogs.js) only when calling dialogs directly (today only used from roll classes). +- **Skill improvement rolls:** import from [`roll/skill-improvement-roll.js`](roll/skill-improvement-roll.js) (used by agent sheet). +- **Profession logic:** import from [`profession/index.js`](profession/index.js). +- **Active effect runtime:** import exhaustion/stimulant sync from [`active-effect/runtime/`](active-effect/runtime/). +- **Derived actor math:** import from [`data/derived/actor-derived.js`](data/derived/actor-derived.js). +- **Agent skill defaults:** single source in [`data/actor/base/agent-skill-defaults.js`](data/actor/base/agent-skill-defaults.js) (used by human actor schema and profession code). diff --git a/module/data/active-effect/dg-active-effect-data.js b/module/active-effect/data/dg-active-effect-data.js similarity index 100% rename from module/data/active-effect/dg-active-effect-data.js rename to module/active-effect/data/dg-active-effect-data.js diff --git a/module/documents/dg-active-effect.js b/module/active-effect/documents/dg-active-effect.js similarity index 100% rename from module/documents/dg-active-effect.js rename to module/active-effect/documents/dg-active-effect.js diff --git a/module/active-effect/runtime/agent-condition-sync.js b/module/active-effect/runtime/agent-condition-sync.js new file mode 100644 index 0000000..62b3f48 --- /dev/null +++ b/module/active-effect/runtime/agent-condition-sync.js @@ -0,0 +1,25 @@ +import DG from "../../config/index.js"; + +/** + * @param {Actor} actor + * @returns {boolean} + */ +function hasActiveStimulantEffect(actor) { + return ( + actor.effects?.some( + (effect) => effect.getFlag(DG.ID, "stimulant") && !effect.isSuppressed, + ) ?? false + ); +} + +/** + * Persisted manual suppress or an active stimulant AE. + * + * @param {Actor} actor + * @returns {boolean} + */ +export function getEffectiveSuppressExhaustion(actor) { + const persisted = + actor._source?.system?.physical?.suppressExhaustion ?? false; + return Boolean(persisted) || hasActiveStimulantEffect(actor); +} diff --git a/module/utils/active-effect-derived.js b/module/active-effect/runtime/derived.js similarity index 99% rename from module/utils/active-effect-derived.js rename to module/active-effect/runtime/derived.js index 4b4eaf6..56ed41f 100644 --- a/module/utils/active-effect-derived.js +++ b/module/active-effect/runtime/derived.js @@ -7,7 +7,7 @@ import { prepareAgentSkillFlags, prepareBreakingPointHit, prepareStatisticsX5, -} from "./derived-actor-data.js"; +} from "../../data/derived/actor-derived.js"; /** * @param {number} current diff --git a/module/utils/exhaustion-effect.js b/module/active-effect/runtime/exhaustion-effect.js similarity index 95% rename from module/utils/exhaustion-effect.js rename to module/active-effect/runtime/exhaustion-effect.js index b10f919..30643c3 100644 --- a/module/utils/exhaustion-effect.js +++ b/module/active-effect/runtime/exhaustion-effect.js @@ -1,6 +1,6 @@ -import DG from "../config.js"; -import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.js"; -import { getEffectiveSuppressExhaustion } from "./stimulant-effect.js"; +import DG from "../../config/index.js"; +import { ROLL_TARGET_FIELD_KEYS } from "../effect-fields.js"; +import { getEffectiveSuppressExhaustion } from "./agent-condition-sync.js"; /** @type {WeakMap>} */ const syncLocks = new WeakMap(); diff --git a/module/utils/stimulant-effect.js b/module/active-effect/runtime/stimulant-effect.js similarity index 91% rename from module/utils/stimulant-effect.js rename to module/active-effect/runtime/stimulant-effect.js index 08afcfd..9206c49 100644 --- a/module/utils/stimulant-effect.js +++ b/module/active-effect/runtime/stimulant-effect.js @@ -1,4 +1,4 @@ -import DG from "../config.js"; +import DG from "../../config/index.js"; import { syncExhaustionEffect } from "./exhaustion-effect.js"; /** @@ -51,16 +51,7 @@ function getStimulantRemainingHours(effect) { return 0; } -/** - * Persisted manual suppress or an active stimulant AE. - * @param {Actor} actor - * @returns {boolean} - */ -export function getEffectiveSuppressExhaustion(actor) { - const persisted = - actor._source?.system?.physical?.suppressExhaustion ?? false; - return Boolean(persisted) || hasActiveStimulantEffect(actor); -} +export { getEffectiveSuppressExhaustion } from "./agent-condition-sync.js"; /** * @param {number} hours diff --git a/module/utils/active-effect-submit.js b/module/active-effect/runtime/submit.js similarity index 90% rename from module/utils/active-effect-submit.js rename to module/active-effect/runtime/submit.js index acf81a9..691942d 100644 --- a/module/utils/active-effect-submit.js +++ b/module/active-effect/runtime/submit.js @@ -1,5 +1,5 @@ -import DG from "../config.js"; -import { getStatisticEffectiveValue } from "./derived-actor-data.js"; +import DG from "../../config/index.js"; +import { getStatisticEffectiveValue } from "../../data/derived/actor-derived.js"; /** * Strip form values that match post–AE prepared numbers so persisted data stays at base values. diff --git a/module/actor/actor.js b/module/actor/actor.js index e424442..f434548 100644 --- a/module/actor/actor.js +++ b/module/actor/actor.js @@ -1,11 +1,11 @@ -import { refreshDerivedAfterActiveEffects } from "../utils/active-effect-derived.js"; +import { refreshDerivedAfterActiveEffects } from "../active-effect/runtime/derived.js"; import { prepareAgentStatSanityTooltips } from "../utils/roll-target-tooltip.js"; import { syncExhaustionEffect, updateTouchesExhaustionPhysical, -} from "../utils/exhaustion-effect.js"; -import { pruneExpiredStimulantEffects } from "../utils/stimulant-effect.js"; -import { sanitizeActiveEffectBackedUpdateData } from "../utils/active-effect-submit.js"; +} from "../active-effect/runtime/exhaustion-effect.js"; +import { pruneExpiredStimulantEffects } from "../active-effect/runtime/stimulant-effect.js"; +import { sanitizeActiveEffectBackedUpdateData } from "../active-effect/runtime/submit.js"; /** * Extend the base Actor entity by defining a custom roll data structure which is ideal for the Simple system. diff --git a/module/applications/add-profession-dialog.js b/module/applications/add-profession-dialog.js index 8f868de..920e4da 100644 --- a/module/applications/add-profession-dialog.js +++ b/module/applications/add-profession-dialog.js @@ -1,4 +1,4 @@ -import { BASE_TEMPLATE_PATH } from "../config.js"; +import { BASE_TEMPLATE_PATH } from "../config/index.js"; import { bindDialogTabs, getDialogContentRoot, @@ -15,7 +15,7 @@ import { parseProfessionSkillKey, prepareProfessionSkillRows, splitProfessionSkillMap, -} from "../utils/profession-skills.js"; +} from "../profession/index.js"; import { reorderForColumnSorting, splitIntoColumns, diff --git a/module/applications/assign-stats-dialog.js b/module/applications/assign-stats-dialog.js index 91fbdcd..591bbdb 100644 --- a/module/applications/assign-stats-dialog.js +++ b/module/applications/assign-stats-dialog.js @@ -1,4 +1,4 @@ -import { BASE_TEMPLATE_PATH } from "../config.js"; +import { BASE_TEMPLATE_PATH } from "../config/index.js"; import { STAT_KEYS, STAT_MAX, @@ -7,7 +7,7 @@ import { buildStatisticRows, getDefaultPointBuyValues, validatePointBuyValues, -} from "../utils/profession-stat-setup.js"; +} from "../profession/stat-setup.js"; import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js"; const { renderTemplate } = foundry.applications.handlebars; diff --git a/module/applications/dg-active-effect-config.js b/module/applications/dg-active-effect-config.js index d6d8702..284b503 100644 --- a/module/applications/dg-active-effect-config.js +++ b/module/applications/dg-active-effect-config.js @@ -1,4 +1,4 @@ -import DG from "../config.js"; +import DG from "../config/index.js"; import { buildEffectFieldSelectOptions, getEffectFieldGroupsForDocument, diff --git a/module/applications/dg-dialog.js b/module/applications/dg-dialog.js index 792cd97..010c61b 100644 --- a/module/applications/dg-dialog.js +++ b/module/applications/dg-dialog.js @@ -1,4 +1,4 @@ -import DG from "../config.js"; +import DG from "../config/index.js"; const { DialogV2 } = foundry.applications.api; diff --git a/module/applications/edit-stats.js b/module/applications/edit-stats.js index 4763621..d4d05b7 100644 --- a/module/applications/edit-stats.js +++ b/module/applications/edit-stats.js @@ -1,4 +1,4 @@ -import DG, { BASE_TEMPLATE_PATH } from "../config.js"; +import DG, { BASE_TEMPLATE_PATH } from "../config/index.js"; export default class ActorEditStatForm extends foundry.applications.api.HandlebarsApplicationMixin( foundry.applications.api.ApplicationV2, diff --git a/module/applications/pick-statistics-dialog.js b/module/applications/pick-statistics-dialog.js index 31da8ef..38d94da 100644 --- a/module/applications/pick-statistics-dialog.js +++ b/module/applications/pick-statistics-dialog.js @@ -1,4 +1,4 @@ -import { BASE_TEMPLATE_PATH } from "../config.js"; +import { BASE_TEMPLATE_PATH } from "../config/index.js"; import { showDgDialog } from "./dg-dialog.js"; const { renderTemplate } = foundry.applications.handlebars; diff --git a/module/applications/profession-setup-flow.js b/module/applications/profession-setup-flow.js index e3055b4..cdb2559 100644 --- a/module/applications/profession-setup-flow.js +++ b/module/applications/profession-setup-flow.js @@ -24,7 +24,7 @@ function createProfessionStub(actor, itemData) { * @param {TokenDocument|null} [options.token] * @returns {Promise} True if Character Creation was submitted successfully. */ -export async function runProfessionSetupFlow( +async function runProfessionSetupFlow( professionItem, actor, { token = null } = {}, diff --git a/module/applications/roll-stats-dialog.js b/module/applications/roll-stats-dialog.js index a854a8b..a3c1820 100644 --- a/module/applications/roll-stats-dialog.js +++ b/module/applications/roll-stats-dialog.js @@ -1,4 +1,4 @@ -import { BASE_TEMPLATE_PATH } from "../config.js"; +import { BASE_TEMPLATE_PATH } from "../config/index.js"; import { createDGChatMessage } from "../chat/dg-chat-card.js"; import { STAT_KEYS, @@ -6,7 +6,7 @@ import { buildRollStatsChatContent, buildStatisticRows, rollStatisticScores, -} from "../utils/profession-stat-setup.js"; +} from "../profession/stat-setup.js"; import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js"; const { renderTemplate } = foundry.applications.handlebars; diff --git a/module/chat/dg-chat-card.js b/module/chat/dg-chat-card.js index da015db..9326d1e 100644 --- a/module/chat/dg-chat-card.js +++ b/module/chat/dg-chat-card.js @@ -1,6 +1,6 @@ /* global TokenDocument */ -import DG from "../config.js"; +import DG from "../config/index.js"; const CHAT_CARD_TEMPLATE = "systems/deltagreen/templates/chat/dg-chat-card.hbs"; const { renderTemplate } = foundry.applications.handlebars; diff --git a/module/utils/resource-chat.js b/module/chat/resource-chat.js similarity index 70% rename from module/utils/resource-chat.js rename to module/chat/resource-chat.js index c86ecda..20fe6be 100644 --- a/module/utils/resource-chat.js +++ b/module/chat/resource-chat.js @@ -1,7 +1,4 @@ -import { - createDGChatMessage, - createDGRollChatMessage, -} from "../chat/dg-chat-card.js"; +import { createDGRollChatMessage } from "./dg-chat-card.js"; /** * @param {object} params @@ -10,8 +7,8 @@ import { * @param {string} params.contentKey i18n key for message body. * @param {string} params.labelKey i18n key for card subtitle. * @param {Record} params.i18nData + * @param {Roll} params.roll Roll attached to the chat message for display and Dice So Nice. * @param {string|null} [params.extraContentKey] Optional i18n key appended on a new line after the main body. - * @param {Roll} [params.roll] When set, attaches the roll to the chat message for display and Dice So Nice. * @returns {Promise} */ export async function createAgentResourceChatMessage({ @@ -20,8 +17,8 @@ export async function createAgentResourceChatMessage({ contentKey, labelKey, i18nData, + roll, extraContentKey = null, - roll = null, }) { let content = game.i18n.format(contentKey, i18nData); if (extraContentKey) { @@ -30,20 +27,10 @@ export async function createAgentResourceChatMessage({ const label = game.i18n.localize(labelKey); const messageMode = game.settings.get("core", "messageMode"); - if (roll) { - return createDGRollChatMessage({ - actor, - token, - roll, - label, - content, - messageMode, - }); - } - - return createDGChatMessage({ + return createDGRollChatMessage({ actor, token, + roll, label, content, messageMode, diff --git a/module/config.js b/module/config/index.js similarity index 100% rename from module/config.js rename to module/config/index.js diff --git a/module/data/actor/agent.js b/module/data/actor/agent.js index 0caf7ee..be66f0c 100644 --- a/module/data/actor/agent.js +++ b/module/data/actor/agent.js @@ -10,9 +10,9 @@ import { prepareSanityAdaptations, prepareStatisticsX5, removeLegacyRitualSkill, -} from "../../utils/derived-actor-data.js"; +} from "../../data/derived/actor-derived.js"; -const { SchemaField, NumberField, StringField, BooleanField, HTMLField } = +const { SchemaField, NumberField, StringField, BooleanField } = foundry.data.fields; export default class AgentData extends CharacterData { diff --git a/module/data/actor/base/agent-skill-defaults.js b/module/data/actor/base/agent-skill-defaults.js new file mode 100644 index 0000000..7b65a88 --- /dev/null +++ b/module/data/actor/base/agent-skill-defaults.js @@ -0,0 +1,39 @@ +/** Default proficiency for fixed agent skills (single source of truth for schema initials). */ +export const AGENT_SKILL_DEFAULTS = /** @type {Record} */ ({ + accounting: 10, + alertness: 20, + anthropology: 0, + archeology: 0, + artillery: 0, + athletics: 30, + bureaucracy: 10, + computer_science: 0, + criminology: 10, + demolitions: 0, + disguise: 10, + dodge: 30, + drive: 20, + firearms: 20, + first_aid: 10, + forensics: 0, + heavy_machiner: 10, + heavy_weapons: 0, + history: 10, + humint: 10, + law: 0, + medicine: 0, + melee_weapons: 30, + navigate: 10, + occult: 10, + persuade: 20, + pharmacy: 0, + psychotherapy: 10, + ride: 10, + search: 20, + sigint: 0, + stealth: 10, + surgery: 0, + survival: 10, + swim: 20, + unarmed_combat: 40, +}); diff --git a/module/data/actor/base/human-skills.js b/module/data/actor/base/human-skills.js index a564814..39176a3 100644 --- a/module/data/actor/base/human-skills.js +++ b/module/data/actor/base/human-skills.js @@ -1,3 +1,4 @@ +import { AGENT_SKILL_DEFAULTS } from "./agent-skill-defaults.js"; import { skillField } from "./general.js"; const { SchemaField, NumberField, StringField, ArrayField, ObjectField } = @@ -6,44 +7,45 @@ const { TypeDataModel } = foundry.abstract; export default class HumanSkillsActorData extends TypeDataModel { static defineSchema() { + const d = AGENT_SKILL_DEFAULTS; return { skills: new SchemaField({ - accounting: skillField(10, "Accounting"), - alertness: skillField(20, "Alertness"), - anthropology: skillField(0, "Anthropology"), - archeology: skillField(0, "Archeology"), - artillery: skillField(0, "Artillery"), - athletics: skillField(30, "Athletics"), - bureaucracy: skillField(10, "Bureaucracy"), - computer_science: skillField(0, "Computer Science"), - criminology: skillField(10, "Criminology"), - demolitions: skillField(0, "Demolitions"), - disguise: skillField(10, "Disguise"), - dodge: skillField(30, "Dodge"), - drive: skillField(20, "Drive"), - firearms: skillField(20, "Firearms"), - first_aid: skillField(10, "First Aid"), - forensics: skillField(0, "Forensics"), - heavy_machiner: skillField(10, "Heavy Machinery"), - heavy_weapons: skillField(0, "Heavy Weapons"), - history: skillField(10, "History"), - humint: skillField(10, "HUMINT"), - law: skillField(0, "Law"), - medicine: skillField(0, "Medicine"), - melee_weapons: skillField(30, "Melee Weapons"), - navigate: skillField(10, "Navigate"), - occult: skillField(10, "Occult"), - persuade: skillField(20, "Persuade"), - pharmacy: skillField(0, "Pharmacy"), - psychotherapy: skillField(10, "Psychotherapy"), - ride: skillField(10, "Ride"), - search: skillField(20, "Search"), - sigint: skillField(0, "SIGINT"), - stealth: skillField(10, "Stealth"), - surgery: skillField(0, "Surgery"), - survival: skillField(10, "Survival"), - swim: skillField(20, "Swim"), - unarmed_combat: skillField(40, "Unarmed Combat"), + accounting: skillField(d.accounting, "Accounting"), + alertness: skillField(d.alertness, "Alertness"), + anthropology: skillField(d.anthropology, "Anthropology"), + archeology: skillField(d.archeology, "Archeology"), + artillery: skillField(d.artillery, "Artillery"), + athletics: skillField(d.athletics, "Athletics"), + bureaucracy: skillField(d.bureaucracy, "Bureaucracy"), + computer_science: skillField(d.computer_science, "Computer Science"), + criminology: skillField(d.criminology, "Criminology"), + demolitions: skillField(d.demolitions, "Demolitions"), + disguise: skillField(d.disguise, "Disguise"), + dodge: skillField(d.dodge, "Dodge"), + drive: skillField(d.drive, "Drive"), + firearms: skillField(d.firearms, "Firearms"), + first_aid: skillField(d.first_aid, "First Aid"), + forensics: skillField(d.forensics, "Forensics"), + heavy_machiner: skillField(d.heavy_machiner, "Heavy Machinery"), + heavy_weapons: skillField(d.heavy_weapons, "Heavy Weapons"), + history: skillField(d.history, "History"), + humint: skillField(d.humint, "HUMINT"), + law: skillField(d.law, "Law"), + medicine: skillField(d.medicine, "Medicine"), + melee_weapons: skillField(d.melee_weapons, "Melee Weapons"), + navigate: skillField(d.navigate, "Navigate"), + occult: skillField(d.occult, "Occult"), + persuade: skillField(d.persuade, "Persuade"), + pharmacy: skillField(d.pharmacy, "Pharmacy"), + psychotherapy: skillField(d.psychotherapy, "Psychotherapy"), + ride: skillField(d.ride, "Ride"), + search: skillField(d.search, "Search"), + sigint: skillField(d.sigint, "SIGINT"), + stealth: skillField(d.stealth, "Stealth"), + surgery: skillField(d.surgery, "Surgery"), + survival: skillField(d.survival, "Survival"), + swim: skillField(d.swim, "Swim"), + unarmed_combat: skillField(d.unarmed_combat, "Unarmed Combat"), unnatural: new SchemaField({ proficiency: new NumberField({ initial: 0, min: 0, max: 99, integer: true }), label: new StringField({ initial: "Unnatural" }), diff --git a/module/data/actor/npc.js b/module/data/actor/npc.js index e96fd65..f7d4930 100644 --- a/module/data/actor/npc.js +++ b/module/data/actor/npc.js @@ -11,9 +11,9 @@ import { prepareStatisticsX5, removeLegacyRitualSkill, setSkillTargetProficiencies, -} from "../../utils/derived-actor-data.js"; +} from "../../data/derived/actor-derived.js"; -const { SchemaField, NumberField, StringField, BooleanField, HTMLField } = +const { SchemaField, NumberField, StringField, BooleanField } = foundry.data.fields; export default class NPCData extends CharacterData { diff --git a/module/data/actor/unnatural.js b/module/data/actor/unnatural.js index 915acbb..09dfe84 100644 --- a/module/data/actor/unnatural.js +++ b/module/data/actor/unnatural.js @@ -8,9 +8,9 @@ import { prepareStatisticsX5, removeLegacyRitualSkill, setSkillTargetProficiencies, -} from "../../utils/derived-actor-data.js"; +} from "../../data/derived/actor-derived.js"; -const { SchemaField, NumberField, StringField, BooleanField, HTMLField } = +const { SchemaField, NumberField, StringField, BooleanField } = foundry.data.fields; export default class UnnaturalData extends CharacterData { diff --git a/module/data/actor/vehicle.js b/module/data/actor/vehicle.js index 6703686..a0f933b 100644 --- a/module/data/actor/vehicle.js +++ b/module/data/actor/vehicle.js @@ -1,8 +1,8 @@ import SheetSettingsActorData from "./base/sheet-settings.js"; import DGHTMLField from "../fields/html-content-field.js"; -import { computeEquippedArmorProtection } from "../../utils/derived-actor-data.js"; +import { computeEquippedArmorProtection } from "../../data/derived/actor-derived.js"; -const { SchemaField, NumberField, StringField, ArrayField, HTMLField } = +const { SchemaField, NumberField, StringField, ArrayField } = foundry.data.fields; export default class VehicleData extends foundry.abstract.TypeDataModel { diff --git a/module/utils/derived-actor-data.js b/module/data/derived/actor-derived.js similarity index 100% rename from module/utils/derived-actor-data.js rename to module/data/derived/actor-derived.js diff --git a/module/data/item/ritual.js b/module/data/item/ritual.js index 71d58e1..f4ea744 100644 --- a/module/data/item/ritual.js +++ b/module/data/item/ritual.js @@ -1,7 +1,7 @@ import defineBaseItemSystemFields from "./base-fields.js"; import DGHTMLField from "../fields/html-content-field.js"; -const { NumberField, StringField, BooleanField, SchemaField, HTMLField } = +const { NumberField, StringField, BooleanField, SchemaField } = foundry.data.fields; export default class RitualItemData extends foundry.abstract.TypeDataModel { diff --git a/module/data/item/tome.js b/module/data/item/tome.js index d2eab1f..5c3a9a5 100644 --- a/module/data/item/tome.js +++ b/module/data/item/tome.js @@ -1,7 +1,7 @@ import defineBaseItemSystemFields from "./base-fields.js"; import DGHTMLField from "../fields/html-content-field.js"; -const { NumberField, StringField, BooleanField, SchemaField, HTMLField } = +const { NumberField, StringField, BooleanField, SchemaField } = foundry.data.fields; export default class TomeItemData extends foundry.abstract.TypeDataModel { diff --git a/module/deltagreen.js b/module/deltagreen.js index e1dcb25..b5138ae 100644 --- a/module/deltagreen.js +++ b/module/deltagreen.js @@ -1,5 +1,5 @@ // Import Modules -import DG from "./config.js"; +import DG from "./config/index.js"; import DeltaGreenActor from "./actor/actor.js"; import DGAgentSheet from "./sheets/agent-sheet.js"; import DeltaGreenItem from "./item/item.js"; @@ -10,7 +10,7 @@ import { DGLethalityRoll, DGDamageRoll, DGSanityDamageRoll, -} from "./roll/roll-classes.js"; +} from "./roll/roll.js"; import registerSystemSettings from "./settings.js"; import preloadHandlebarsTemplates from "./templates.js"; import registerHandlebarsHelpers from "./utils/register-helpers.js"; @@ -39,14 +39,14 @@ import GearItemData from "./data/item/gear.js"; import TomeItemData from "./data/item/tome.js"; import RitualItemData from "./data/item/ritual.js"; import ProfessionItemData from "./data/item/profession.js"; -import DGActiveEffect from "./documents/dg-active-effect.js"; +import DGActiveEffect from "./active-effect/documents/dg-active-effect.js"; import DGActiveEffectConfig from "./applications/dg-active-effect-config.js"; -import DGActiveEffectTypeDataModel from "./data/active-effect/dg-active-effect-data.js"; -import { syncExhaustionEffect } from "./utils/exhaustion-effect.js"; +import DGActiveEffectTypeDataModel from "./active-effect/data/dg-active-effect-data.js"; +import { syncExhaustionEffect } from "./active-effect/runtime/exhaustion-effect.js"; import { pruneAllAgentsExpiredStimulants, pruneExpiredStimulantEffects, -} from "./utils/stimulant-effect.js"; +} from "./active-effect/runtime/stimulant-effect.js"; const { Actors, Items } = foundry.documents.collections; const { DocumentSheetConfig } = foundry.applications.apps; diff --git a/module/utils/dice-so-nice.js b/module/integrations/dice-so-nice.js similarity index 100% rename from module/utils/dice-so-nice.js rename to module/integrations/dice-so-nice.js diff --git a/module/item/item.js b/module/item/item.js index 785f9ac..d8d08b8 100644 --- a/module/item/item.js +++ b/module/item/item.js @@ -1,4 +1,5 @@ import { getDGRollToken } from "../chat/dg-chat-card.js"; +import { appendMeleeDamageBonus } from "../roll/melee-damage.js"; import { DGDamageRoll, DGLethalityRoll } from "../roll/roll.js"; /** @@ -6,13 +7,6 @@ import { DGDamageRoll, DGLethalityRoll } from "../roll/roll.js"; * @extends {Item} */ export default class DeltaGreenItem extends Item { - /** - * Augment the basic Item data model with additional dynamic data. - */ - prepareData() { - super.prepareData(); - } - /** * Handle clickable rolls. * @param {Event} event The originating click event @@ -22,8 +16,6 @@ export default class DeltaGreenItem extends Item { // Basic template rendering data const item = this; const { actor } = this; - const actorSystemData = this.actor.system || {}; - let roll; if (item.system.isLethal) { roll = new DGLethalityRoll( @@ -38,12 +30,11 @@ export default class DeltaGreenItem extends Item { ); } else { // regular damage roll - let diceFormula = item.system.damage; - const skillType = item.system.skill; - - if (skillType === "unarmed_combat" || skillType === "melee_weapons") { - diceFormula += actorSystemData.statistics.str.meleeDamageBonusFormula; - } + let diceFormula = appendMeleeDamageBonus( + item.system.damage, + actor, + item.system.skill, + ); if (isCrit) { diceFormula = `2*(${diceFormula})`; diff --git a/module/macros/stat-parser-macro.js b/module/macros/stat-parser-macro.js index cdbf909..ca6787b 100644 --- a/module/macros/stat-parser-macro.js +++ b/module/macros/stat-parser-macro.js @@ -111,9 +111,13 @@ function GetAttacksFromInput(inputText) { } } - // look for armor piercing if (attackLine.indexOf("ARMOR PIERCING") >= 0) { - // TODO: This should do something. + const apMatch = attackLine.match(/ARMOR\s+PIERCING\s*(\d+)/i); + const armorPiercing = apMatch?.[1] + ? parseInt(apMatch[1], 10) + : 5; + weaponData.armorPiercing = armorPiercing; + weaponData.system.armorPiercing = armorPiercing; } } diff --git a/module/profession/catalog.js b/module/profession/catalog.js new file mode 100644 index 0000000..ca9df76 --- /dev/null +++ b/module/profession/catalog.js @@ -0,0 +1,223 @@ +import DG from "../config/index.js"; +import { + BONUS_SKILL_CATALOG_EXCLUDED_FIXED, + BONUS_SKILL_COUNT, + BONUS_SKILL_INCREMENT, + MAX_ALLOWED_BONUS_WASTE, + SKILL_CAP, + TYPED_SKILL_TEMPLATE_GROUPS, +} from "./constants.js"; +import { + formatProfessionSkillKey, + formatProfessionSkillLabel, + getAgentSkillDefaults, + getTypedGroupDisplayName, + parseProfessionSkillKey, +} from "./keys.js"; + +/** + * @typedef {object} SkillCatalogEntry + * @property {string} id - Select value (`fixed:accounting` or `typed:Craft`) + * @property {ProfessionSkillRef} ref + * @property {string} label + * @property {string} sortLabel + */ + +/** + * @returns {SkillCatalogEntry[]} + */ +export function buildSkillCatalog() { + /** @type {SkillCatalogEntry[]} */ + const entries = []; + + for (const key of DG.skills) { + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + const label = formatProfessionSkillLabel(ref); + entries.push({ + id: `fixed:${key}`, + ref, + label, + sortLabel: label, + }); + } + + for (const group of TYPED_SKILL_TEMPLATE_GROUPS) { + const ref = /** @type {TypedSkillRef} */ ({ + kind: "typed", + group, + label: "", + }); + const label = getTypedGroupDisplayName(group); + entries.push({ + id: `typed:${group}`, + ref, + label, + sortLabel: label, + }); + } + + entries.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + return entries; +} + +/** + * Skill catalog for character-creation bonus picks (excludes Unnatural). + * + * @returns {SkillCatalogEntry[]} + */ +export function buildBonusSkillCatalog() { + return buildSkillCatalog().filter( + (entry) => + entry.ref.kind !== "fixed" || + !BONUS_SKILL_CATALOG_EXCLUDED_FIXED.has(entry.ref.key), + ); +} + +/** + * @param {string} catalogId + * @param {string} [typedLabel] + * @param {{ allowEmptyTypedLabel?: boolean }} [options] + * @returns {ProfessionSkillRef | null} + */ +export function catalogIdToSkillRef( + catalogId, + typedLabel = "", + { allowEmptyTypedLabel = false } = {}, +) { + if (!catalogId) return null; + if (catalogId.startsWith("fixed:")) { + const key = catalogId.slice(6); + if (!DG.skills.includes(key)) return null; + return { kind: "fixed", key }; + } + if (catalogId.startsWith("typed:")) { + const group = catalogId.slice(6); + if (!TYPED_SKILL_TEMPLATE_GROUPS.includes(group)) return null; + const label = typedLabel.trim(); + if (!label && !allowEmptyTypedLabel) return null; + return { kind: "typed", group, label }; + } + return null; +} + +/** + * @param {ProfessionSkillRef} ref + * @returns {string} + */ +function getBonusTrackKey(ref) { + if (ref.kind === "fixed") return `fixed:${ref.key}`; + return formatProfessionSkillKey(ref); +} + +/** + * @param {string} trackKey + * @returns {string} + */ +export function getBonusTrackLabel(trackKey) { + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + return formatProfessionSkillLabel( + /** @type {FixedSkillRef} */ ({ kind: "fixed", key }), + ); + } + const ref = parseProfessionSkillKey(trackKey); + if (ref) return formatProfessionSkillLabel(ref); + return trackKey; +} + +/** + * @param {string} trackKey + * @param {Record} baseFixed + * @param {Record} baseTyped + * @param {Record} defaults + * @returns {number} + */ +function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) { + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + return baseFixed[key] ?? defaults[key] ?? 0; + } + return baseTyped[trackKey]?.value ?? 0; +} + +/** + * Bonus skill slots that would waste 20+ points or raise a skill already at the creation cap. + * + * @param {Record} baseFixed + * @param {Record} baseTyped + * @param {string[]} bonusCatalogIds + * @param {string[]} bonusTypedLabels + * @returns {string[]} + */ +export function collectBonusCapValidationErrors( + baseFixed, + baseTyped, + bonusCatalogIds, + bonusTypedLabels, +) { + const defaults = getAgentSkillDefaults(); + /** @type {Record} */ + const counts = {}; + /** @type {(string | null)[]} */ + const slotTrackKeys = []; + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const catalogId = bonusCatalogIds?.[i]; + if (!catalogId) { + slotTrackKeys.push(null); + continue; + } + const ref = catalogIdToSkillRef(catalogId, bonusTypedLabels?.[i] ?? ""); + if (!ref) { + slotTrackKeys.push(null); + continue; + } + const trackKey = getBonusTrackKey(ref); + slotTrackKeys.push(trackKey); + counts[trackKey] = (counts[trackKey] ?? 0) + 1; + } + + /** @type {Set} */ + const violatingTrackKeys = new Set(); + + for (const [trackKey, count] of Object.entries(counts)) { + const base = getBonusTrackBaseValue( + trackKey, + baseFixed, + baseTyped, + defaults, + ); + const final = base + count * BONUS_SKILL_INCREMENT; + const waste = Math.max(0, final - SKILL_CAP); + if (base >= SKILL_CAP || waste >= MAX_ALLOWED_BONUS_WASTE) { + violatingTrackKeys.add(trackKey); + } + } + + /** @type {string[]} */ + const errors = []; + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const trackKey = slotTrackKeys[i]; + if (!trackKey || !violatingTrackKeys.has(trackKey)) continue; + + const base = getBonusTrackBaseValue( + trackKey, + baseFixed, + baseTyped, + defaults, + ); + const count = counts[trackKey] ?? 0; + const final = base + count * BONUS_SKILL_INCREMENT; + const waste = Math.max(0, final - SKILL_CAP); + + if (base >= SKILL_CAP) { + errors.push(`bonusAtCap:${i}|${trackKey}`); + } else { + errors.push(`bonusWaste:${i}|${waste}|${trackKey}`); + } + } + + return errors; +} diff --git a/module/profession/compute.js b/module/profession/compute.js new file mode 100644 index 0000000..09ad678 --- /dev/null +++ b/module/profession/compute.js @@ -0,0 +1,249 @@ +import { + BONUS_SKILL_COUNT, + BONUS_SKILL_INCREMENT, + SKILL_CAP, +} from "./constants.js"; +import { + catalogIdToSkillRef, + collectBonusCapValidationErrors, +} from "./catalog.js"; +import { + formatProfessionSkillKey, + formatProfessionSkillLabel, + getAgentSkillDefaults, + parseProfessionSkillKey, +} from "./keys.js"; +import { validateProfessionFormState } from "./validation.js"; + +/** + * @param {Record} automaticSkills + * @param {Record} optionSkills + * @param {number} optionPicks + * @param {object} formState + * @param {Set} formState.checkedOptionKeys + * @param {Record} formState.chooseOneLabels - keyed by profession map key + * @param {string[]} formState.bonusCatalogIds - length 8 + * @param {string[]} formState.bonusTypedLabels - length 8 + * @param {string[]} [formState.bondNames] + * @param {string[]} [formState.bondRelationships] + * @param {object} [context] + * @param {Record} [context.automaticMeta] + * @param {Record} [context.optionMeta] + * @param {number} [context.bondCount] + * @returns {{ + * fixedValues: Record, + * typedValues: Record, + * capWarnings: { label: string, attempted: number, waste: number }[], + * isValid: boolean, + * validationErrors: string[], + * modifiedFixedKeys: string[], + * modifiedTypedKeys: string[], + * }} + */ +export function computeSkillValues( + automaticSkills, + optionSkills, + optionPicks, + formState, + { automaticMeta = {}, optionMeta = {}, bondCount = 0 } = {}, +) { + const defaults = getAgentSkillDefaults(); + /** @type {Record} */ + const fixedValues = { ...defaults }; + /** @type {Record} */ + const typedValues = {}; + /** @type {Set} */ + const modifiedFixedKeys = new Set(); + /** @type {Set} */ + const modifiedTypedKeys = new Set(); + + /** + * @param {TypedSkillRef} ref + * @param {string} label + */ + const markTypedModified = (ref, label) => { + modifiedTypedKeys.add( + formatProfessionSkillKey({ kind: "typed", group: ref.group, label }), + ); + }; + + /** + * @param {string} mapKey + * @param {number} rating + * @param {{ labelOverride?: string }} [options] + * @returns {string | null} Merged typed storage key, if applied. + */ + const applyOverride = (mapKey, rating, options = {}) => { + const ref = parseProfessionSkillKey(mapKey); + if (!ref) return null; + const value = Number(rating); + if (ref.kind === "fixed") { + fixedValues[ref.key] = value; + return null; + } + + const label = options.labelOverride?.trim() ?? ref.label?.trim() ?? ""; + if (!label) return null; + + const storageKey = formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label, + }); + typedValues[storageKey] = { + group: ref.group, + label, + value, + }; + return storageKey; + }; + + for (const [key, rating] of Object.entries(automaticSkills ?? {})) { + const ref = parseProfessionSkillKey(key); + if (!ref) continue; + + let labelOverride; + if (ref.kind === "typed" && automaticMeta[key]?.chooseOne) { + labelOverride = formState.chooseOneLabels?.[key]?.trim(); + if (!labelOverride) continue; + } + + const storageKey = applyOverride(key, rating, { labelOverride }); + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else if (storageKey) { + markTypedModified(ref, labelOverride ?? ref.label); + } + } + + for (const key of formState.checkedOptionKeys ?? []) { + if (!optionSkills[key] && optionSkills[key] !== 0) continue; + const ref = parseProfessionSkillKey(key); + if (!ref) continue; + + let labelOverride; + if (ref.kind === "typed" && optionMeta[key]?.chooseOne) { + labelOverride = formState.chooseOneLabels?.[key]?.trim(); + if (!labelOverride) continue; + } else if (ref.kind === "typed") { + labelOverride = ref.label?.trim(); + if (!labelOverride) continue; + } + + const storageKey = applyOverride(key, optionSkills[key], { labelOverride }); + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else if (storageKey) { + markTypedModified(ref, labelOverride ?? ref.label); + } + } + + /** @type {Record} */ + const bonusCounts = {}; + + const bonusIds = formState.bonusCatalogIds ?? []; + const bonusLabels = formState.bonusTypedLabels ?? []; + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const catalogId = bonusIds[i]; + if (!catalogId) continue; + const typedLabel = bonusLabels[i] ?? ""; + const ref = catalogIdToSkillRef(catalogId, typedLabel); + if (!ref) continue; + + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else modifiedTypedKeys.add(formatProfessionSkillKey(ref)); + + let trackKey; + if (ref.kind === "fixed") { + trackKey = `fixed:${ref.key}`; + } else { + trackKey = formatProfessionSkillKey(ref); + } + bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1; + } + + /** Pre-bonus base for cap warnings */ + const baseFixed = { ...fixedValues }; + const baseTyped = foundry.utils.deepClone(typedValues); + + for (const [trackKey, count] of Object.entries(bonusCounts)) { + const bonus = count * BONUS_SKILL_INCREMENT; + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus; + } else { + const ref = parseProfessionSkillKey(trackKey); + if (ref?.kind !== "typed") continue; + + const base = typedValues[trackKey]?.value ?? 0; + typedValues[trackKey] = { + group: ref.group, + label: ref.label, + value: base + bonus, + }; + } + } + + /** @type {{ label: string, attempted: number, waste: number }[]} */ + const capWarnings = []; + + const checkCap = (label, base, final) => { + if (final > SKILL_CAP) { + capWarnings.push({ + label, + attempted: final, + waste: final - SKILL_CAP, + }); + } + }; + + for (const [key, final] of Object.entries(fixedValues)) { + const base = baseFixed[key] ?? defaults[key] ?? 0; + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + checkCap(formatProfessionSkillLabel(ref), base, final); + } + + for (const [storageKey, data] of Object.entries(typedValues)) { + const base = baseTyped[storageKey]?.value ?? 0; + const ref = /** @type {TypedSkillRef} */ ({ + kind: "typed", + group: data.group, + label: data.label, + }); + checkCap(formatProfessionSkillLabel(ref), base, data.value); + } + + /** Apply cap for display (values shown capped at 80) */ + for (const key of Object.keys(fixedValues)) { + if (fixedValues[key] > SKILL_CAP) fixedValues[key] = SKILL_CAP; + } + for (const data of Object.values(typedValues)) { + if (data.value > SKILL_CAP) data.value = SKILL_CAP; + } + + const validationErrors = validateProfessionFormState(optionPicks, formState, { + automaticSkills, + optionSkills, + automaticMeta, + optionMeta, + bondCount, + }); + + validationErrors.push( + ...collectBonusCapValidationErrors( + baseFixed, + baseTyped, + bonusIds, + bonusLabels, + ), + ); + + return { + fixedValues, + typedValues, + capWarnings, + isValid: validationErrors.length === 0, + validationErrors, + modifiedFixedKeys: [...modifiedFixedKeys], + modifiedTypedKeys: [...modifiedTypedKeys], + }; +} diff --git a/module/profession/constants.js b/module/profession/constants.js new file mode 100644 index 0000000..9825567 --- /dev/null +++ b/module/profession/constants.js @@ -0,0 +1,42 @@ +/** @typedef {"fixed" | "typed"} ProfessionSkillKind */ +/** @typedef {{ kind: "fixed", key: string }} FixedSkillRef */ +/** @typedef {{ kind: "typed", group: string, label: string }} TypedSkillRef */ +/** @typedef {FixedSkillRef | TypedSkillRef} ProfessionSkillRef */ +/** @typedef {{ chooseOne?: boolean }} ProfessionSkillMeta */ + +/** Typed skill template groups (camelCase storage keys). */ +export const TYPED_SKILL_TEMPLATE_GROUPS = /** @type {const} */ ([ + "Art", + "Craft", + "ForeignLanguage", + "MilitaryScience", + "Pilot", + "Science", + "Other", +]); + +export const TYPED_GROUP_I18N = { + Art: "DG.TypeSkills.Art", + Craft: "DG.TypeSkills.Craft", + ForeignLanguage: "DG.TypeSkills.ForeignLanguage", + MilitaryScience: "DG.TypeSkills.MilitaryScience", + Pilot: "DG.TypeSkills.Pilot", + Science: "DG.TypeSkills.Science", + Other: "DG.TypeSkills.Other", +}; + +export const BONUS_SKILL_COUNT = 8; +export const BONUS_SKILL_INCREMENT = 20; + +/** @type {ReadonlySet} */ +export const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]); +export const SKILL_CAP = 80; +/** Bonus picks that would waste this many points or more are rejected (e.g. 80 + 20% at cap). */ +export const MAX_ALLOWED_BONUS_WASTE = 20; + +export const TYPED_KEY_PATTERN = + /^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i; + +/** Legacy internal keys from earlier builds (`typed:ForeignLanguage:abc12345`). */ +export const INTERNAL_TYPED_KEY_PATTERN = + /^typed:([A-Za-z]+):([A-Za-z0-9]{8,16})(?::(.+))?$/; diff --git a/module/profession/display.js b/module/profession/display.js new file mode 100644 index 0000000..e67b5cd --- /dev/null +++ b/module/profession/display.js @@ -0,0 +1,70 @@ +import { + formatProfessionSkillKeyLabel, + formatProfessionSkillLabel, +} from "./keys.js"; + +/** + * Sorted fixed skill rows for display. + * @param {Record} fixedValues + * @returns {{ key: string, label: string, value: number }[]} + */ +export function buildSortedFixedSkillRows(fixedValues) { + const rows = Object.entries(fixedValues).map(([key, value]) => { + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + return { + key, + label: formatProfessionSkillLabel(ref), + value, + sortLabel: formatProfessionSkillLabel(ref), + }; + }); + rows.sort((a, b) => a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang)); + return rows; +} + +/** + * Sorted typed skill rows for display. + * @param {Record} typedValues + * @returns {{ storageKey: string, label: string, value: number, sortLabel: string }[]} + */ +export function buildSortedTypedSkillRows(typedValues) { + const rows = Object.entries(typedValues).map(([storageKey, data]) => { + const ref = { + kind: "typed", + group: data.group, + label: data.label, + }; + const label = formatProfessionSkillLabel(ref); + return { + storageKey, + label, + value: data.value, + sortLabel: label, + }; + }); + rows.sort((a, b) => a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang)); + return rows; +} + +/** + * Prepare sorted skill rows from profession item maps. + * @param {Record} skillMap + * @param {Record} [skillMeta] + * @returns {{ key: string, label: string, rating: number }[]} + */ +export function prepareProfessionSkillRows(skillMap, skillMeta = {}) { + const rows = Object.entries(skillMap ?? {}).map(([key, rating]) => { + const label = formatProfessionSkillKeyLabel(key, { + automaticMeta: skillMeta, + optionMeta: skillMeta, + }); + return { + key, + label, + rating: Number(rating), + sortLabel: label, + }; + }); + rows.sort((a, b) => a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang)); + return rows; +} diff --git a/module/profession/index.js b/module/profession/index.js new file mode 100644 index 0000000..c71f103 --- /dev/null +++ b/module/profession/index.js @@ -0,0 +1,33 @@ +export { TYPED_SKILL_TEMPLATE_GROUPS, BONUS_SKILL_COUNT } from "./constants.js"; +export { + getChooseOnePlaceholderLabel, + normalizeTypedSkillName, + isChooseOnePlaceholderLabel, + isChooseOneProfessionSkillKey, + findTypedSkillNameConflict, + getAgentSkillDefaults, + normalizeTypedGroup, + getTypedGroupDisplayName, + formatProfessionSkillKey, + allocateProfessionSkillStorageKey, + parseProfessionSkillKey, + formatProfessionSkillLabel, + formatProfessionSkillKeyLabel, + splitProfessionSkillMap, +} from "./keys.js"; +export { + buildSkillCatalog, + buildBonusSkillCatalog, + catalogIdToSkillRef, + collectBonusCapValidationErrors, +} from "./catalog.js"; +export { computeSkillValues } from "./compute.js"; +export { + validateProfessionFormState, + formatProfessionValidationMessages, +} from "./validation.js"; +export { + buildSortedFixedSkillRows, + buildSortedTypedSkillRows, + prepareProfessionSkillRows, +} from "./display.js"; diff --git a/module/profession/keys.js b/module/profession/keys.js new file mode 100644 index 0000000..aea75ca --- /dev/null +++ b/module/profession/keys.js @@ -0,0 +1,269 @@ +import DG from "../config/index.js"; +import { AGENT_SKILL_DEFAULTS } from "../data/actor/base/agent-skill-defaults.js"; +import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js"; +import { + INTERNAL_TYPED_KEY_PATTERN, + TYPED_KEY_PATTERN, + TYPED_GROUP_I18N, +} from "./constants.js"; +/** + * @returns {string} + */ +export function getChooseOnePlaceholderLabel() { + return game.i18n.localize("DG.Profession.ChooseOnePlaceholder"); +} + +/** + * @param {string} label + * @returns {string} + */ +export function normalizeTypedSkillName(label) { + return label.trim().toLocaleLowerCase(game.i18n.lang); +} + +/** + * @param {string} label + * @returns {boolean} + */ +export function isChooseOnePlaceholderLabel(label) { + return ( + normalizeTypedSkillName(label) === + normalizeTypedSkillName(getChooseOnePlaceholderLabel()) + ); +} + +/** + * @param {string} mapKey + * @param {Record} automaticMeta + * @param {Record} optionMeta + * @returns {boolean} + */ +export function isChooseOneProfessionSkillKey( + mapKey, + automaticMeta = {}, + optionMeta = {}, +) { + return Boolean( + automaticMeta[mapKey]?.chooseOne ?? optionMeta[mapKey]?.chooseOne, + ); +} + +/** + * @param {object} params + * @param {string} params.group + * @param {string} params.label + * @param {boolean} params.chooseOne + * @param {Record} params.automaticSkills + * @param {Record} params.optionSkills + * @param {Record} [params.automaticMeta] + * @param {Record} [params.optionMeta] + * @param {string} [params.excludeKey] + * @returns {string | null} Conflicting map key, if any. + */ +export function findTypedSkillNameConflict({ + group, + label, + chooseOne, + automaticSkills, + optionSkills, + automaticMeta = {}, + optionMeta = {}, + excludeKey = null, +}) { + // Multiple choose-one slots per category are allowed on the profession item. + if (chooseOne) return null; + if (!group) return null; + + const normalizedGroup = normalizeTypedGroup(group); + const normalizedNew = normalizeTypedSkillName(label); + if (!normalizedNew) return "__empty__"; + + const maps = [ + [automaticSkills, automaticMeta], + [optionSkills, optionMeta], + ]; + + for (const [skillMap, metaMap] of maps) { + for (const mapKey of Object.keys(skillMap ?? {})) { + if (mapKey === excludeKey || mapKey === PROFESSION_OPTION_PICKS_KEY) + continue; + + const ref = parseProfessionSkillKey(mapKey); + if (!ref || ref.kind !== "typed") continue; + if (normalizeTypedGroup(ref.group) !== normalizedGroup) continue; + + // Unresolved choose-one slots have no fixed name yet. + if (metaMap[mapKey]?.chooseOne) continue; + + if (normalizeTypedSkillName(ref.label) === normalizedNew) return mapKey; + } + } + + return null; +} + +/** + * @returns {Record} + */ +export function getAgentSkillDefaults() { + return { ...AGENT_SKILL_DEFAULTS }; +} + +/** + * @param {string} group + * @returns {string} + */ +export function normalizeTypedGroup(group) { + if (group == null) return ""; + const trimmed = String(group).trim(); + const map = { + "Foreign Language": "ForeignLanguage", + "Military Science": "MilitaryScience", + }; + return map[trimmed] ?? trimmed.replace(/\s+/g, ""); +} + +/** + * @param {string} group + * @returns {string} + */ +export function getTypedGroupDisplayName(group) { + const key = normalizeTypedGroup(group); + const i18nKey = TYPED_GROUP_I18N[key]; + if (i18nKey) return game.i18n.localize(i18nKey); + return group; +} + +/** + * @param {ProfessionSkillRef} ref + * @returns {string} + */ +export function formatProfessionSkillKey(ref) { + if (ref.kind === "fixed") return ref.key; + const groupLabel = getTypedGroupDisplayName(ref.group); + return `${groupLabel} (${ref.label.trim()})`; +} + +/** + * Storage key for a profession skill map entry. + * + * @param {ProfessionSkillRef} ref + * @param {{ chooseOne?: boolean }} [options] + * @returns {string} + */ +export function allocateProfessionSkillStorageKey( + ref, + { chooseOne = false } = {}, +) { + if (ref.kind === "fixed") return ref.key; + if (chooseOne) { + return `typed:${normalizeTypedGroup(ref.group)}:${foundry.utils.randomID( + 8, + )}`; + } + const label = ref.label?.trim() ?? ""; + return formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label, + }); +} + +/** + * @param {string} key + * @returns {ProfessionSkillRef | null} + */ +export function parseProfessionSkillKey(key) { + if (!key || key === PROFESSION_OPTION_PICKS_KEY) return null; + if (DG.skills.includes(key)) return { kind: "fixed", key }; + + const internalMatch = key.match(INTERNAL_TYPED_KEY_PATTERN); + if (internalMatch) { + let label = ""; + if (internalMatch[3] !== undefined) { + try { + label = decodeURIComponent(internalMatch[3]); + } catch { + label = internalMatch[3]; + } + } + return { + kind: "typed", + group: internalMatch[1], + label, + }; + } + + const match = key.match(TYPED_KEY_PATTERN); + if (match) { + return { + kind: "typed", + group: normalizeTypedGroup(match[1]), + label: match[2].trim(), + }; + } + return null; +} + +/** + * @param {ProfessionSkillRef} ref + * @returns {string} + */ +export function formatProfessionSkillLabel(ref) { + if (ref.kind === "fixed") { + if (game.i18n.lang === "ja") { + const ja = game.i18n.localize(`DG.Skills.ruby.${ref.key}`); + if (ja && ja !== `DG.Skills.ruby.${ref.key}`) return ja; + } + const label = game.i18n.localize(`DG.Skills.${ref.key}`); + if (label && label !== `DG.Skills.${ref.key}`) return label; + return ref.key; + } + if (!ref.label?.trim()) return getTypedGroupDisplayName(ref.group); + return formatProfessionSkillKey(ref); +} + +/** + * @param {string} key + * @param {object} [metaContext] + * @param {Record} [metaContext.automaticMeta] + * @param {Record} [metaContext.optionMeta] + * @returns {string} + */ +export function formatProfessionSkillKeyLabel( + key, + { automaticMeta = {}, optionMeta = {} } = {}, +) { + const ref = parseProfessionSkillKey(key); + if (!ref) return key; + if ( + ref.kind === "typed" && + isChooseOneProfessionSkillKey(key, automaticMeta, optionMeta) + ) { + return formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label: getChooseOnePlaceholderLabel(), + }); + } + if (ref.kind === "typed" && !ref.label?.trim()) { + return getTypedGroupDisplayName(ref.group); + } + return formatProfessionSkillLabel(ref); +} + +/** + * @param {Record} obj + * @returns {{ optionPicks: number, skills: Record }} + */ +export function splitProfessionSkillMap(obj) { + const source = obj ?? {}; + const optionPicks = Number(source[PROFESSION_OPTION_PICKS_KEY]) || 0; + /** @type {Record} */ + const skills = {}; + for (const [key, value] of Object.entries(source)) { + if (key === PROFESSION_OPTION_PICKS_KEY) continue; + skills[key] = Number(value); + } + return { optionPicks, skills }; +} diff --git a/module/utils/profession-stat-setup.js b/module/profession/stat-setup.js similarity index 88% rename from module/utils/profession-stat-setup.js rename to module/profession/stat-setup.js index 8dff4e5..75b5868 100644 --- a/module/utils/profession-stat-setup.js +++ b/module/profession/stat-setup.js @@ -1,5 +1,5 @@ -import DG from "../config.js"; -import { showDiceSoNicePooledRolls } from "./dice-so-nice.js"; +import DG from "../config/index.js"; +import { showDiceSoNicePooledRolls } from "../integrations/dice-so-nice.js"; export const STAT_KEYS = DG.statistics; export const POINT_BUY_TOTAL = 72; @@ -46,11 +46,7 @@ export function validatePointBuyValues(values) { let sum = 0; for (const key of STAT_KEYS) { const value = Number(values[key]); - if ( - !Number.isInteger(value) || - value < STAT_MIN || - value > STAT_MAX - ) { + if (!Number.isInteger(value) || value < STAT_MIN || value > STAT_MAX) { return { isValid: false, remaining: POINT_BUY_TOTAL - sum }; } sum += value; @@ -95,13 +91,17 @@ export async function rollStatisticScores({ export function buildRollStatsChatContent(rolls) { const cells = rolls.map( (entry, index) => - `${foundry.utils.escapeHTML(String(entry.total))}`, + `${foundry.utils.escapeHTML(String(entry.total))}`, ); const rows = []; for (let i = 0; i < cells.length; i += 3) { rows.push(`${cells.slice(i, i + 3).join("")}`); } - return `${rows.join("")}
`; + return `${rows.join( + "", + )}
`; } /** diff --git a/module/profession/validation.js b/module/profession/validation.js new file mode 100644 index 0000000..57dadca --- /dev/null +++ b/module/profession/validation.js @@ -0,0 +1,238 @@ +import { BONUS_SKILL_COUNT, MAX_ALLOWED_BONUS_WASTE } from "./constants.js"; +import { catalogIdToSkillRef, getBonusTrackLabel } from "./catalog.js"; +import { + getTypedGroupDisplayName, + normalizeTypedGroup, + normalizeTypedSkillName, + parseProfessionSkillKey, +} from "./keys.js"; + +/** + * @param {number} optionPicks + * @param {object} formState + * @param {object} [context] + * @param {Record} [context.automaticSkills] + * @param {Record} [context.optionSkills] + * @param {Record} [context.automaticMeta] + * @param {Record} [context.optionMeta] + * @param {number} [context.bondCount] + * @returns {string[]} + */ +export function validateProfessionFormState( + optionPicks, + formState, + context = {}, +) { + const errors = []; + const checked = formState.checkedOptionKeys ?? new Set(); + const picks = Number(optionPicks) || 0; + const { + automaticSkills = {}, + optionSkills = {}, + automaticMeta = {}, + optionMeta = {}, + bondCount = 0, + } = context; + + if (checked.size !== picks) { + errors.push("optionPicks"); + } + + for (let i = 0; i < bondCount; i++) { + if (!formState.bondNames?.[i]?.trim()) errors.push(`bondName${i}`); + if (!formState.bondRelationships?.[i]?.trim()) { + errors.push(`bondRelationship${i}`); + } + } + + for (let i = 0; i < BONUS_SKILL_COUNT; i++) { + const catalogId = formState.bonusCatalogIds?.[i]; + if (!catalogId) { + errors.push(`bonus${i}`); + continue; + } + const ref = catalogIdToSkillRef( + catalogId, + formState.bonusTypedLabels?.[i] ?? "", + ); + if (!ref) { + errors.push(`bonus${i}`); + continue; + } + if (ref.kind === "typed" && !formState.bonusTypedLabels?.[i]?.trim()) { + errors.push(`bonusType${i}`); + } + } + + /** @type {Map>} */ + const resolvedByGroup = new Map(); + + const registerResolvedName = (ref, rawLabel) => { + const label = rawLabel?.trim(); + if (!label) { + errors.push(`typedNameRequired:${ref.group}`); + return; + } + const group = normalizeTypedGroup(ref.group); + const normalized = normalizeTypedSkillName(label); + if (!resolvedByGroup.has(group)) resolvedByGroup.set(group, new Set()); + const names = resolvedByGroup.get(group); + if (names.has(normalized)) { + errors.push(`typedNameConflict:${group}:${label}`); + return; + } + names.add(normalized); + }; + + for (const [key] of Object.entries(automaticSkills)) { + const ref = parseProfessionSkillKey(key); + if (!ref || ref.kind !== "typed") continue; + if (automaticMeta[key]?.chooseOne) { + registerResolvedName(ref, formState.chooseOneLabels?.[key]); + } else { + registerResolvedName(ref, ref.label); + } + } + + for (const key of checked) { + const ref = parseProfessionSkillKey(key); + if (!ref || ref.kind !== "typed") continue; + if (optionMeta[key]?.chooseOne) { + registerResolvedName(ref, formState.chooseOneLabels?.[key]); + } else { + registerResolvedName(ref, ref.label); + } + } + + return errors; +} + +/** + * User-facing messages for Add Profession validation error codes. + * @param {string[]} errors + * @param {object} [context] + * @param {number} [context.optionPicks] + * @returns {string[]} + */ +export function formatProfessionValidationMessages( + errors, + { optionPicks = 0 } = {}, +) { + const messages = []; + const seen = new Set(); + + const push = (msg) => { + if (seen.has(msg)) return; + seen.add(msg); + messages.push(msg); + }; + + let needsOptionPicks = false; + let needsBondName = false; + let needsBondRelationship = false; + let needsBonusSkill = false; + let needsBonusSkillType = false; + /** @type {Set} */ + const typedNameRequiredGroups = new Set(); + /** @type {string[]} */ + const bonusAtCapTrackKeys = []; + + for (const code of errors) { + if (code === "optionPicks") { + needsOptionPicks = true; + continue; + } + if (code.startsWith("bondName")) { + needsBondName = true; + continue; + } + if (code.startsWith("bondRelationship")) { + needsBondRelationship = true; + continue; + } + const bonusAtCap = code.match(/^bonusAtCap:\d+\|(.+)$/); + if (bonusAtCap) { + bonusAtCapTrackKeys.push(bonusAtCap[1]); + continue; + } + + const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/); + if (bonusWaste) { + push( + game.i18n.format("DG.Profession.Dialog.BonusSkillWasteTooHigh", { + skill: getBonusTrackLabel(bonusWaste[2]), + waste: Number(bonusWaste[1]), + maxWaste: MAX_ALLOWED_BONUS_WASTE - 1, + }), + ); + continue; + } + + if (code.startsWith("bonusType")) { + needsBonusSkillType = true; + continue; + } + if (/^bonus\d+$/.test(code)) { + needsBonusSkill = true; + continue; + } + + const typedNameRequired = code.match(/^typedNameRequired:(.+)$/); + if (typedNameRequired) { + typedNameRequiredGroups.add(typedNameRequired[1]); + continue; + } + + const conflict = code.match(/^typedNameConflict:([^:]+):(.+)$/); + if (conflict) { + const [, group, name] = conflict; + push( + game.i18n.format("DG.Profession.Dialog.DuplicateTypedName", { + type: getTypedGroupDisplayName(group), + name, + }), + ); + } + } + + if (needsOptionPicks) { + push( + game.i18n.format("DG.Profession.Dialog.OptionPicksRequired", { + picks: optionPicks, + }), + ); + } + if (needsBonusSkill) { + push( + game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", { + count: BONUS_SKILL_COUNT, + }), + ); + } + if (needsBonusSkillType) { + push(game.i18n.localize("DG.Profession.Dialog.BonusSkillTypeRequired")); + } + for (const group of typedNameRequiredGroups) { + push( + game.i18n.format("DG.Profession.Dialog.TypedNameRequired", { + type: getTypedGroupDisplayName(group), + }), + ); + } + if (needsBondName) { + push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired")); + } + if (needsBondRelationship) { + push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired")); + } + + for (const trackKey of bonusAtCapTrackKeys) { + push( + game.i18n.format("DG.Profession.Dialog.BonusSkillAlreadyAtCap", { + skill: getBonusTrackLabel(trackKey), + }), + ); + } + + return messages; +} diff --git a/module/roll/classes/dg-damage-roll.js b/module/roll/classes/dg-damage-roll.js new file mode 100644 index 0000000..d271e1c --- /dev/null +++ b/module/roll/classes/dg-damage-roll.js @@ -0,0 +1,47 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import { showDamageRollModifyDialog } from "../roll-dialogs.js"; +import { DGRoll } from "./dg-roll.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +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.Damage") + .toUpperCase()} ${game.i18n.localize("DG.Roll.For")} ${ + this.item.name + } (${ + this.item.system.armorPiercing + } armor penetration)`; + } catch (ex) { + // console.log(ex); + label = `DAMAGE for ${label.toUpperCase()}`; + } + const html = await renderTemplate( + "systems/deltagreen/templates/roll/damage-roll.hbs", + { + formula: this.formula, + total: this.total, + }, + ); + + return this.toMessage({ content: html, label }); + } + + async showDialog() { + return showDamageRollModifyDialog({ + itemName: this.item?.name, + formula: this.formula, + }); + } +} diff --git a/module/roll/classes/dg-lethality-roll.js b/module/roll/classes/dg-lethality-roll.js new file mode 100644 index 0000000..2739e75 --- /dev/null +++ b/module/roll/classes/dg-lethality-roll.js @@ -0,0 +1,116 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import DGUtils from "../../utils/utility-functions.js"; +import { DGPercentileRoll } from "./dg-percentile-roll.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +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.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, 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 }; + } +} diff --git a/module/roll/roll-classes.js b/module/roll/classes/dg-percentile-roll.js similarity index 54% rename from module/roll/roll-classes.js rename to module/roll/classes/dg-percentile-roll.js index 3ecf69f..57106a6 100644 --- a/module/roll/roll-classes.js +++ b/module/roll/classes/dg-percentile-roll.js @@ -1,102 +1,20 @@ -/* eslint-disable max-classes-per-file */ -import { - createDGRollChatMessage, - prepareDGRollChatMessageData, -} from "../chat/dg-chat-card.js"; -import DGUtils from "../utils/utility-functions.js"; -import DG from "../config.js"; -import { - showDamageRollModifyDialog, - showPercentileRollModifyDialog, -} from "./roll-dialogs.js"; +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import DGUtils from "../../utils/utility-functions.js"; +import DG from "../../config/index.js"; +import { showPercentileRollModifyDialog } from "../roll-dialogs.js"; import { isDiceSoNiceAvailable, waitForDiceSoNiceMessageAnimation, -} from "../utils/dice-so-nice.js"; +} from "../../integrations/dice-so-nice.js"; import { clampPercentileRollTarget, getRollTargetDisplayClassFromModifier, -} from "../utils/active-effect-derived.js"; +} from "../../active-effect/runtime/derived.js"; +import { DGRoll } from "./dg-roll.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; - } - - /** - * Posts a roll to chat with the Delta Green card shell and an explicit `rolls` - * array for Dice So Nice (listen-path compatible). - * - * @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 } = {}) { - const label = messageData.label ?? messageData.flavor; - delete messageData.label; - - const mode = - messageMode ?? - this.options.messageMode ?? - this.options.rollMode; - - if (create) { - return createDGRollChatMessage({ - roll: this, - actor: this.actor, - token: this.options.token, - label, - content: messageData.content, - messageMode: mode, - flags: messageData.flags ?? {}, - }); - } - - const { messageData: prepared, mappedMode } = - await prepareDGRollChatMessageData({ - roll: this, - actor: this.actor, - token: this.options.token, - label, - content: messageData.content, - messageMode: mode, - flags: messageData.flags ?? {}, - }); - - const cls = foundry.utils.getDocumentClass("ChatMessage"); - const msg = new cls(prepared); - msg.applyMode(mappedMode); - return msg.toObject(); - } -} - export class DGPercentileRoll extends DGRoll { /** * Creates D100 rolls, the base die of the system. @@ -123,10 +41,7 @@ export class DGPercentileRoll extends DGRoll { 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, - ); + const { target, localizedKey, skillPath } = this.getRollInfoFromKey(); this.target = target; this.localizedKey = localizedKey; this.skillPath = skillPath; @@ -191,7 +106,7 @@ export class DGPercentileRoll extends DGRoll { } } - const rollTargetModifier = this.rollTargetModifier; + const { rollTargetModifier } = this; const targetDisplayClass = getRollTargetDisplayClassFromModifier(rollTargetModifier); @@ -300,13 +215,11 @@ export class DGPercentileRoll extends DGRoll { } /** - * Utility function, called in the DGPercentileRoll constructor. - * If this roll key corresponds to a stat, skill, - * or typedSkill, get pertinent info. + * Resolve roll target, localized label, and skill path from `this.key` and `this.actor`. * - * This is used for Stat, Skill, Typed Skill, Weapon, and Special Training Rolls. + * Used for Stat, Skill, Typed Skill, Weapon, and Special Training Rolls. * - * @returns {Object} - Contains the roll target and localized version of the key. + * @returns {{ target: number|null, localizedKey: string|null, skillPath: string|null }} */ getRollInfoFromKey() { const actorData = this.actor.system; @@ -351,7 +264,7 @@ export class DGPercentileRoll extends DGRoll { */ createLabel() { const startOfLabel = `${this.localizedKey}`; - const rollTargetModifier = this.rollTargetModifier; + const { rollTargetModifier } = this; const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${ this.effectiveTarget }`; @@ -388,7 +301,7 @@ export class DGPercentileRoll extends DGRoll { if (this.type === "luck") return 0; try { - const rollTarget = this.actor.system.rollTarget; + const { rollTarget } = this.actor.system; if (!rollTarget) return 0; if (this.type === "sanity") return Number(rollTarget.sanity) || 0; @@ -399,21 +312,6 @@ export class DGPercentileRoll extends DGRoll { } } - /** - * Target after roll-target Active Effects, before dialog modifier. - * @returns {number|null} - */ - get rollTargetEffectiveTarget() { - if (!this.target || Number.isNaN(this.target)) return null; - - const rollTargetModifier = this.rollTargetModifier; - if (!rollTargetModifier) return parseInt(this.target); - - return clampPercentileRollTarget(this.target, rollTargetModifier, { - allowOver99: this.target > 99 && this.type === "stat", - }); - } - /** * "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. @@ -492,7 +390,7 @@ export class DGPercentileRoll extends DGRoll { get effectiveTarget() { let target = 1; - const rollTargetModifier = this.rollTargetModifier; + const { rollTargetModifier } = this; if (!this.target || Number.isNaN(this.target)) { return null; @@ -523,204 +421,3 @@ export class DGPercentileRoll extends DGRoll { 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.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, 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.Damage") - .toUpperCase()} ${game.i18n.localize("DG.Roll.For")} ${ - this.item.name - } (${ - this.item.system.armorPiercing - } armor penetration)`; - } catch (ex) { - // console.log(ex); - label = `DAMAGE for ${label.toUpperCase()}`; - } - const html = await renderTemplate( - "systems/deltagreen/templates/roll/damage-roll.hbs", - { - formula: this.formula, - total: this.total, - }, - ); - - return this.toMessage({ content: html, 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 label = `${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, label }); - } - - /** - * 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/classes/dg-roll.js b/module/roll/classes/dg-roll.js new file mode 100644 index 0000000..0a8fae0 --- /dev/null +++ b/module/roll/classes/dg-roll.js @@ -0,0 +1,82 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import { + createDGRollChatMessage, + prepareDGRollChatMessageData, +} from "../../chat/dg-chat-card.js"; + +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; + } + + /** + * Posts a roll to chat with the Delta Green card shell and an explicit `rolls` + * array for Dice So Nice (listen-path compatible). + * + * @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 } = {}) { + const label = messageData.label ?? messageData.flavor; + delete messageData.label; + + const mode = + messageMode ?? this.options.messageMode ?? this.options.rollMode; + + if (create) { + return createDGRollChatMessage({ + roll: this, + actor: this.actor, + token: this.options.token, + label, + content: messageData.content, + messageMode: mode, + flags: messageData.flags ?? {}, + }); + } + + const { messageData: prepared, mappedMode } = + await prepareDGRollChatMessageData({ + roll: this, + actor: this.actor, + token: this.options.token, + label, + content: messageData.content, + messageMode: mode, + flags: messageData.flags ?? {}, + }); + + const cls = foundry.utils.getDocumentClass("ChatMessage"); + // eslint-disable-next-line new-cap -- Foundry document class resolved at runtime + const msg = new cls(prepared); + msg.applyMode(mappedMode); + return msg.toObject(); + } +} diff --git a/module/roll/classes/dg-sanity-damage-roll.js b/module/roll/classes/dg-sanity-damage-roll.js new file mode 100644 index 0000000..61796b5 --- /dev/null +++ b/module/roll/classes/dg-sanity-damage-roll.js @@ -0,0 +1,56 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import DGUtils from "../../utils/utility-functions.js"; +import { DGRoll } from "./dg-roll.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +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 label = `${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, label }); + } + + /** + * 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/melee-damage.js b/module/roll/melee-damage.js new file mode 100644 index 0000000..f9c9129 --- /dev/null +++ b/module/roll/melee-damage.js @@ -0,0 +1,21 @@ +/** + * Append STR melee damage bonus to a weapon damage formula when applicable. + * + * @param {string} diceFormula + * @param {Actor|null} actor + * @param {string|null|undefined} skill Weapon skill key from item system + * @returns {string} + */ +export function appendMeleeDamageBonus(diceFormula, actor, skill) { + if ( + !actor || + (actor.type !== "agent" && actor.type !== "npc") || + (skill !== "unarmed_combat" && skill !== "melee_weapons") + ) { + return diceFormula; + } + + const bonus = actor.system?.statistics?.str?.meleeDamageBonusFormula; + if (!bonus) return diceFormula; + return `${diceFormula}${bonus}`; +} diff --git a/module/roll/roll.js b/module/roll/roll.js index 83c7a1b..55dae63 100644 --- a/module/roll/roll.js +++ b/module/roll/roll.js @@ -1,10 +1,9 @@ -import { - DGRoll, - DGPercentileRoll, - DGLethalityRoll, - DGDamageRoll, - DGSanityDamageRoll, -} from "./roll-classes.js"; +import { DGRoll } from "./classes/dg-roll.js"; +import { DGPercentileRoll } from "./classes/dg-percentile-roll.js"; +import { DGLethalityRoll } from "./classes/dg-lethality-roll.js"; +import { DGDamageRoll } from "./classes/dg-damage-roll.js"; +import { DGSanityDamageRoll } from "./classes/dg-sanity-damage-roll.js"; +import { appendMeleeDamageBonus } from "./melee-damage.js"; export { DGRoll, @@ -58,15 +57,11 @@ export function createDGRollFromDataset( roll = new DGLethalityRoll("1D100", {}, rollOptions); break; case "damage": { - let diceFormula = item.system.damage; - const { skill } = item.system; - if ( - actor && - (actor.type === "agent" || actor.type === "npc") && - (skill === "unarmed_combat" || skill === "melee_weapons") - ) { - diceFormula += actor.system.statistics.str.meleeDamageBonusFormula; - } + const diceFormula = appendMeleeDamageBonus( + item.system.damage, + actor, + item.system.skill, + ); roll = new DGDamageRoll(diceFormula, {}, rollOptions); break; } diff --git a/module/roll/skill-improvement-roll.js b/module/roll/skill-improvement-roll.js index e688369..6d07ced 100644 --- a/module/roll/skill-improvement-roll.js +++ b/module/roll/skill-improvement-roll.js @@ -1,5 +1,5 @@ import { createDGChatMessage } from "../chat/dg-chat-card.js"; -import DG from "../config.js"; +import DG from "../config/index.js"; /** * @param {"1"|"d3"|"d4"|"d4-1"} baseFormula diff --git a/module/settings.js b/module/settings.js index 598edc4..a1579bd 100644 --- a/module/settings.js +++ b/module/settings.js @@ -1,5 +1,5 @@ /* eslint-disable max-classes-per-file */ -import DG, { BASE_TEMPLATE_PATH } from "./config.js"; +import DG, { BASE_TEMPLATE_PATH } from "./config/index.js"; import DGActorSheet from "./sheets/base-actor-sheet.js"; import DGItemSheet from "./sheets/base-item-sheet.js"; diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js index 5e211b7..a46eb40 100644 --- a/module/sheets/agent-sheet.js +++ b/module/sheets/agent-sheet.js @@ -1,6 +1,8 @@ -import DG, { BASE_TEMPLATE_PATH } from "../config.js"; -import { packSkillGroupsIntoColumns } from "../utils/skill-layout.js"; -import { createAgentResourceChatMessage } from "../utils/resource-chat.js"; +import DG, { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { prepareAgentSkillColumns } from "../utils/skill-layout.js"; +import { formatProfessionSkillLabel } from "../profession/index.js"; +import { buildSkillTooltip } from "../utils/skill-tooltip.js"; +import { createAgentResourceChatMessage } from "../chat/resource-chat.js"; import { createSkillImprovementChatMessage, evaluateSkillImprovementRolls, @@ -14,10 +16,11 @@ import { clearStimulantEffects, getEffectiveSuppressExhaustion, hasActiveStimulantEffect, -} from "../utils/stimulant-effect.js"; +} from "../active-effect/runtime/stimulant-effect.js"; import { assignProfessionToAgent } from "../applications/profession-setup-flow.js"; import { showDgDialog } from "../applications/dg-dialog.js"; import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js"; + const { renderTemplate } = foundry.applications.handlebars; const AgentSheetBase = EffectsTabMixin(DGActorSheet); @@ -103,6 +106,8 @@ export default class DGAgentSheet extends AgentSheetBase { `${this.TEMPLATE_PATH}/parts/personal-tab-agent.html`, `${this.TEMPLATE_PATH}/parts/effects-tab.html`, `${this.TEMPLATE_PATH}/parts/about-tab.html`, + `${this.TEMPLATE_PATH}/partials/agent-skill-row-partial.html`, + `${this.TEMPLATE_PATH}/partials/agent-special-training-row-partial.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`, @@ -149,7 +154,18 @@ export default class DGAgentSheet extends AgentSheetBase { if (this.actor.type !== "agent") return context; - context.typedSkillColumns = this._prepareTypedSkillColumns(); + const sortSkillsByColumn = game.settings.get("deltagreen", "sortSkills"); + context.sortSkillsByColumn = sortSkillsByColumn; + context.skillColumns = prepareAgentSkillColumns( + this.actor.system.sortedSkills, + sortSkillsByColumn, + ); + context.specialTrainingRows = this._prepareSpecialTrainingBlocks(); + context.showSpecialTrainingSection = context.specialTrainingRows.length > 0; + context.specialTrainingColumns = prepareAgentSkillColumns( + context.specialTrainingRows, + sortSkillsByColumn, + ); context.customSkillsEditMode = this._customSkillsEditMode; context.physicalUi = this._preparePhysicalUi(); context.professionItem = this.actor.items.find( @@ -175,8 +191,71 @@ export default class DGAgentSheet extends AgentSheetBase { } /** @override */ - _sortCustomSkills() { - // Agent sheet uses typedSkillColumns instead of sortedCustomSkills. + _sortSkills() { + const sortedSkills = []; + + for (const [key, skill] of Object.entries(this.actor.system.skills)) { + let sortLabel; + if (game.i18n.lang === "ja") { + sortLabel = game.i18n.localize(`DG.Skills.ruby.${key}`); + } else { + sortLabel = game.i18n.localize(`DG.Skills.${key}`); + } + + if (sortLabel === "" || sortLabel === `DG.Skills.${key}`) { + sortLabel = key; + } + + sortedSkills.push({ + ...skill, + skillKind: "fixed", + key, + sortLabel, + }); + } + + for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) { + const displayLabel = formatProfessionSkillLabel({ + kind: "typed", + group: skill.group, + label: skill.label, + }); + sortedSkills.push({ + ...skill, + skillKind: "typed", + key, + displayLabel, + sortLabel: displayLabel, + actorType: this.actor.type, + }); + } + + sortedSkills.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + + this.actor.system.sortedSkills = sortedSkills; + } + + /** @override */ + _prepareSkillTooltips() { + for (const skill of this.actor.system.sortedSkills) { + if (skill.skillKind === "typed") { + skill.tooltip = buildSkillTooltip( + "sheet", + this.actor, + { kind: "typed", group: skill.group, label: skill.label }, + { proficiency: Number(skill.proficiency) || 0 }, + ); + } else { + skill.tooltip = buildSkillTooltip( + "sheet", + this.actor, + { kind: "fixed", key: skill.key }, + { proficiency: Number(skill.proficiency) || 0 }, + ); + } + } } /** @override */ @@ -262,61 +341,7 @@ export default class DGAgentSheet extends AgentSheetBase { } /** - * 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). + * Sorted special-training blocks for the skills tab. * * @returns {object[]} */ @@ -416,15 +441,6 @@ export default class DGAgentSheet extends AgentSheetBase { static async _restAgent() { const { actor } = this; - if (getEffectiveSuppressExhaustion(actor)) { - ui.notifications.warn( - game.i18n.format("DG.Physical.RestBlockedWhileSuppressed", { - name: actor.name, - }), - ); - return; - } - const wasExhausted = DGAgentSheet._isActorExhausted(actor); const wpRoll = await new Roll("1d6").evaluate(); const gain = wpRoll.total; @@ -524,6 +540,7 @@ export default class DGAgentSheet extends AgentSheetBase { await createAgentResourceChatMessage({ actor, token: this.token, + roll: hoursRoll, contentKey, labelKey, i18nData: { diff --git a/module/sheets/base-actor-sheet.js b/module/sheets/base-actor-sheet.js index 359ef39..a9d80fb 100644 --- a/module/sheets/base-actor-sheet.js +++ b/module/sheets/base-actor-sheet.js @@ -85,7 +85,9 @@ export default class DGActorSheet extends ComposedActorSheetBase { if (this.actor.type === "vehicle") return context; this._sortSkills(); - this._sortCustomSkills(); + if (this.actor.type !== "agent") { + this._sortCustomSkills(); + } context.skillTooltipDisplay = game.settings.get( "deltagreen", diff --git a/module/sheets/base-item-sheet.js b/module/sheets/base-item-sheet.js index aae8d10..c11270a 100644 --- a/module/sheets/base-item-sheet.js +++ b/module/sheets/base-item-sheet.js @@ -1,4 +1,4 @@ -import { BASE_TEMPLATE_PATH } from "../config.js"; +import { BASE_TEMPLATE_PATH } from "../config/index.js"; import DGSheetMixin from "./base-sheet.js"; import EffectsTabMixin from "./mixins/effects-tab-mixin.js"; import ProfessionItemMixin from "./mixins/profession-item-mixin.js"; diff --git a/module/sheets/base-sheet.js b/module/sheets/base-sheet.js index 711edce..7692c46 100644 --- a/module/sheets/base-sheet.js +++ b/module/sheets/base-sheet.js @@ -1,4 +1,4 @@ -import DG, { BASE_TEMPLATE_PATH } from "../config.js"; +import DG, { BASE_TEMPLATE_PATH } from "../config/index.js"; import { getRichTextFieldsForPart } from "../config/rich-text-fields.js"; import { prepareRichTextContext } from "../utils/rich-text.js"; diff --git a/module/sheets/mixins/effects-tab-mixin.js b/module/sheets/mixins/effects-tab-mixin.js index 3d8ae2e..a3ea9cb 100644 --- a/module/sheets/mixins/effects-tab-mixin.js +++ b/module/sheets/mixins/effects-tab-mixin.js @@ -1,5 +1,5 @@ import { SUPPORTED_ITEM_TYPES } from "../../active-effect/effect-fields.js"; -import { getTransferSuppressionReasonKey } from "../../documents/dg-active-effect.js"; +import { getTransferSuppressionReasonKey } from "../../active-effect/documents/dg-active-effect.js"; /** @param {typeof foundry.applications.api.ApplicationV2} Base */ export default function EffectsTabMixin(Base) { diff --git a/module/sheets/mixins/profession-item-mixin.js b/module/sheets/mixins/profession-item-mixin.js index 4b31ab4..677de44 100644 --- a/module/sheets/mixins/profession-item-mixin.js +++ b/module/sheets/mixins/profession-item-mixin.js @@ -1,4 +1,4 @@ -import { BASE_TEMPLATE_PATH } from "../../config.js"; +import { BASE_TEMPLATE_PATH } from "../../config/index.js"; import { PROFESSION_OPTION_PICKS_KEY } from "../../data/item/profession.js"; import { allocateProfessionSkillStorageKey, @@ -7,7 +7,7 @@ import { findTypedSkillNameConflict, prepareProfessionSkillRows, splitProfessionSkillMap, -} from "../../utils/profession-skills.js"; +} from "../../profession/index.js"; import { getDialogContentRoot, showDgDialog, diff --git a/module/sheets/mixins/skill-prep-mixin.js b/module/sheets/mixins/skill-prep-mixin.js index 69d3c78..3fba0eb 100644 --- a/module/sheets/mixins/skill-prep-mixin.js +++ b/module/sheets/mixins/skill-prep-mixin.js @@ -1,6 +1,6 @@ -import DG from "../../config.js"; +import DG from "../../config/index.js"; import { reorderForColumnSorting } from "../../utils/skill-layout.js"; -import { getRollTargetDisplayClassFromModifier } from "../../utils/active-effect-derived.js"; +import { getRollTargetDisplayClassFromModifier } from "../../active-effect/runtime/derived.js"; import { applySkillTooltipDisplayMode, buildAgentSpecialTrainingTooltip, diff --git a/module/utils/profession-skills.js b/module/utils/profession-skills.js deleted file mode 100644 index 09e65e8..0000000 --- a/module/utils/profession-skills.js +++ /dev/null @@ -1,1076 +0,0 @@ -import DG from "../config.js"; -import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js"; - -/** @typedef {"fixed" | "typed"} ProfessionSkillKind */ - -/** @typedef {{ kind: "fixed", key: string }} FixedSkillRef */ - -/** @typedef {{ kind: "typed", group: string, label: string }} TypedSkillRef */ - -/** @typedef {FixedSkillRef | TypedSkillRef} ProfessionSkillRef */ - -/** @typedef {{ chooseOne?: boolean }} ProfessionSkillMeta */ - -/** Typed skill template groups (camelCase storage keys). */ -export const TYPED_SKILL_TEMPLATE_GROUPS = /** @type {const} */ ([ - "Art", - "Craft", - "ForeignLanguage", - "MilitaryScience", - "Pilot", - "Science", - "Other", -]); - -const TYPED_GROUP_I18N = { - Art: "DG.TypeSkills.Art", - Craft: "DG.TypeSkills.Craft", - ForeignLanguage: "DG.TypeSkills.ForeignLanguage", - MilitaryScience: "DG.TypeSkills.MilitaryScience", - Pilot: "DG.TypeSkills.Pilot", - Science: "DG.TypeSkills.Science", - Other: "DG.TypeSkills.Other", -}; - -/** Agent default skill ratings (from human-skills schema initials). */ -const AGENT_SKILL_DEFAULTS = /** @type {Record} */ ({ - accounting: 10, - alertness: 20, - anthropology: 0, - archeology: 0, - artillery: 0, - athletics: 30, - bureaucracy: 10, - computer_science: 0, - criminology: 10, - demolitions: 0, - disguise: 10, - dodge: 30, - drive: 20, - firearms: 20, - first_aid: 10, - forensics: 0, - heavy_machiner: 10, - heavy_weapons: 0, - history: 10, - humint: 10, - law: 0, - medicine: 0, - melee_weapons: 30, - navigate: 10, - occult: 10, - persuade: 20, - pharmacy: 0, - psychotherapy: 10, - ride: 10, - search: 20, - sigint: 0, - stealth: 10, - surgery: 0, - survival: 10, - swim: 20, - unarmed_combat: 40, - unnatural: 0, -}); - -const BONUS_SKILL_COUNT = 8; -const BONUS_SKILL_INCREMENT = 20; - -/** @type {ReadonlySet} */ -const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]); -const SKILL_CAP = 80; -/** Bonus picks that would waste this many points or more are rejected (e.g. 80 + 20% at cap). */ -const MAX_ALLOWED_BONUS_WASTE = 20; - -const TYPED_KEY_PATTERN = - /^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i; - -/** Legacy internal keys from earlier builds (`typed:ForeignLanguage:abc12345`). */ -const INTERNAL_TYPED_KEY_PATTERN = - /^typed:([A-Za-z]+):([A-Za-z0-9]{8,16})(?::(.+))?$/; - -/** - * @returns {string} - */ -export function getChooseOnePlaceholderLabel() { - return game.i18n.localize("DG.Profession.ChooseOnePlaceholder"); -} - -/** - * @param {string} label - * @returns {string} - */ -export function normalizeTypedSkillName(label) { - return label.trim().toLocaleLowerCase(game.i18n.lang); -} - -/** - * @param {string} label - * @returns {boolean} - */ -export function isChooseOnePlaceholderLabel(label) { - return ( - normalizeTypedSkillName(label) === - normalizeTypedSkillName(getChooseOnePlaceholderLabel()) - ); -} - -/** - * @param {string} mapKey - * @param {Record} automaticMeta - * @param {Record} optionMeta - * @returns {boolean} - */ -export function isChooseOneProfessionSkillKey( - mapKey, - automaticMeta = {}, - optionMeta = {}, -) { - return Boolean( - automaticMeta[mapKey]?.chooseOne ?? optionMeta[mapKey]?.chooseOne, - ); -} - -/** - * @param {object} params - * @param {string} params.group - * @param {string} params.label - * @param {boolean} params.chooseOne - * @param {Record} params.automaticSkills - * @param {Record} params.optionSkills - * @param {Record} [params.automaticMeta] - * @param {Record} [params.optionMeta] - * @param {string} [params.excludeKey] - * @returns {string | null} Conflicting map key, if any. - */ -export function findTypedSkillNameConflict({ - group, - label, - chooseOne, - automaticSkills, - optionSkills, - automaticMeta = {}, - optionMeta = {}, - excludeKey = null, -}) { - // Multiple choose-one slots per category are allowed on the profession item. - if (chooseOne) return null; - if (!group) return null; - - const normalizedGroup = normalizeTypedGroup(group); - const normalizedNew = normalizeTypedSkillName(label); - if (!normalizedNew) return "__empty__"; - - const maps = [ - [automaticSkills, automaticMeta], - [optionSkills, optionMeta], - ]; - - for (const [skillMap, metaMap] of maps) { - for (const mapKey of Object.keys(skillMap ?? {})) { - if (mapKey === excludeKey || mapKey === PROFESSION_OPTION_PICKS_KEY) continue; - - const ref = parseProfessionSkillKey(mapKey); - if (!ref || ref.kind !== "typed") continue; - if (normalizeTypedGroup(ref.group) !== normalizedGroup) continue; - - // Unresolved choose-one slots have no fixed name yet. - if (metaMap[mapKey]?.chooseOne) continue; - - if (normalizeTypedSkillName(ref.label) === normalizedNew) return mapKey; - } - } - - return null; -} - -/** - * @returns {Record} - */ -export function getAgentSkillDefaults() { - return { ...AGENT_SKILL_DEFAULTS }; -} - -/** - * @param {string} group - * @returns {string} - */ -export function normalizeTypedGroup(group) { - if (group == null) return ""; - const trimmed = String(group).trim(); - const map = { - "Foreign Language": "ForeignLanguage", - "Military Science": "MilitaryScience", - }; - return map[trimmed] ?? trimmed.replace(/\s+/g, ""); -} - -/** - * @param {string} group - * @returns {string} - */ -export function getTypedGroupDisplayName(group) { - const key = normalizeTypedGroup(group); - const i18nKey = TYPED_GROUP_I18N[key]; - if (i18nKey) return game.i18n.localize(i18nKey); - return group; -} - -/** - * @param {ProfessionSkillRef} ref - * @returns {string} - */ -export function formatProfessionSkillKey(ref) { - if (ref.kind === "fixed") return ref.key; - const groupLabel = getTypedGroupDisplayName(ref.group); - return `${groupLabel} (${ref.label.trim()})`; -} - -/** - * Storage key for a profession skill map entry. - * - * @param {ProfessionSkillRef} ref - * @param {{ chooseOne?: boolean }} [options] - * @returns {string} - */ -export function allocateProfessionSkillStorageKey(ref, { chooseOne = false } = {}) { - if (ref.kind === "fixed") return ref.key; - if (chooseOne) { - return `typed:${normalizeTypedGroup(ref.group)}:${foundry.utils.randomID(8)}`; - } - const label = ref.label?.trim() ?? ""; - return formatProfessionSkillKey({ - kind: "typed", - group: ref.group, - label, - }); -} - -/** - * @param {string} key - * @returns {ProfessionSkillRef | null} - */ -export function parseProfessionSkillKey(key) { - if (!key || key === PROFESSION_OPTION_PICKS_KEY) return null; - if (DG.skills.includes(key)) return { kind: "fixed", key }; - - const internalMatch = key.match(INTERNAL_TYPED_KEY_PATTERN); - if (internalMatch) { - let label = ""; - if (internalMatch[3] !== undefined) { - try { - label = decodeURIComponent(internalMatch[3]); - } catch { - label = internalMatch[3]; - } - } - return { - kind: "typed", - group: internalMatch[1], - label, - }; - } - - const match = key.match(TYPED_KEY_PATTERN); - if (match) { - return { - kind: "typed", - group: normalizeTypedGroup(match[1]), - label: match[2].trim(), - }; - } - return null; -} - -/** - * @param {ProfessionSkillRef} ref - * @returns {string} - */ -export function formatProfessionSkillLabel(ref) { - if (ref.kind === "fixed") { - if (game.i18n.lang === "ja") { - const ja = game.i18n.localize(`DG.Skills.ruby.${ref.key}`); - if (ja && ja !== `DG.Skills.ruby.${ref.key}`) return ja; - } - const label = game.i18n.localize(`DG.Skills.${ref.key}`); - if (label && label !== `DG.Skills.${ref.key}`) return label; - return ref.key; - } - if (!ref.label?.trim()) return getTypedGroupDisplayName(ref.group); - return formatProfessionSkillKey(ref); -} - -/** - * @param {string} key - * @param {object} [metaContext] - * @param {Record} [metaContext.automaticMeta] - * @param {Record} [metaContext.optionMeta] - * @returns {string} - */ -export function formatProfessionSkillKeyLabel( - key, - { automaticMeta = {}, optionMeta = {} } = {}, -) { - const ref = parseProfessionSkillKey(key); - if (!ref) return key; - if ( - ref.kind === "typed" && - isChooseOneProfessionSkillKey(key, automaticMeta, optionMeta) - ) { - return formatProfessionSkillKey({ - kind: "typed", - group: ref.group, - label: getChooseOnePlaceholderLabel(), - }); - } - if (ref.kind === "typed" && !ref.label?.trim()) { - return getTypedGroupDisplayName(ref.group); - } - return formatProfessionSkillLabel(ref); -} - -/** - * @param {Record} obj - * @returns {{ optionPicks: number, skills: Record }} - */ -export function splitProfessionSkillMap(obj) { - const source = obj ?? {}; - const optionPicks = Number(source[PROFESSION_OPTION_PICKS_KEY]) || 0; - /** @type {Record} */ - const skills = {}; - for (const [key, value] of Object.entries(source)) { - if (key === PROFESSION_OPTION_PICKS_KEY) continue; - skills[key] = Number(value); - } - return { optionPicks, skills }; -} - -/** - * @typedef {object} SkillCatalogEntry - * @property {string} id - Select value (`fixed:accounting` or `typed:Craft`) - * @property {ProfessionSkillRef} ref - * @property {string} label - * @property {string} sortLabel - */ - -/** - * @returns {SkillCatalogEntry[]} - */ -export function buildSkillCatalog() { - /** @type {SkillCatalogEntry[]} */ - const entries = []; - - for (const key of DG.skills) { - const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); - const label = formatProfessionSkillLabel(ref); - entries.push({ - id: `fixed:${key}`, - ref, - label, - sortLabel: label, - }); - } - - for (const group of TYPED_SKILL_TEMPLATE_GROUPS) { - const ref = /** @type {TypedSkillRef} */ ({ kind: "typed", group, label: "" }); - const label = getTypedGroupDisplayName(group); - entries.push({ - id: `typed:${group}`, - ref, - label, - sortLabel: label, - }); - } - - entries.sort((a, b) => - a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), - ); - return entries; -} - -/** - * Skill catalog for character-creation bonus picks (excludes Unnatural). - * - * @returns {SkillCatalogEntry[]} - */ -export function buildBonusSkillCatalog() { - return buildSkillCatalog().filter( - (entry) => - entry.ref.kind !== "fixed" || - !BONUS_SKILL_CATALOG_EXCLUDED_FIXED.has(entry.ref.key), - ); -} - -/** - * @param {string} catalogId - * @param {string} [typedLabel] - * @param {{ allowEmptyTypedLabel?: boolean }} [options] - * @returns {ProfessionSkillRef | null} - */ -export function catalogIdToSkillRef( - catalogId, - typedLabel = "", - { allowEmptyTypedLabel = false } = {}, -) { - if (!catalogId) return null; - if (catalogId.startsWith("fixed:")) { - const key = catalogId.slice(6); - if (!DG.skills.includes(key)) return null; - return { kind: "fixed", key }; - } - if (catalogId.startsWith("typed:")) { - const group = catalogId.slice(6); - if (!TYPED_SKILL_TEMPLATE_GROUPS.includes(group)) return null; - const label = typedLabel.trim(); - if (!label && !allowEmptyTypedLabel) return null; - return { kind: "typed", group, label }; - } - return null; -} - -/** - * @param {ProfessionSkillRef} ref - * @returns {string} - */ -function getBonusTrackKey(ref) { - if (ref.kind === "fixed") return `fixed:${ref.key}`; - return formatProfessionSkillKey(ref); -} - -/** - * @param {string} trackKey - * @returns {string} - */ -function getBonusTrackLabel(trackKey) { - if (trackKey.startsWith("fixed:")) { - const key = trackKey.slice(6); - return formatProfessionSkillLabel( - /** @type {FixedSkillRef} */ ({ kind: "fixed", key }), - ); - } - const ref = parseProfessionSkillKey(trackKey); - if (ref) return formatProfessionSkillLabel(ref); - return trackKey; -} - -/** - * @param {string} trackKey - * @param {Record} baseFixed - * @param {Record} baseTyped - * @param {Record} defaults - * @returns {number} - */ -function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) { - if (trackKey.startsWith("fixed:")) { - const key = trackKey.slice(6); - return baseFixed[key] ?? defaults[key] ?? 0; - } - return baseTyped[trackKey]?.value ?? 0; -} - -/** - * Bonus skill slots that would waste 20+ points or raise a skill already at the creation cap. - * - * @param {Record} baseFixed - * @param {Record} baseTyped - * @param {string[]} bonusCatalogIds - * @param {string[]} bonusTypedLabels - * @returns {string[]} - */ -export function collectBonusCapValidationErrors( - baseFixed, - baseTyped, - bonusCatalogIds, - bonusTypedLabels, -) { - const defaults = getAgentSkillDefaults(); - /** @type {Record} */ - const counts = {}; - /** @type {(string | null)[]} */ - const slotTrackKeys = []; - - for (let i = 0; i < BONUS_SKILL_COUNT; i++) { - const catalogId = bonusCatalogIds?.[i]; - if (!catalogId) { - slotTrackKeys.push(null); - continue; - } - const ref = catalogIdToSkillRef( - catalogId, - bonusTypedLabels?.[i] ?? "", - ); - if (!ref) { - slotTrackKeys.push(null); - continue; - } - const trackKey = getBonusTrackKey(ref); - slotTrackKeys.push(trackKey); - counts[trackKey] = (counts[trackKey] ?? 0) + 1; - } - - /** @type {Set} */ - const violatingTrackKeys = new Set(); - - for (const [trackKey, count] of Object.entries(counts)) { - const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults); - const final = base + count * BONUS_SKILL_INCREMENT; - const waste = Math.max(0, final - SKILL_CAP); - if (base >= SKILL_CAP || waste >= MAX_ALLOWED_BONUS_WASTE) { - violatingTrackKeys.add(trackKey); - } - } - - /** @type {string[]} */ - const errors = []; - for (let i = 0; i < BONUS_SKILL_COUNT; i++) { - const trackKey = slotTrackKeys[i]; - if (!trackKey || !violatingTrackKeys.has(trackKey)) continue; - - const base = getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults); - const count = counts[trackKey] ?? 0; - const final = base + count * BONUS_SKILL_INCREMENT; - const waste = Math.max(0, final - SKILL_CAP); - - if (base >= SKILL_CAP) { - errors.push(`bonusAtCap:${i}|${trackKey}`); - } else { - errors.push(`bonusWaste:${i}|${waste}|${trackKey}`); - } - } - - return errors; -} - -/** - * @param {Record} automaticSkills - * @param {Record} optionSkills - * @param {number} optionPicks - * @param {object} formState - * @param {Set} formState.checkedOptionKeys - * @param {Record} formState.chooseOneLabels - keyed by profession map key - * @param {string[]} formState.bonusCatalogIds - length 8 - * @param {string[]} formState.bonusTypedLabels - length 8 - * @param {string[]} [formState.bondNames] - * @param {string[]} [formState.bondRelationships] - * @param {object} [context] - * @param {Record} [context.automaticMeta] - * @param {Record} [context.optionMeta] - * @param {number} [context.bondCount] - * @returns {{ - * fixedValues: Record, - * typedValues: Record, - * capWarnings: { label: string, attempted: number, waste: number }[], - * isValid: boolean, - * validationErrors: string[], - * modifiedFixedKeys: string[], - * modifiedTypedKeys: string[], - * }} - */ -export function computeSkillValues( - automaticSkills, - optionSkills, - optionPicks, - formState, - { automaticMeta = {}, optionMeta = {}, bondCount = 0 } = {}, -) { - const defaults = getAgentSkillDefaults(); - /** @type {Record} */ - const fixedValues = { ...defaults }; - /** @type {Record} */ - const typedValues = {}; - /** @type {Set} */ - const modifiedFixedKeys = new Set(); - /** @type {Set} */ - const modifiedTypedKeys = new Set(); - - /** - * @param {TypedSkillRef} ref - * @param {string} label - */ - const markTypedModified = (ref, label) => { - modifiedTypedKeys.add( - formatProfessionSkillKey({ kind: "typed", group: ref.group, label }), - ); - }; - - /** - * @param {string} mapKey - * @param {number} rating - * @param {{ labelOverride?: string }} [options] - * @returns {string | null} Merged typed storage key, if applied. - */ - const applyOverride = (mapKey, rating, options = {}) => { - const ref = parseProfessionSkillKey(mapKey); - if (!ref) return null; - const value = Number(rating); - if (ref.kind === "fixed") { - fixedValues[ref.key] = value; - return null; - } - - const label = - options.labelOverride?.trim() ?? ref.label?.trim() ?? ""; - if (!label) return null; - - const storageKey = formatProfessionSkillKey({ - kind: "typed", - group: ref.group, - label, - }); - typedValues[storageKey] = { - group: ref.group, - label, - value, - }; - return storageKey; - }; - - for (const [key, rating] of Object.entries(automaticSkills ?? {})) { - const ref = parseProfessionSkillKey(key); - if (!ref) continue; - - let labelOverride; - if (ref.kind === "typed" && automaticMeta[key]?.chooseOne) { - labelOverride = formState.chooseOneLabels?.[key]?.trim(); - if (!labelOverride) continue; - } - - const storageKey = applyOverride(key, rating, { labelOverride }); - if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); - else if (storageKey) { - markTypedModified(ref, labelOverride ?? ref.label); - } - } - - for (const key of formState.checkedOptionKeys ?? []) { - if (!optionSkills[key] && optionSkills[key] !== 0) continue; - const ref = parseProfessionSkillKey(key); - if (!ref) continue; - - let labelOverride; - if (ref.kind === "typed" && optionMeta[key]?.chooseOne) { - labelOverride = formState.chooseOneLabels?.[key]?.trim(); - if (!labelOverride) continue; - } else if (ref.kind === "typed") { - labelOverride = ref.label?.trim(); - if (!labelOverride) continue; - } - - const storageKey = applyOverride(key, optionSkills[key], { labelOverride }); - if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); - else if (storageKey) { - markTypedModified(ref, labelOverride ?? ref.label); - } - } - - /** @type {Record} */ - const bonusCounts = {}; - - const bonusIds = formState.bonusCatalogIds ?? []; - const bonusLabels = formState.bonusTypedLabels ?? []; - - for (let i = 0; i < BONUS_SKILL_COUNT; i++) { - const catalogId = bonusIds[i]; - if (!catalogId) continue; - const typedLabel = bonusLabels[i] ?? ""; - const ref = catalogIdToSkillRef(catalogId, typedLabel); - if (!ref) continue; - - if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); - else modifiedTypedKeys.add(formatProfessionSkillKey(ref)); - - let trackKey; - if (ref.kind === "fixed") { - trackKey = `fixed:${ref.key}`; - } else { - trackKey = formatProfessionSkillKey(ref); - } - bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1; - } - - /** Pre-bonus base for cap warnings */ - const baseFixed = { ...fixedValues }; - const baseTyped = foundry.utils.deepClone(typedValues); - - for (const [trackKey, count] of Object.entries(bonusCounts)) { - const bonus = count * BONUS_SKILL_INCREMENT; - if (trackKey.startsWith("fixed:")) { - const key = trackKey.slice(6); - fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus; - } else { - const ref = parseProfessionSkillKey(trackKey); - if (ref?.kind !== "typed") continue; - - const base = typedValues[trackKey]?.value ?? 0; - typedValues[trackKey] = { - group: ref.group, - label: ref.label, - value: base + bonus, - }; - } - } - - /** @type {{ label: string, attempted: number, waste: number }[]} */ - const capWarnings = []; - - const checkCap = (label, base, final) => { - if (final > SKILL_CAP) { - capWarnings.push({ - label, - attempted: final, - waste: final - SKILL_CAP, - }); - } - }; - - for (const [key, final] of Object.entries(fixedValues)) { - const base = baseFixed[key] ?? defaults[key] ?? 0; - const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); - checkCap(formatProfessionSkillLabel(ref), base, final); - } - - for (const [storageKey, data] of Object.entries(typedValues)) { - const base = baseTyped[storageKey]?.value ?? 0; - const ref = /** @type {TypedSkillRef} */ ({ - kind: "typed", - group: data.group, - label: data.label, - }); - checkCap(formatProfessionSkillLabel(ref), base, data.value); - } - - /** Apply cap for display (values shown capped at 80) */ - for (const key of Object.keys(fixedValues)) { - if (fixedValues[key] > SKILL_CAP) fixedValues[key] = SKILL_CAP; - } - for (const data of Object.values(typedValues)) { - if (data.value > SKILL_CAP) data.value = SKILL_CAP; - } - - const validationErrors = validateProfessionFormState(optionPicks, formState, { - automaticSkills, - optionSkills, - automaticMeta, - optionMeta, - bondCount, - }); - - validationErrors.push( - ...collectBonusCapValidationErrors( - baseFixed, - baseTyped, - bonusIds, - bonusLabels, - ), - ); - - return { - fixedValues, - typedValues, - capWarnings, - isValid: validationErrors.length === 0, - validationErrors, - modifiedFixedKeys: [...modifiedFixedKeys], - modifiedTypedKeys: [...modifiedTypedKeys], - }; -} - -/** - * @param {number} optionPicks - * @param {object} formState - * @param {object} [context] - * @param {Record} [context.automaticSkills] - * @param {Record} [context.optionSkills] - * @param {Record} [context.automaticMeta] - * @param {Record} [context.optionMeta] - * @param {number} [context.bondCount] - * @returns {string[]} - */ -export function validateProfessionFormState(optionPicks, formState, context = {}) { - const errors = []; - const checked = formState.checkedOptionKeys ?? new Set(); - const picks = Number(optionPicks) || 0; - const { - automaticSkills = {}, - optionSkills = {}, - automaticMeta = {}, - optionMeta = {}, - bondCount = 0, - } = context; - - if (checked.size !== picks) { - errors.push("optionPicks"); - } - - for (let i = 0; i < bondCount; i++) { - if (!formState.bondNames?.[i]?.trim()) errors.push(`bondName${i}`); - if (!formState.bondRelationships?.[i]?.trim()) { - errors.push(`bondRelationship${i}`); - } - } - - for (let i = 0; i < BONUS_SKILL_COUNT; i++) { - const catalogId = formState.bonusCatalogIds?.[i]; - if (!catalogId) { - errors.push(`bonus${i}`); - continue; - } - const ref = catalogIdToSkillRef( - catalogId, - formState.bonusTypedLabels?.[i] ?? "", - ); - if (!ref) { - errors.push(`bonus${i}`); - continue; - } - if (ref.kind === "typed" && !formState.bonusTypedLabels?.[i]?.trim()) { - errors.push(`bonusType${i}`); - } - } - - /** @type {Map>} */ - const resolvedByGroup = new Map(); - - const registerResolvedName = (ref, rawLabel) => { - const label = rawLabel?.trim(); - if (!label) { - errors.push(`typedNameRequired:${ref.group}`); - return; - } - const group = normalizeTypedGroup(ref.group); - const normalized = normalizeTypedSkillName(label); - if (!resolvedByGroup.has(group)) resolvedByGroup.set(group, new Set()); - const names = resolvedByGroup.get(group); - if (names.has(normalized)) { - errors.push(`typedNameConflict:${group}:${label}`); - return; - } - names.add(normalized); - }; - - for (const [key] of Object.entries(automaticSkills)) { - const ref = parseProfessionSkillKey(key); - if (!ref || ref.kind !== "typed") continue; - if (automaticMeta[key]?.chooseOne) { - registerResolvedName(ref, formState.chooseOneLabels?.[key]); - } else { - registerResolvedName(ref, ref.label); - } - } - - for (const key of checked) { - const ref = parseProfessionSkillKey(key); - if (!ref || ref.kind !== "typed") continue; - if (optionMeta[key]?.chooseOne) { - registerResolvedName(ref, formState.chooseOneLabels?.[key]); - } else { - registerResolvedName(ref, ref.label); - } - } - - return errors; -} - -/** - * User-facing messages for Add Profession validation error codes. - * @param {string[]} errors - * @param {object} [context] - * @param {number} [context.optionPicks] - * @returns {string[]} - */ -export function formatProfessionValidationMessages( - errors, - { optionPicks = 0 } = {}, -) { - const messages = []; - const seen = new Set(); - - const push = (msg) => { - if (seen.has(msg)) return; - seen.add(msg); - messages.push(msg); - }; - - let needsOptionPicks = false; - let needsBondName = false; - let needsBondRelationship = false; - let needsBonusSkill = false; - let needsBonusSkillType = false; - /** @type {Set} */ - const typedNameRequiredGroups = new Set(); - /** @type {string[]} */ - const bonusAtCapTrackKeys = []; - - for (const code of errors) { - if (code === "optionPicks") { - needsOptionPicks = true; - continue; - } - if (code.startsWith("bondName")) { - needsBondName = true; - continue; - } - if (code.startsWith("bondRelationship")) { - needsBondRelationship = true; - continue; - } - const bonusAtCap = code.match(/^bonusAtCap:\d+\|(.+)$/); - if (bonusAtCap) { - bonusAtCapTrackKeys.push(bonusAtCap[1]); - continue; - } - - const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/); - if (bonusWaste) { - push( - game.i18n.format("DG.Profession.Dialog.BonusSkillWasteTooHigh", { - skill: getBonusTrackLabel(bonusWaste[2]), - waste: Number(bonusWaste[1]), - maxWaste: MAX_ALLOWED_BONUS_WASTE - 1, - }), - ); - continue; - } - - if (code.startsWith("bonusType")) { - needsBonusSkillType = true; - continue; - } - if (/^bonus\d+$/.test(code)) { - needsBonusSkill = true; - continue; - } - - const typedNameRequired = code.match(/^typedNameRequired:(.+)$/); - if (typedNameRequired) { - typedNameRequiredGroups.add(typedNameRequired[1]); - continue; - } - - const conflict = code.match(/^typedNameConflict:([^:]+):(.+)$/); - if (conflict) { - const [, group, name] = conflict; - push( - game.i18n.format("DG.Profession.Dialog.DuplicateTypedName", { - type: getTypedGroupDisplayName(group), - name, - }), - ); - } - } - - if (needsOptionPicks) { - push( - game.i18n.format("DG.Profession.Dialog.OptionPicksRequired", { - picks: optionPicks, - }), - ); - } - if (needsBonusSkill) { - push( - game.i18n.format("DG.Profession.Dialog.BonusSkillRequired", { - count: BONUS_SKILL_COUNT, - }), - ); - } - if (needsBonusSkillType) { - push(game.i18n.localize("DG.Profession.Dialog.BonusSkillTypeRequired")); - } - for (const group of typedNameRequiredGroups) { - push( - game.i18n.format("DG.Profession.Dialog.TypedNameRequired", { - type: getTypedGroupDisplayName(group), - }), - ); - } - if (needsBondName) { - push(game.i18n.localize("DG.Profession.Dialog.BondNameRequired")); - } - if (needsBondRelationship) { - push(game.i18n.localize("DG.Profession.Dialog.BondRelationshipRequired")); - } - - for (const trackKey of bonusAtCapTrackKeys) { - push( - game.i18n.format("DG.Profession.Dialog.BonusSkillAlreadyAtCap", { - skill: getBonusTrackLabel(trackKey), - }), - ); - } - - return messages; -} - -/** - * Sorted fixed skill rows for display. - * @param {Record} fixedValues - * @returns {{ key: string, label: string, value: number }[]} - */ -export function buildSortedFixedSkillRows(fixedValues) { - const rows = Object.entries(fixedValues).map(([key, value]) => { - const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); - return { - key, - label: formatProfessionSkillLabel(ref), - value, - sortLabel: formatProfessionSkillLabel(ref), - }; - }); - rows.sort((a, b) => - a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), - ); - return rows; -} - -/** - * Sorted typed skill rows for display. - * @param {Record} typedValues - * @returns {{ storageKey: string, label: string, value: number, sortLabel: string }[]} - */ -export function buildSortedTypedSkillRows(typedValues) { - const rows = Object.entries(typedValues).map(([storageKey, data]) => { - const ref = { - kind: "typed", - group: data.group, - label: data.label, - }; - const label = formatProfessionSkillLabel(ref); - return { - storageKey, - label, - value: data.value, - sortLabel: label, - }; - }); - rows.sort((a, b) => - a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), - ); - return rows; -} - -/** - * Prepare sorted skill rows from profession item maps. - * @param {Record} skillMap - * @param {Record} [skillMeta] - * @returns {{ key: string, label: string, rating: number }[]} - */ -export function prepareProfessionSkillRows(skillMap, skillMeta = {}) { - const rows = Object.entries(skillMap ?? {}).map(([key, rating]) => { - const label = formatProfessionSkillKeyLabel(key, { - automaticMeta: skillMeta, - optionMeta: skillMeta, - }); - return { - key, - label, - rating: Number(rating), - sortLabel: label, - }; - }); - rows.sort((a, b) => - a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), - ); - return rows; -} - -export { BONUS_SKILL_COUNT }; diff --git a/module/utils/roll-target-tooltip.js b/module/utils/roll-target-tooltip.js index 9f614f2..cb83828 100644 --- a/module/utils/roll-target-tooltip.js +++ b/module/utils/roll-target-tooltip.js @@ -1,6 +1,6 @@ -import DG from "../config.js"; +import DG from "../config/index.js"; import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.js"; -import { clampPercentileRollTarget } from "./active-effect-derived.js"; +import { clampPercentileRollTarget } from "../active-effect/runtime/derived.js"; import DGUtils from "./utility-functions.js"; /** @typedef {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} RollTargetFieldKey */ diff --git a/module/utils/skill-layout.js b/module/utils/skill-layout.js index 7aef8ba..4af66d2 100644 --- a/module/utils/skill-layout.js +++ b/module/utils/skill-layout.js @@ -1,20 +1,4 @@ -/** - * Splits a flat list into column arrays for vertical stacking (top-to-bottom per column). - * - * @template T - * @param {T[]} arr - * @param {number} numCols - * @returns {T[][]} - */ -export function splitIntoColumns(arr, numCols) { - if (numCols <= 1) return [arr]; - const columns = Array.from({ length: numCols }, () => /** @type {T[]} */ ([])); - const perCol = Math.ceil(arr.length / numCols); - for (let col = 0; col < numCols; col++) { - columns[col] = arr.slice(col * perCol, (col + 1) * perCol); - } - return columns; -} +const AGENT_SKILL_COLUMNS = 3; /** * Reorders a flat array into column-major order for grid display. @@ -24,7 +8,6 @@ export function splitIntoColumns(arr, 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); @@ -54,91 +37,113 @@ export function reorderForColumnSorting(arr, numCols) { } /** - * @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. + * Column-major packing (matches legacy reorderForColumnSorting + 3-column grid). * - * @param {object[]} groups + * @template T + * @param {T[]} items * @param {number} numCols + * @returns {T[][]} + */ +function packItemsColumnMajor(items, numCols) { + const reordered = reorderForColumnSorting([...items], numCols); + const columns = Array.from( + { length: numCols }, + () => /** @type {T[]} */ ([]), + ); + + for (let col = 0; col < numCols; col++) { + for (let row = 0; row * numCols + col < reordered.length; row++) { + const item = reordered[row * numCols + col]; + if (item !== undefined) { + columns[col].push(item); + } + } + } + + return columns; +} + +/** + * Row-major packing: alphabetical left-to-right, top-to-bottom across columns. + * + * @template T + * @param {T[]} items + * @param {number} numCols + * @returns {T[][]} + */ +function packItemsRowMajor(items, numCols) { + const columns = Array.from( + { length: numCols }, + () => /** @type {T[]} */ ([]), + ); + + for (let i = 0; i < items.length; i++) { + columns[i % numCols].push(items[i]); + } + + return columns; +} + +/** + * Packs items into column arrays for vertical stacking (top-to-bottom per column). + * + * @template T + * @param {T[]} items + * @param {number} numCols + * @param {{ sortByColumn?: boolean }} [options] + * @returns {T[][]} + */ +export function packItemsIntoWeightedColumns( + items, + numCols, + { sortByColumn = false } = {}, +) { + if (numCols <= 1) return [items]; + + if (items.length === 0) { + return Array.from({ length: numCols }, () => /** @type {T[]} */ ([])); + } + + if (sortByColumn) { + return packItemsColumnMajor(items, numCols); + } + + return packItemsRowMajor(items, numCols); +} + +/** + * Packs agent skills into sheet columns. + * + * @param {object[]} sortedSkills * @param {boolean} sortByColumn + * @param {number} [numCols] * @returns {object[][]} */ -export function packSkillGroupsIntoColumns(groups, numCols, sortByColumn) { - if (sortByColumn) { - return packSkillGroupsByColumn(groups, numCols); +export function prepareAgentSkillColumns( + sortedSkills, + sortByColumn, + numCols = AGENT_SKILL_COLUMNS, +) { + return packItemsIntoWeightedColumns(sortedSkills, numCols, { sortByColumn }); +} + +/** + * Splits a flat list into column arrays for vertical stacking (top-to-bottom per column). + * + * @template T + * @param {T[]} arr + * @param {number} numCols + * @returns {T[][]} + */ +export function splitIntoColumns(arr, numCols) { + if (numCols <= 1) return [arr]; + const columns = Array.from( + { length: numCols }, + () => /** @type {T[]} */ ([]), + ); + const perCol = Math.ceil(arr.length / numCols); + for (let col = 0; col < numCols; col++) { + columns[col] = arr.slice(col * perCol, (col + 1) * perCol); } - return packSkillGroupsByRow(groups, numCols); + return columns; } diff --git a/module/utils/skill-tooltip.js b/module/utils/skill-tooltip.js index 3ddccb0..6531b5d 100644 --- a/module/utils/skill-tooltip.js +++ b/module/utils/skill-tooltip.js @@ -1,4 +1,4 @@ -/** @typedef {import("./profession-skills.js").ProfessionSkillRef} ProfessionSkillRef */ +/** @typedef {import("../profession/index.js").ProfessionSkillRef} ProfessionSkillRef */ /** @typedef {"dialog" | "sheet"} SkillTooltipContext */ import { appendRollTargetTooltipSection } from "./roll-target-tooltip.js"; diff --git a/module/utils/world-migration.js b/module/utils/world-migration.js index 03220a6..9362813 100644 --- a/module/utils/world-migration.js +++ b/module/utils/world-migration.js @@ -1,4 +1,4 @@ -import DG from "../config.js"; +import DG from "../config/index.js"; const MIGRATION_VERSION = 2; const ACTOR_TYPES_WITH_SKILLS = ["agent", "npc", "unnatural"]; diff --git a/package.json b/package.json index affe086..1f7fe6c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "deltagreen", "version": "1.3.1", "description": "This is a fan made but now officially supported rules implementation (system) for [Foundry Virtual Tabletop](https://foundryvtt.com/) system for Delta Green: The RPG, based on the _Agent's Handbook_ rule set from Arc Dream Publishing (_**not** the Call of Cthulu rules_).", - "main": "deltagreen.js", + "main": "module/deltagreen.js", "scripts": { "lint": "eslint .", "format:check": "prettier --check .", diff --git a/scss/_mixins.scss b/scss/_mixins.scss index b6670a2..fd33e82 100644 --- a/scss/_mixins.scss +++ b/scss/_mixins.scss @@ -26,6 +26,35 @@ } } +/* Parchment/light sheet bodies: override Foundry v14 .application light text and heading typography. */ +@mixin dg-parchment-content-theme { + :is(h1, h2, h3, h4, h5, h6) { + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + } + + .resource-label, + .standard-form .form-group > label, + .form-group > label { + color: inherit; + } +} + +@mixin dg-parchment-form-fields { + input[type="text"], + input[type="number"], + input[type="password"], + select { + background-color: rgba(0, 0, 0, 0.05); + border: 1px solid rgb(122, 121, 113); + border-radius: 3px; + color: inherit; + } +} + @mixin item-sheet-grid-2col { display: grid; grid-template-columns: 25% 75%; diff --git a/scss/dialogue.scss b/scss/damage-modify.scss similarity index 100% rename from scss/dialogue.scss rename to scss/damage-modify.scss diff --git a/scss/deltagreen/_agent-sheet.scss b/scss/deltagreen/_agent-sheet.scss index 6cf977e..87b05a1 100644 --- a/scss/deltagreen/_agent-sheet.scss +++ b/scss/deltagreen/_agent-sheet.scss @@ -67,7 +67,7 @@ min-width: 0; position: relative; text-align: center; - margin-left: -14px; + margin-left: -5px; padding-inline: 3px; font-size: 11px; overflow: visible; @@ -141,7 +141,7 @@ .tabs [data-action="tab"][data-tab="about"] { flex: 0 0 auto; min-width: unset; - margin-left: -14px; + margin-left: -5px; } .window-content > [data-application-part="rightBar"], @@ -177,6 +177,32 @@ border-top-right-radius: 0; } + .right-bar > .tab:not(.active) { + display: none; + } + + .right-bar > .tab.skills.active { + display: flex; + flex-direction: column; + overflow: hidden; + + .skills-tab-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + + .skills-tab-footer { + flex: 0 0 auto; + margin-top: auto; + padding-top: 8px; + } + + .skills-tab-footer .skill-util-buttons { + margin-bottom: 0; + } + } + .tabs [data-action="tab"].active { z-index: 10; margin-bottom: -2px; @@ -294,34 +320,40 @@ .skill-util-buttons { display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: stretch; + flex-direction: column; gap: 8px; width: 100%; margin: 5px 0; box-sizing: border-box; - .action-pill { - flex: 1 1 0; - min-width: 0; - margin: 0; - text-align: center; + .skill-util-buttons-row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + gap: 8px; + width: 100%; + + .action-pill { + flex: 1 1 0; + min-width: 0; + margin: 0; + text-align: center; + } + + .typed-skill-add { + margin-right: 0; + } } - .typed-skill-add { - margin-right: 0; + .skill-util-buttons-row--full .action-pill { + flex: 1 1 100%; + width: 100%; } a[data-action="applySkillImprovements"] { float: none; } - - .custom-skills-edit-toggle { - flex: 0 0 auto; - min-width: 2rem; - padding-inline: 6px; - } } .custom-skill-group-label { @@ -350,9 +382,72 @@ color: var(--dg-on-dark); } - .typed-skill-row { - min-height: 27px; + .agent-skills-columns { + align-items: start; + --agent-skill-row-gap: 3px; + } + + .agent-skills-column { + display: flex; + flex-direction: column; + gap: var(--agent-skill-row-gap); + min-width: 0; + + /* Prevent flex column from shrinking skill rows below min-height */ + > .skill-row, + > .item.skill-row { + flex: 0 0 auto; + flex-shrink: 0; + align-self: stretch; + width: 100%; + } + } + + .tab.skills .skill-row, + .tab.skills .skill-row > .flexrow { + flex-wrap: nowrap; align-items: center; + width: 100%; + min-width: 0; + box-sizing: border-box; + } + + /* Fixed skills: one flexrow row — stop Foundry .flexrow > * { flex: 1 } from widening inputs */ + .tab.skills .flexrow.skill-row > .skill-label { + flex: 1 1 0; + min-width: 0; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.2; + } + + .tab.skills .flexrow.skill-row > .percentile-skill-input { + flex: 0 0 40px; + width: 40px; + max-width: 40px; + } + + .tab.skills .flexrow.skill-row > .checkbox-skill-input { + flex: 0 0 20px; + width: 20px; + max-width: 20px; + } + + /* Typed skills: label grows, controls stay fixed width */ + .tab.skills .typed-skill-row > .flexrow > .skill-label { + flex: 1 1 0; + min-width: 0; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.2; + } + + .tab.skills .typed-skill-row > .flexrow > .custom-skill-controls-slot { + flex: 0 0 70px; + width: 70px; + max-width: 70px; } .custom-skill-controls-slot { @@ -400,15 +495,8 @@ pointer-events: none; } - .typed-skills-columns { - align-items: start; - } - - .typed-skills-column { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; + .special-training-columns { + margin-bottom: 4px; } .skill-and-training-controls { @@ -528,10 +616,53 @@ margin-bottom: 4px; } + .left-bar-section-divider { + margin: 8px 0; + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.15); + height: 0; + } + + .left-bar-section-heading { + margin: 0 0 6px; + padding: 0; + border: 0; + background: none; + font-family: inherit; + font-size: 14px; + font-weight: bold; + font-style: normal; + line-height: 1.2; + text-align: left; + color: inherit; + } + .left-bar-tab-panels .tab:not(.active) { display: none; } + .left-bar-rest-sanity-panels { + display: grid; + + > .tab { + grid-area: 1 / 1; + min-width: 0; + box-sizing: border-box; + } + + > .tab:not(.active) { + display: block; + visibility: hidden; + pointer-events: none; + } + + > .tab.active { + visibility: visible; + pointer-events: auto; + z-index: 1; + } + } + .left-bar-action-stack { display: flex; flex-direction: column; diff --git a/scss/deltagreen/_character-sheet-cont.scss b/scss/deltagreen/_character-sheet-cont.scss index 399939b..b71b850 100644 --- a/scss/deltagreen/_character-sheet-cont.scss +++ b/scss/deltagreen/_character-sheet-cont.scss @@ -292,3 +292,28 @@ div.horizontal-bar-section-divider.skills-header { grid-template-columns: 1fr auto 1fr; align-items: center; } + +/* Agent sheet: only the title — center in a single column (not grid column 2 of 3). */ +div.horizontal-bar-section-divider.skills-header:not(:has(.toggle-untrained)) { + grid-template-columns: 1fr; +} + +.horizontal-bar-section-divider .skills-header-title { + grid-column: 2; + margin: 0; + line-height: 1.2; +} + +div.horizontal-bar-section-divider.skills-header:not(:has(.toggle-untrained)) + .skills-header-title { + grid-column: 1; + justify-self: center; +} + +.horizontal-bar-section-divider .toggle-untrained { + grid-column: 3; + padding-right: 0.5rem; + justify-self: end; + align-self: center; + line-height: 1.2; +} diff --git a/scss/deltagreen/_chat-cards.scss b/scss/deltagreen/_chat-cards.scss index 143f8aa..2012f3c 100644 --- a/scss/deltagreen/_chat-cards.scss +++ b/scss/deltagreen/_chat-cards.scss @@ -107,3 +107,34 @@ .strike { text-decoration: line-through; } + +.roll-stats-chat-table { + width: auto; + margin: 0 auto; + border-collapse: collapse; +} + +/* Override Foundry row striping; alternate per cell in reading order (checkerboard in 3 cols) */ +.dg-chat-card__body .roll-stats-chat-table tbody tr { + background-color: transparent; +} + +.dg-chat-card__body .roll-stats-chat-table td { + padding: 0.35rem 1rem; + text-align: center; + font-weight: bold; + font-size: var(--font-size-16, 1rem); + border: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.2)); +} + +.dg-chat-card__body .roll-stats-chat-table td.roll-stats-chat-value--a { + background-color: var(--table-row-color-odd, transparent); +} + +.dg-chat-card__body .roll-stats-chat-table td.roll-stats-chat-value--b { + background-color: var(--table-row-color-even, rgba(255, 255, 255, 0.1)); +} + +.dg-chat-card__body .roll-stats-chat-table .roll-stats-chat-value { + min-width: 2rem; +} diff --git a/scss/deltagreen/_global.scss b/scss/deltagreen/_global.scss index 5208cad..7fbc8c3 100644 --- a/scss/deltagreen/_global.scss +++ b/scss/deltagreen/_global.scss @@ -5,4 +5,5 @@ .deltagreen section.window-content { @include dg-window-content; @include dg-window-form-inputs; + @include dg-parchment-content-theme; } diff --git a/scss/deltagreen/_item-sheets.scss b/scss/deltagreen/_item-sheets.scss index ca9b6aa..cb32103 100644 --- a/scss/deltagreen/_item-sheets.scss +++ b/scss/deltagreen/_item-sheets.scss @@ -1,5 +1,19 @@ @use "../mixins" as *; +/* + Item sheets (ApplicationV2): Foundry .application uses light --color-text-primary, + but DG window-content uses light parchment backgrounds. Reset ink, headings, labels, + and form fields here so new item tabs stay readable without per-type patches. +*/ +.deltagreen.item .window-content { + color: var(--dg-ink); + scrollbar-color: #215112 #111 !important; + + @include dg-window-form-inputs-with-select; + @include dg-parchment-content-theme; + @include dg-parchment-form-fields; +} + /* Item Sheet Common CSS */ diff --git a/scss/deltagreen/_profession.scss b/scss/deltagreen/_profession.scss index e8217ac..093a428 100644 --- a/scss/deltagreen/_profession.scss +++ b/scss/deltagreen/_profession.scss @@ -1,76 +1,96 @@ +@use "../mixins" as *; + /* Profession item sheet */ .deltagreen .profession-sheet-grid-2col { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.75rem; - align-items: start; + @include item-sheet-grid-2col; + padding-left: 5px; } -.deltagreen .profession-sheet-name { +.deltagreen .profession-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid(150px); +} + +.deltagreen.item .tab.profession-skills .profession-skills-scroll { + padding: 0 0.5rem 0.5rem; +} + +.deltagreen .tab.profession-skills .profession-skills-section + .profession-skills-section { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-light-2, rgba(0, 0, 0, 0.15)); +} + +.deltagreen .tab.profession-skills .profession-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--color-border-light-2, rgba(0, 0, 0, 0.15)); +} + +.deltagreen .tab.profession-skills .profession-section-title { + margin: 0; + font-size: var(--font-size-14, 0.875rem); + font-weight: bold; + line-height: 1.3; +} +.deltagreen .tab.profession-skills .profession-section-add { + @include dg-dark-button; + flex: 0 0 auto; + padding: 0.2rem 0.5rem; + font-size: var(--font-size-12, 0.75rem); + line-height: 1.3; +} + +.deltagreen .tab.profession-skills .profession-section-config { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + + .resource-label { + margin: 0; + } +} + +.deltagreen .tab.profession-skills .profession-skill-list-columns-header { display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-bottom: 0.25rem; + font-size: var(--font-size-12, 0.75rem); + font-weight: bold; +} + +.deltagreen .tab.profession-skills .profession-skill-col-header { + display: grid; + grid-template-columns: 1fr auto 1.5rem; gap: 0.5rem; align-items: center; } -.deltagreen .tab.profession-skills { - --profession-toolbar-label-width: 11rem; - --profession-toolbar-secondary-label-width: 7rem; -} - -.deltagreen .tab.profession-skills .profession-skills-section { - margin-top: 0.75rem; -} - -.deltagreen .tab.profession-skills .profession-skills-section:first-child { - margin-top: 0; -} - -.deltagreen .tab.profession-skills .profession-section-toolbar { - display: grid; - grid-template-columns: - var(--profession-toolbar-label-width) - auto - var(--profession-toolbar-secondary-label-width) - 4rem; - align-items: center; - column-gap: 0.5rem; - margin-bottom: 0.35rem; -} - -.deltagreen .tab.profession-skills .profession-skills-toolbar-label { - font-weight: bold; -} - -.deltagreen .tab.profession-skills .profession-skills-toolbar-control { - justify-self: start; -} - -.deltagreen .tab.profession-skills .profession-bonds-input, -.deltagreen .tab.profession-skills .profession-option-picks-input { - width: 100%; - text-align: center; -} - .deltagreen .tab.profession-skills .profession-skill-list { display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.25rem 1rem; + grid-template-columns: repeat(2, 1fr); + gap: 0.2rem 0.75rem; list-style: none; margin: 0; padding: 0; } .deltagreen .tab.profession-skills .profession-skill-row { - display: flex; + display: grid; + grid-template-columns: 1fr auto 1.5rem; + gap: 0.25rem 0.5rem; align-items: center; - gap: 0.5rem; - padding: 0.15rem 0; + min-height: 27px; min-width: 0; + box-sizing: border-box; } .deltagreen .tab.profession-skills .profession-skill-label { - flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -78,38 +98,27 @@ } .deltagreen .tab.profession-skills .profession-skill-rating { - flex-shrink: 0; - min-width: 2.5rem; text-align: right; + min-width: 2.5rem; } -.roll-stats-chat-table { - width: auto; - margin: 0 auto; - border-collapse: collapse; -} +.deltagreen .tab.profession-skills .profession-skill-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: inherit; + opacity: 0.65; + line-height: 1; + cursor: pointer; -/* Override Foundry row striping; alternate per cell in reading order (checkerboard in 3 cols) */ -.dg-chat-card__body .roll-stats-chat-table tbody tr { - background-color: transparent; -} - -.dg-chat-card__body .roll-stats-chat-table td { - padding: 0.35rem 1rem; - text-align: center; - font-weight: bold; - font-size: var(--font-size-16, 1rem); - border: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.2)); -} - -.dg-chat-card__body .roll-stats-chat-table td.roll-stats-chat-value--a { - background-color: var(--table-row-color-odd, transparent); -} - -.dg-chat-card__body .roll-stats-chat-table td.roll-stats-chat-value--b { - background-color: var(--table-row-color-even, rgba(255, 255, 255, 0.1)); -} - -.dg-chat-card__body .roll-stats-chat-table .roll-stats-chat-value { - min-width: 2rem; + &:hover:not(:disabled) { + @include dg-dark-button; + opacity: 1; + } } diff --git a/scss/dg-dialog/_base.scss b/scss/dg-dialog/_base.scss index 0c1f4f9..f912d90 100644 --- a/scss/dg-dialog/_base.scss +++ b/scss/dg-dialog/_base.scss @@ -6,31 +6,16 @@ section.window-content { @include dg-window-content; @include dg-window-form-inputs-with-select; - - :is(h1, h2, h3, h4, h5, h6) { - color: inherit; - } + @include dg-parchment-content-theme; + @include dg-parchment-form-fields; } - /* Foundry sets explicit light-theme tokens on headings/labels; inherit parchment body color. */ - .standard-form .form-group > label, - .form-group > label, .dg-dialog__field-label, .dg-dialog .dg-dialog__heading, .dg-dialog__intro { color: inherit; } - .dg-dialog input[type="text"], - .dg-dialog input[type="number"], - .dg-dialog input[type="password"], - .dg-dialog select { - background-color: rgba(0, 0, 0, 0.05); - border: 1px solid rgb(122, 121, 113); - border-radius: 3px; - color: inherit; - } - .dg-dialog__field-label, .dg-dialog .dg-dialog__heading { font-weight: bold; diff --git a/system.json b/system.json index d2622b5..1383701 100644 --- a/system.json +++ b/system.json @@ -28,7 +28,7 @@ } }, "esmodules": ["module/deltagreen.js"], - "styles": ["css/deltagreen.css", "css/dg-dialog.css", "css/dialogue.css"], + "styles": ["css/deltagreen.css", "css/dg-dialog.css", "css/damage-modify.css"], "languages": [ { "lang": "en", diff --git a/templates/actor/partials/agent-skill-row-partial.html b/templates/actor/partials/agent-skill-row-partial.html new file mode 100644 index 0000000..16ba829 --- /dev/null +++ b/templates/actor/partials/agent-skill-row-partial.html @@ -0,0 +1,81 @@ +{{#if (eq skill.skillKind "typed") }} +
+
+ +
+
+ + +
+ +
+
+
+{{else}} +
+ + + + +
+{{/if}} diff --git a/templates/actor/partials/agent-special-training-row-partial.html b/templates/actor/partials/agent-special-training-row-partial.html new file mode 100644 index 0000000..0e053c0 --- /dev/null +++ b/templates/actor/partials/agent-special-training-row-partial.html @@ -0,0 +1,32 @@ +
+
+ +
+ + +
+
+
diff --git a/templates/actor/partials/custom-skills-partial-agent.html b/templates/actor/partials/custom-skills-partial-agent.html index 7d1f949..c4f5ceb 100644 --- a/templates/actor/partials/custom-skills-partial-agent.html +++ b/templates/actor/partials/custom-skills-partial-agent.html @@ -1,140 +1,15 @@ -
- - - {{localize 'DG.Skills.AddTypedOrCustomSkill'}} - +{{#if showSpecialTrainingSection }} +
+ {{localize "DG.Sheet.BlockHeaders.SpecialTraining"}} +
- - - {{localize 'DG.SpecialTraining.Dialog.CreateSpecialTraining'}} - - - {{#if_eq actor.type "agent" }} - - {{localize 'DG.Skills.ApplySkillImprovements.Title'}} - - - {{/if_eq}} - - - - -
- -
- {{#each typedSkillColumns as |column| }} -
- {{#each column as |block| }} - {{#if_eq block.type "groupHeader" }} -
- {{block.label}} -
- {{/if_eq}} - - {{#if_eq block.type "typedSkill" }} -
-
- -
-
- - {{#if_eq block.actorType "agent" }} - - {{/if_eq}} -
- -
-
-
- {{/if_eq}} - - {{#if_eq block.type "training" }} -
-
- -
- - -
-
-
- {{/if_eq}} - {{/each}} -
- {{/each}} -
+
+ {{#each specialTrainingColumns as |column| }} +
+ {{#each column as |block| }} + {{> "systems/deltagreen/templates/actor/partials/agent-special-training-row-partial.html" block=block }} + {{/each}} +
+ {{/each}} +
+{{/if}} diff --git a/templates/actor/partials/left-bar-sanity-tab.html b/templates/actor/partials/left-bar-sanity-tab.html index a977eea..c7cf42d 100644 --- a/templates/actor/partials/left-bar-sanity-tab.html +++ b/templates/actor/partials/left-bar-sanity-tab.html @@ -1,7 +1,7 @@
-
+
-
- {{localize 'DG.Sheet.BlockHeaders.Adaptations'}} -
+ + +
{{localize 'DG.Sheet.BlockHeaders.Adaptations'}}