Merge pull request #3 from deltagreen-foundryvtt/sobrandm/xxx/extravaganza
Sobrandm/xxx/extravaganza
This commit is contained in:
@@ -29,6 +29,17 @@ jobs:
|
||||
- name: Check Prettier formatting
|
||||
run: npm run format:check
|
||||
|
||||
sass:
|
||||
name: Compile Sass
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Node Setup
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Compile Sass
|
||||
run: npm run build:css
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
@@ -54,6 +54,12 @@ jobs:
|
||||
- name: Build Packs
|
||||
uses: ./.github/actions/build-packs
|
||||
|
||||
- name: Node Setup
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build CSS
|
||||
run: npm run build:css
|
||||
|
||||
- name: Substitute Download Link
|
||||
uses: TomaszKandula/variable-substitution@v1.0.1
|
||||
with:
|
||||
|
||||
@@ -24,6 +24,12 @@ jobs:
|
||||
- name: Build Packs
|
||||
uses: ./.github/actions/build-packs
|
||||
|
||||
- name: Node Setup
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build CSS
|
||||
run: npm run build:css
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
uses: dhkatz/get-version-action@v3.0.0
|
||||
|
||||
@@ -15,3 +15,7 @@ deltagreen.lock
|
||||
|
||||
# Ignore npm modules
|
||||
node_modules
|
||||
|
||||
# Sass build output (source lives in scss/)
|
||||
css/*.css
|
||||
css/*.css.map
|
||||
|
||||
@@ -13,3 +13,7 @@ packs/**
|
||||
# Ignore html/handlebars files as a different formatter is used.
|
||||
*.html
|
||||
*.hbs
|
||||
|
||||
# Generated from scss/ via npm run build:css
|
||||
css/*.css
|
||||
css/*.css.map
|
||||
|
||||
Vendored
+2
-1
@@ -5,7 +5,8 @@
|
||||
"esbenp.prettier-vscode",
|
||||
"monosans.djlint",
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
"EditorConfig.EditorConfig"
|
||||
"EditorConfig.EditorConfig",
|
||||
"syler.sass-indented"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
@@ -11,7 +11,24 @@
|
||||
- [#211](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/211) - Move macros to System macros folder in compendium.
|
||||
- [#283](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/283) - Added new actor sheet with left hand Sidebar RFC
|
||||
- [#291](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/291) - Moving Luck button from three dots to left hand panel
|
||||
- [#297](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/297) - Move adaption ticks to left hand panel
|
||||
- [#374](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/374) - Moving Age to Bio tab
|
||||
- [#393](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/393) - Updated to support Foundry v14
|
||||
- [#223](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/223) - Migrated item sheets to Foundry ApplicationV2 (`ItemSheetV2`)
|
||||
- [#123](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/123) - Added Active Effect support
|
||||
- [#346](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/346) - Maximum resources modifiable through AE
|
||||
- [#365](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/365) - Added buttons to trigger exhaustion, rest, and use stimulants
|
||||
- [#209](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/209) - Added **Profession** item type with partial chargen automation
|
||||
|
||||
### **Bug Fixes:**
|
||||
|
||||
- [#392](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/392) - Fixed sheet rolls
|
||||
- [#394](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/394) - Unified actor and item description fields on schema with shared ProseMirror preparation
|
||||
|
||||
### **Under the Hood:**
|
||||
|
||||
- [#323](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/323) - Refactored roll logic and split into util functions
|
||||
- [#233](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/233) - Refactored CSS to Sass
|
||||
|
||||
## Version 1.6.6 - 2026-04-15
|
||||
|
||||
|
||||
+28
-6
@@ -12,7 +12,7 @@
|
||||
|
||||
### Formatters
|
||||
|
||||
- JavaScript, JSON, CSS: [Prettier](https://prettier.io/docs/install)
|
||||
- JavaScript, JSON, SCSS: [Prettier](https://prettier.io/docs/install)
|
||||
- Handlebars: [DJLint](https://djlint.com/docs/getting-started/)
|
||||
|
||||
### Linters
|
||||
@@ -20,12 +20,13 @@
|
||||
- JavaScript: [Eslint](https://eslint.org/docs/latest/use/getting-started)
|
||||
- Markdown: [Markdownlint](https://github.com/markdownlint/markdownlint)
|
||||
- Handlebars: DJLint (see above)
|
||||
- CSS: VSCode Built-in
|
||||
- SCSS: VSCode Built-in
|
||||
|
||||
### Misc
|
||||
|
||||
- [EditorConfig](https://editorconfig.org/) - Used to standardize file configurations like tabs vs. spaces, end-of-line characters, etc.
|
||||
- [FVTT CLI](https://github.com/foundryvtt/foundryvtt-cli) - Used to package document JSON files into LevelDB packs, and vice-versa. In simpler terms, this lets us convert documents in compendia into JSON files, OR turn those JSON files back into documents in the compendia.
|
||||
- [Sass](https://sass-lang.com/) (via npm) - Compiles `scss/` source into the `css/` files Foundry loads at runtime.
|
||||
|
||||
### VSCode Extensions
|
||||
|
||||
@@ -81,7 +82,25 @@ npm -v
|
||||
|
||||
2. Install `djlint` (doesn't come through NPM). This requires `Python` and `pip`; instructions for installing these are specific to your operating system, and beyond the scope of this guide.
|
||||
|
||||
### 4. Set Up Your Code Editor
|
||||
### 4. Compile Stylesheets
|
||||
|
||||
Styles live in [`scss/`](scss/). Foundry loads compiled output from [`css/`](css/), which is **not** committed to git — you must build it locally.
|
||||
|
||||
1. Run a one-off compile (required after a fresh clone, or before testing if you are not using watch mode):
|
||||
|
||||
```bash
|
||||
npm run build:css
|
||||
```
|
||||
|
||||
2. While actively editing styles, run Sass in watch mode in a **separate terminal** (leave it running):
|
||||
|
||||
```bash
|
||||
npm run watch:css
|
||||
```
|
||||
|
||||
Edit files under `scss/`; output is written to `css/`. Foundry's hot reload (configured in `system.json`) picks up changes to the compiled CSS when watch mode is running.
|
||||
|
||||
### 5. Set Up Your Code Editor
|
||||
|
||||
We recommend [Visual Studio Code](https://code.visualstudio.com/) for working on this project, but you're welcome to use any editor. The following extensions are **auto-recommended** by VSCode, but here they are again for special instructions, manual installation, or reference.
|
||||
|
||||
@@ -106,7 +125,7 @@ Once these extensions are installed, your editor should automatically highlight
|
||||
|
||||
Your pull request will automatically be checked for linting and formatting via GitHub Actions. If your changes don't pass, they can't be merged (see [Branching and Pull Requests](#branching-commits-and-pull-requests)).
|
||||
|
||||
### 5. Set Up a Local Foundry VTT Test Instance
|
||||
### 6. Set Up a Local Foundry VTT Test Instance
|
||||
|
||||
1. We **highly** recommend setting up a separate instance of Foundry from your main installation. This way you are not at risk of breaking things in live worlds.
|
||||
|
||||
@@ -136,7 +155,7 @@ Your pull request will automatically be checked for linting and formatting via G
|
||||
mklink /D "%localappdata%/FoundryVTT/Data/systems/deltagreen" "%cd%/delta-green-foundry-vtt-system"
|
||||
```
|
||||
|
||||
### 6. Start Developing
|
||||
### 7. Start Developing
|
||||
|
||||
- Use the developer console (`F12` in most browsers) to inspect logs and debug issues.
|
||||
- Make changes to code and refresh the browser to see them take effect.
|
||||
@@ -448,6 +467,8 @@ Make sure your code is clean and ready:
|
||||
- ✅ You’ve manually tested your change in a local Foundry world.
|
||||
- ✅ `npm run lint` shows no errors
|
||||
- ✅ `npm run format:check` passes
|
||||
- ✅ `npm run build:css` succeeds (required if you changed anything under `scss/`)
|
||||
- ✅ If you changed styles, you tested with `npm run watch:css` running and confirmed the UI looks correct
|
||||
- ✅ Your editor doesn’t show linting/formatting warnings.
|
||||
|
||||
We use a GitHub Action to enforce code quality. If your code doesn’t meet the linting and formatting standards, your PR cannot be merged until those checks pass.
|
||||
@@ -467,8 +488,9 @@ It is difficult to create a one-size-fits-all procedure for testing, because eac
|
||||
|
||||
1. Prepare your environment
|
||||
|
||||
- **Styles:** On a fresh clone, run `npm run build:css` (or keep `npm run watch:css` running) so Foundry has compiled CSS to load.
|
||||
- Test in a safe environment (**HIGHLY RECOMMENDED**):
|
||||
- We highly recommend [setting up an installation of Foundry separate from your main instance](#5-set-up-a-local-foundry-vtt-test-instance).
|
||||
- We highly recommend [setting up an installation of Foundry separate from your main instance](#6-set-up-a-local-foundry-vtt-test-instance).
|
||||
- This way, you are never at risk of breaking anything in your main worlds.
|
||||
- If you do not follow the above advice, be aware that opening your main world while you have an in-progress (non-release) version of the system installed **will almost certainly break things in a way that cannot be fixed**. To go back to the release version of the system, uninstall the test build, and reinstall from the in-Foundry system browser.
|
||||
- Update to the target build:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
-1470
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "SpecialElite";
|
||||
src: url("../assets/font/special_elite/SpecialElite-Regular.ttf")
|
||||
format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */
|
||||
}
|
||||
|
||||
.special-elite-font {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.tox .tox-editor-container {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.tox .tox-edit-area {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.editor-host .editor {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.editor-host .tox {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.editor-host .tox-tinymce {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
|
||||
.editor-host .tox .tox-edit-area {
|
||||
font-family: "SpecialElite", sans-serif !important;
|
||||
}
|
||||
+148
-75
@@ -1,11 +1,8 @@
|
||||
{
|
||||
"DG.SystemName": "Delta Green",
|
||||
"DG.AgentNameFieldTooltip": "LAST NAME, FIRST NAME (AND ALIAS IF APPLICABLE)",
|
||||
"DG.RollLuck": "Roll Luck",
|
||||
"DG.Luck": "Luck",
|
||||
|
||||
"DG.FallbackText.newItem": "New {type}",
|
||||
|
||||
"DG.Skills.accounting": "Accounting",
|
||||
"DG.Skills.alertness": "Alertness",
|
||||
"DG.Skills.anthropology": "Anthropology",
|
||||
@@ -44,7 +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.",
|
||||
@@ -82,8 +80,9 @@
|
||||
"DG.Skills.Tooltip.swim": "Survive in water under duress—rescue others, escape, or endure hazards.",
|
||||
"DG.Skills.Tooltip.unarmed_combat": "Subdue or injure opponents using fists, grapples, kicks, and other hand-to-hand techniques.",
|
||||
"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",
|
||||
@@ -95,7 +94,6 @@
|
||||
"DG.Skills.EditSkill": "Edit Skill",
|
||||
"DG.Skills.SkillGroup": "Skill Group",
|
||||
"DG.Skills.SkillName": "Skill Name",
|
||||
|
||||
"DG.TypeSkills.Art": "Art",
|
||||
"DG.TypeSkills.Craft": "Craft",
|
||||
"DG.TypeSkills.ForeignLanguage": "Foreign Language",
|
||||
@@ -103,7 +101,6 @@
|
||||
"DG.TypeSkills.Pilot": "Pilot",
|
||||
"DG.TypeSkills.Science": "Science",
|
||||
"DG.TypeSkills.Other": "Other",
|
||||
|
||||
"DG.TypeSkills.Tooltip.Art": "Create or appraise artistic works, and spot forgeries in a given field.",
|
||||
"DG.TypeSkills.Tooltip.Craft": "Build, fix, or sabotage specialized tools or systems like engines, locks, or wiring.",
|
||||
"DG.TypeSkills.Tooltip.ForeignLanguage": "Speak, read, and understand a specific language.",
|
||||
@@ -111,7 +108,6 @@
|
||||
"DG.TypeSkills.Tooltip.Pilot": "Control and navigate air, sea, or space vehicles in critical situations or conditions.",
|
||||
"DG.TypeSkills.Tooltip.Science": "Apply specialized scientific knowledge to interpret or investigate the physical world.",
|
||||
"DG.TypeSkills.Tooltip.Other": "Other",
|
||||
|
||||
"DG.SpecialTraining.Dialog.CreateSpecialTraining": "Add Special Training",
|
||||
"DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Custom/Typed Skills",
|
||||
"DG.SpecialTraining.Dialog.DropDown.Skills": "Skills",
|
||||
@@ -120,7 +116,6 @@
|
||||
"DG.SpecialTraining.Dialog.NameLabel": "Name",
|
||||
"DG.SpecialTraining.Dialog.SkillLabel": "Skill or Stat",
|
||||
"DG.SpecialTraining.Dialog.Title": "Create/Edit Special Training",
|
||||
|
||||
"DG.Attributes.HP": "HP",
|
||||
"DG.Attributes.WP": "WP",
|
||||
"DG.Attributes.SAN": "SAN",
|
||||
@@ -130,7 +125,6 @@
|
||||
"DG.Attributes.int": "INT",
|
||||
"DG.Attributes.pow": "POW",
|
||||
"DG.Attributes.cha": "CHA",
|
||||
|
||||
"DG.Attributes.Tooltip.HP": "Hit Points (HP) indicate physical health, a character becomes unconsious at 2 HP, and dies at 0 HP.",
|
||||
"DG.Attributes.Tooltip.WP": "Willpower Points (WP) indicate mental fortitude and drive. Agents require Willpower Points to keep going despite exhaustion or mentally taxing situations. See Page 47 of the Agent's Handbook for more detail.",
|
||||
"DG.Attributes.Tooltip.SAN": "Sanity Points (SAN) indicate a connection with humanity and reality as most people perceive it. Click to roll a SAN test",
|
||||
@@ -140,18 +134,22 @@
|
||||
"DG.Attributes.Tooltip.int": "See Page 19 of the Agent's Handbook - Intelligence",
|
||||
"DG.Attributes.Tooltip.pow": "See Page 19 of the Agent's Handbook - Power",
|
||||
"DG.Attributes.Tooltip.cha": "See Page 19 of the Agent's Handbook - Charisma",
|
||||
|
||||
"DG.Navigation.Agent.skills": "Skills",
|
||||
"DG.Navigation.Agent.combat": "Combat",
|
||||
"DG.Navigation.Agent.physical": "Statistics",
|
||||
"DG.Navigation.Agent.motivations": "Psychological",
|
||||
"DG.Navigation.Agent.gear": "Equipment",
|
||||
"DG.Navigation.Agent.personal": "Personal",
|
||||
"DG.Navigation.Agent.effects": "Effects",
|
||||
"DG.Navigation.Agent.LeftBar.rest": "Rest",
|
||||
"DG.Navigation.Agent.LeftBar.sanity": "Sanity",
|
||||
"DG.Navigation.Item.effects": "Effects",
|
||||
"DG.Navigation.Agent.bio": "Bio",
|
||||
"DG.Navigation.Agent.bonds": "Bonds",
|
||||
"DG.Navigation.NPC.skills": "Skills & Training",
|
||||
"DG.Navigation.NPC.gear": "Attacks & Abilities",
|
||||
"DG.Navigation.Vehicle.gear": "Gear",
|
||||
"DG.Navigation.Vehicle.notes": "Notes",
|
||||
|
||||
"DG.About.Changelog": "CHANGELOG (ONLINE)",
|
||||
"DG.About.Copyright": "Published by Arc Dream Publishing by arrangement with the Delta Green Partnership. The intellectual property known as Delta Green is a trademark and copyright owned by the Delta Green Partnership, who has licensed its use here.",
|
||||
"DG.About.License": "The contents of this system are licensed per the terms of the included <a href=\"systems/deltagreen/LICENSE.txt\">license</a> file, excepting those elements that are components of the Delta Green intellectual property.",
|
||||
@@ -159,8 +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.Roll.Rolling": "Rolling",
|
||||
"DG.About.ChangelogTooltip": "Launch browser with the online changelog.",
|
||||
"DG.Roll.Target": "Target: ",
|
||||
"DG.Roll.Success": "Success",
|
||||
"DG.Roll.Failure": "Failure",
|
||||
@@ -170,21 +167,59 @@
|
||||
"DG.Roll.Lethal": "Lethal",
|
||||
"DG.Roll.For": "For",
|
||||
"DG.Roll.Damage": "Damage",
|
||||
"DG.Roll.RollingDamageFor": "Rolling DAMAGE for",
|
||||
"DG.Roll.Or": "Or",
|
||||
"DG.Roll.Roll": "Roll",
|
||||
|
||||
"DG.Physical.Wounds": "Wounds",
|
||||
"DG.Physical.FirstAidLabel": "First Aid Has Already Been Attempted: ",
|
||||
"DG.Physical.Exhausted": "Exhausted:",
|
||||
"DG.Physical.ExhaustionPenalty": "Penalty:",
|
||||
|
||||
"DG.Physical.Exhaust": "Trigger Exhaustion",
|
||||
"DG.Physical.Rest": "Rest",
|
||||
"DG.Physical.Stimulants": "Stimulants",
|
||||
"DG.Physical.SuppressExhaustion": "Exhaustion Suppressed",
|
||||
"DG.Physical.ExhaustionEffectName": "Exhaustion",
|
||||
"DG.Physical.StimulantsEffectName": "Stimulants",
|
||||
"DG.Physical.ExhaustDialogTitle": "Exhaust Agent",
|
||||
"DG.Physical.RollWillpowerLoss": "Roll Willpower Loss",
|
||||
"DG.Physical.StimulantsDialogTitle": "Take Stimulants",
|
||||
"DG.Physical.StimulantsRegular": "Regular Stimulants",
|
||||
"DG.Physical.StimulantsRegularExplanation": "Chain-smoking, coffee, or other mild stimulants suppress exhaustion for 1D6 hours.",
|
||||
"DG.Physical.StimulantsHard": "Hard Drugs",
|
||||
"DG.Physical.StimulantsHardExplanation": "Hard drugs, while illegal, suppress exhaustion for 2D6 hours.",
|
||||
"DG.Physical.Tooltip.SuppressExhaustion": "While checked, exhaustion penalties do not apply to rolls.",
|
||||
"DG.Physical.StimulantsNotExhaustedWarning": "{name} is not exhausted. Taking stimulants would have no effect!",
|
||||
"DG.Physical.Chat.Exhausted": "<b>{name}</b> is exhausted. They lose <strong>{loss} Willpower</strong>.<br /><br />New total: <strong>{current}/{max}</strong>",
|
||||
"DG.Physical.Chat.Rested": "<b>{name}</b> gets a full night's rest and regains <strong>{gain} Willpower</strong>.<br /><br />New total: <strong>{current}/{max}</strong>",
|
||||
"DG.Physical.Chat.RestedNoLongerExhausted": "<br /><strong>{name}</strong> is no longer exhausted.",
|
||||
"DG.Physical.Chat.StimulantsRegular": "<b>{name}</b> takes regular stimulants, suppressing their exhaustion for {hours} hours. They cannot sleep during this time.",
|
||||
"DG.Physical.Chat.StimulantsHard": "<b>{name}</b> takes hard drugs, suppressing their exhaustion for {hours} hours. They cannot sleep during this time.",
|
||||
"DG.Generic.AddButtonLabel": "Add",
|
||||
|
||||
"DG.ActiveEffects.SheetLabel": "Delta Green Active Effect",
|
||||
"DG.ActiveEffects.DefaultName": "Active Effect",
|
||||
"DG.ActiveEffects.Add": "Add Active Effect",
|
||||
"DG.ActiveEffects.Temporary": "Temporary Effects",
|
||||
"DG.ActiveEffects.Permanent": "Permanent Effects",
|
||||
"DG.ActiveEffects.Source": "Source",
|
||||
"DG.ActiveEffects.None": "None",
|
||||
"DG.ActiveEffects.Toggle": "Toggle Active Effect",
|
||||
"DG.ActiveEffects.Edit": "Edit Active Effect",
|
||||
"DG.ActiveEffects.Delete": "Delete Active Effect",
|
||||
"DG.ActiveEffects.SelectAttribute": "Select attribute…",
|
||||
"DG.ActiveEffects.TransferWhenEquipped": "Transferred effects apply to the agent only while this item is equipped.",
|
||||
"DG.ActiveEffects.TransferWhenAcuteEpisode": "Transferred effects apply to the agent only during an acute episode.",
|
||||
"DG.ActiveEffects.SuppressedNotEquipped": "Inactive — item is not equipped",
|
||||
"DG.ActiveEffects.SuppressedAcuteEpisode": "Inactive — acute episode is off",
|
||||
"DG.ActiveEffects.SuppressedUnsupportedItem": "Inactive — not supported for this item type",
|
||||
"DG.ActiveEffects.Groups.Statistics": "Statistics",
|
||||
"DG.ActiveEffects.Groups.Resources": "Resources",
|
||||
"DG.ActiveEffects.Groups.RollTargets": "Roll Targets",
|
||||
"DG.ActiveEffects.Fields.AllSkills": "All Skills",
|
||||
"DG.ActiveEffects.Fields.SanityRolls": "Sanity Rolls",
|
||||
"DG.ActiveEffects.Fields.StatisticRolls": "Statistic Rolls",
|
||||
"DG.ActiveEffects.Fields.MaxHP": "Maximum HP",
|
||||
"DG.ActiveEffects.Fields.MaxWP": "Maximum Willpower",
|
||||
"DG.ActiveEffects.Fields.MaxSanity": "Maximum Sanity",
|
||||
"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.",
|
||||
@@ -195,7 +230,7 @@
|
||||
"DG.Mental.AdaptedToHelplessness": "Adapted to Helplessness",
|
||||
"DG.Mental.Motivations": "Motivation",
|
||||
"DG.Mental.Disorders": "Disorder",
|
||||
|
||||
"DG.Gear.SelectCompendium": "Select Compendium",
|
||||
"DG.Gear.WeaponTypes.Firearms": "Firearms",
|
||||
"DG.Gear.WeaponTypes.Melee": "Melee",
|
||||
"DG.Gear.Weapons": "WEAPONS",
|
||||
@@ -206,7 +241,6 @@
|
||||
"DG.Gear.ArmorPiercing": "Armor Piercing",
|
||||
"DG.Gear.ArmorRating": "Armor Rating",
|
||||
"DG.Gear.Equipped": "Equipped",
|
||||
|
||||
"DG.CV.Sex": "Sex",
|
||||
"DG.CV.SexPlaceholder": "Sex",
|
||||
"DG.CV.AgeAndDoB": "Age and D.O.B.",
|
||||
@@ -217,12 +251,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 ",
|
||||
@@ -233,23 +263,22 @@
|
||||
"DG.Tooltip.SkillFailCheckbox": "After failing a test on this skill (if proficiency is at least 1%), check this box. At the end of the session, add 1D4 percentage points to the skill and uncheck the box. Does not apply to the Unnatural or Ritual skill. See Page 29 of the Agent's Handbook.",
|
||||
"DG.Tooltip.DistinguishingFeature": "Distinguishing Feature (fill out if score is 13 or higher, or 8 or lower).",
|
||||
"DG.Tooltip.x5AttributeLabel": "Click to roll a x5 check.",
|
||||
"DG.Tooltip.RollTarget.Target": "Target: {value}%",
|
||||
"DG.Tooltip.RollTarget.BaseTarget": "Base Target",
|
||||
"DG.Tooltip.RollTarget.FinalTarget": "Final Target",
|
||||
"DG.Tooltip.FirstAidAttempted": "Once an agent receives first aid, they cannot benefit from it again until they receive further damage from a different source.",
|
||||
"DG.Tooltip.AdaptedToViolence": "Being adapted to violence means your Agent’s empathy suffers as they come to view violence as nothing but another tool to be wielded. Agent permanently loses 1D6 CHA and the same amount from each of their Bonds.",
|
||||
"DG.Tooltip.AdaptedToHelplessness": "Being adapted to helplessness means your Agent’s personal drive suffers as they pull back from the world around them. As soon as your Agent becomes adapted to helplessness, agent permanently loses 1D6 POW.",
|
||||
"DG.Tooltip.ToggleEquippedState": "Click to toggle whether this item is equipped or not.",
|
||||
"DG.Tooltip.Exhaustion": "An Agent who works too long or faces extreme danger and injury without resting becomes exhausted. When this comes into play is up to the Handler, but a good rule of thumb is that going a night without sleep or refusing to rest after losing SAN or Hit Points leads to exhaustion.",
|
||||
|
||||
"DG.Gear.Expense.NA": "N/A or None",
|
||||
"DG.Gear.Expense.Incidental": "Incidental",
|
||||
"DG.Gear.Expense.Standard": "Standard",
|
||||
"DG.Gear.Expense.Unusual": "Unusual",
|
||||
"DG.Gear.Expense.Major": "Major",
|
||||
"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",
|
||||
@@ -259,19 +288,17 @@
|
||||
"DG.ItemWindow.Motivations.IsDisorderTooltip": "Check this if adding a disorder, or if an existing motivation has been lost due to gaining a disorder.",
|
||||
"DG.ItemWindow.Motivations.DisorderHasBeenCured": "Disorder Has Been \"Cured\"",
|
||||
"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.Gear.Attributes": "Attributes",
|
||||
"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.Description": "Description",
|
||||
"DG.ItemWindow.Gear.NamePlaceholder": "Name",
|
||||
"DG.ItemWindow.Gear.Expense": "Expense",
|
||||
"DG.ItemWindow.Gear.Equipped": "Equipped",
|
||||
|
||||
"DG.ItemWindow.Armor.Description": "Description",
|
||||
"DG.ItemWindow.Armor.NamePlaceholder": "Name",
|
||||
"DG.ItemWindow.Armor.ArmorRating": "Armor Rating",
|
||||
"DG.ItemWindow.Armor.Expense": "Expense",
|
||||
"DG.ItemWindow.Armor.Equipped": "Equipped",
|
||||
|
||||
"DG.ItemWindow.Weapons.Description": "Description",
|
||||
"DG.ItemWindow.Weapons.NamePlaceholder": "Name",
|
||||
"DG.ItemWindow.Weapons.Skill": "Skill",
|
||||
@@ -282,82 +309,69 @@
|
||||
"DG.ItemWindow.Weapons.LethalityTooltip": "A lethal weapon has a chance to instantly kill a target. Roll a D100 instead of damage die. If it is not lethal, damage is dealt instead as if the result was a 2d10 roll, with a '00' on the first d10 equaling a 10.",
|
||||
"DG.ItemWindow.Weapons.IsLethalityTooltip": "Check this if the weapon has a lethality % associated with it.",
|
||||
"DG.ItemWindow.Weapons.ArmorPiercing": "Armor Piercing",
|
||||
"DG.ItemWindow.Weapons.Ammo": "Ammo (Amount/Type)",
|
||||
"DG.ItemWindow.Weapons.Ammo": "Ammo",
|
||||
"DG.ItemWindow.Weapons.Range": "Range",
|
||||
"DG.ItemWindow.Weapons.KillRadius": "Kill Radius",
|
||||
|
||||
"DG.ModifySkillRollDialogue.Title": "Modify Roll",
|
||||
"DG.ModifySkillRollDialogue.Test": "Test:",
|
||||
"DG.ModifySkillRollDialogue.OriginalTarget": "Original Target:",
|
||||
"DG.ModifySkillRollDialogue.TargetModifier": "Modify Target:",
|
||||
"DG.ModifySkillRollDialogue.ModifierTooltip": "Give a positive or negative modifier here to add/subtract from the target for the skill check.",
|
||||
|
||||
"DG.ModifySkillRollDialogue.ModifierTooltip": "Enter a positive value to increase the target, or a negative value to decrease it.",
|
||||
"DG.ModifySkillRollDialogue.QuickModifiersHeader": "Quick Modifiers",
|
||||
"DG.ModifySkillRollDialogue.QuickModifiersHint": "Click one of the following to apply a preset adjustment and roll immediately.",
|
||||
"DG.ModifySkillRollDialogue.ModifierMuchHarder": "Makes the roll much harder",
|
||||
"DG.ModifySkillRollDialogue.ModifierHarder": "Makes the roll harder",
|
||||
"DG.ModifySkillRollDialogue.ModifierEasier": "Makes the roll easier",
|
||||
"DG.ModifySkillRollDialogue.ModifierMuchEasier": "Makes the roll much easier",
|
||||
"DG.ModifySkillRollDialogue.CustomModifierHeader": "Custom Modifier",
|
||||
"DG.ModifySkillRollDialogue.CustomModifierHint": "For custom adjustments to the roll target, enter a positive or negative value and click the Roll button.",
|
||||
"DG.ModifySkillRollDialogue.InvalidModifier": "Enter a valid modifier to roll.",
|
||||
"DG.ModifyDamageRollDialogue.Weapon": "Weapon:",
|
||||
"DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Use this field to modify the outer part of the formula, such as to double the result.",
|
||||
"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",
|
||||
"DG.Sheet.BlockHeaders.ScenarioInformation": "SCENARIO INFORMATION",
|
||||
"DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "BONDS / RELATIONSHIPS / CONTACTS",
|
||||
|
||||
"DG.AgentSheet.ProfessionPlaceholder": "PROFESSION (RANK IF APPLICABLE)",
|
||||
|
||||
"DG.AgentSheet.ProfessionPlaceholder": "PROFESSION (RANK)",
|
||||
"DG.AgentSheet.RenameProfession.Title": "Rename Profession",
|
||||
"DG.AgentSheet.RenameProfession.Tooltip": "Click to rename 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",
|
||||
"DG.ImpossibleLanscapes.CorruptionFirstHalf": "Corrup",
|
||||
"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",
|
||||
|
||||
"DG.Generic.SanDamage": "SAN Damage",
|
||||
|
||||
"DG.ItemWindow.Weapons.CustomSkillTarget": "Target",
|
||||
"DG.ItemWindow.Weapons.SkillModifier": "Skill Modifier",
|
||||
"DG.ItemWindow.Custom": "Custom",
|
||||
|
||||
"DG.Sheet.class.item": "Item Sheet",
|
||||
"DG.Sheet.class.agent": "Agent Sheet",
|
||||
"DG.Sheet.class.npc": "NPC Sheet",
|
||||
"DG.Sheet.class.unnatural": "Unnatural Sheet",
|
||||
"DG.Sheet.class.vehicle": "Vehicle Sheet",
|
||||
|
||||
"TYPES.Actor.agent": "Agent",
|
||||
"TYPES.Actor.npc": "NPC",
|
||||
"TYPES.Actor.unnatural": "Unnatural",
|
||||
"TYPES.Actor.vehicle": "Vehicle",
|
||||
|
||||
"TYPES.Item.armor": "Armor",
|
||||
"TYPES.Item.bond": "Bond",
|
||||
"TYPES.Item.gear": "Gear",
|
||||
@@ -365,16 +379,71 @@
|
||||
"TYPES.Item.weapon": "Weapon",
|
||||
"TYPES.Item.tome": "Tome",
|
||||
"TYPES.Item.ritual": "Ritual",
|
||||
|
||||
"TYPES.Item.profession": "Profession",
|
||||
"DG.Profession.AlreadyAssigned": "This agent already has a profession. Another cannot be assigned.",
|
||||
"DG.Profession.Dialog.Title": "Character Creation",
|
||||
"DG.Profession.Dialog.Tab.SkillOptions": "Skill Options",
|
||||
"DG.Profession.Dialog.Tab.BonusSkills": "Bonus Skills",
|
||||
"DG.Profession.Dialog.Tab.Bonds": "Bonds",
|
||||
"DG.Profession.Dialog.Skill": "Skill",
|
||||
"DG.Profession.Dialog.Value": "Value",
|
||||
"DG.Profession.Dialog.SkillTotalsHeader": "Skill Totals",
|
||||
"DG.Profession.Dialog.SkillTotalsHint": "Skills in green have been increased by your profession. Hover over a skill to see what it does or consult the Agent's Handbook for the full description.",
|
||||
"DG.Profession.Dialog.SkillsOptionsHeader": "Skills Options ({picks} picks)",
|
||||
"DG.Profession.Dialog.BonusSkillsIntro": "Select skills from the dropdowns to add 20% to their total. A skill may be selected multiple times. Unnatural cannot be selected, nor can any skill be increased above 80% at character creation.",
|
||||
"DG.Profession.Dialog.BonusSkillWasteTooHigh": "{skill} cannot be increased above 80. Select a different bonus skill.",
|
||||
"DG.Profession.Dialog.BondsIntro": "Bonds are the important relationships in your agent's life, such as family, friends, and other loved ones. They can provide a buffer against SAN loss, at the cost of that relationship. Initial relationship score is equal to CHA.",
|
||||
"DG.Profession.Dialog.BondFieldLabel": "Bond{number}",
|
||||
"DG.Profession.Dialog.SelectSkill": "Select a skill…",
|
||||
"DG.Profession.Dialog.SkillTypePlaceholder": "Type or specialization",
|
||||
"DG.Profession.Dialog.Submit": "Submit",
|
||||
"DG.Profession.Dialog.CapWarning": "{skill} is being raised to {attempted} but is capped at 80 during agent creation, wasting {waste} skill points.",
|
||||
"DG.Profession.Dialog.DuplicateTypedName": "Duplicate {type} ({name}) detected. Please choose unique names.",
|
||||
"DG.Profession.ChooseOnePlaceholder": "choose one",
|
||||
"DG.Profession.Dialog.ChooseOneNamesHeader": "Name Skill Specializations",
|
||||
"DG.Profession.Dialog.ChooseOneNameLabel": "{group} — specialization",
|
||||
"DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics",
|
||||
"DG.ProfessionSetup.PickStatistics.Intro": "Choose how to set this agent's statistics before picking skills.",
|
||||
"DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats",
|
||||
"DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.",
|
||||
"DG.ProfessionSetup.PickStatistics.AssignStats": "Assign Stats",
|
||||
"DG.ProfessionSetup.PickStatistics.AssignStatsTooltip": "Divide 72 points between stats as desired.",
|
||||
"DG.ProfessionSetup.PickStatistics.LeaveStatsAlone": "Leave my stats alone!",
|
||||
"DG.ProfessionSetup.RollStats.Title": "Roll Stats",
|
||||
"DG.ProfessionSetup.RollStats.PoolHeader": "Drag each rolled value onto a stat.",
|
||||
"DG.ProfessionSetup.RollStats.Submit": "Submit",
|
||||
"DG.ProfessionSetup.RollStats.ChatLabel": "Stat Rolls (4d6, drop lowest)",
|
||||
"DG.ProfessionSetup.AssignStats.Title": "Assign Stats",
|
||||
"DG.ProfessionSetup.AssignStats.PointsRemaining": "Points remaining: {remaining}",
|
||||
"DG.ProfessionSetup.AssignStats.Submit": "Submit",
|
||||
"DG.ItemWindow.Profession.Description": "Description",
|
||||
"DG.ItemWindow.Profession.SkillsTab": "Skills",
|
||||
"DG.ItemWindow.Profession.Name": "Profession Name",
|
||||
"DG.ItemWindow.Profession.Bonds": "Bonds",
|
||||
"DG.ItemWindow.Profession.BondsTooltip": "Number of bond slots granted when this profession is assigned to an agent (1 – 5).",
|
||||
"DG.ItemWindow.Profession.AutomaticSkills": "Automatic Skills",
|
||||
"DG.ItemWindow.Profession.OptionSkills": "Skill Options",
|
||||
"DG.ItemWindow.Profession.OptionPicks": "Option Picks",
|
||||
"DG.ItemWindow.Profession.AddSkill": "Add Skill",
|
||||
"DG.ItemWindow.Profession.AddSkillTitle": "Add Skill to Profession",
|
||||
"DG.ItemWindow.Profession.RemoveSkill": "Remove Skill",
|
||||
"DG.ItemWindow.Profession.Skill": "Skill",
|
||||
"DG.ItemWindow.Profession.SelectSkill": "Select a skill…",
|
||||
"DG.ItemWindow.Profession.Rating": "Rating (%)",
|
||||
"DG.ItemWindow.Profession.SkillType": "Skill Type",
|
||||
"DG.ItemWindow.Profession.SkillTypePlaceholder": "e.g. German, Painting",
|
||||
"DG.ItemWindow.Profession.TypedSkillRequired": "Enter a skill type before adding this skill.",
|
||||
"DG.ItemWindow.Profession.ChooseOne": "Choose One",
|
||||
"DG.ItemWindow.Profession.ChooseOneHint": "The player will name this skill when the profession is applied to an agent.",
|
||||
"DG.ItemWindow.Profession.TypedSkillNameConflict": "Another automatic or option skill in this category already uses the name \"{name}\".",
|
||||
"DG.Gear.Tomes": "TOMES",
|
||||
"DG.Gear.Rituals": "RITUALS",
|
||||
|
||||
"DG.Tomes.Name": "Name",
|
||||
"DG.Ritual.Name": "Name",
|
||||
|
||||
"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",
|
||||
@@ -383,15 +452,12 @@
|
||||
"DG.ItemWindow.Tome.StudySanityDamage": "Study Sanity Damage",
|
||||
"DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Handler Only",
|
||||
"DG.ItemWindow.Tome.HandlerNotes": "Handler Notes",
|
||||
|
||||
"DG.ItemWindow.Ritual.ActivationSanityDamage": "Activation SAN Damage",
|
||||
"DG.ItemWindow.Ritual.ActivationTime": "Activation Time",
|
||||
"DG.ItemWindow.Ritual.ActivationCosts": "Activation Costs",
|
||||
"DG.ItemWindow.Ritual.StudyTime": "Study Time",
|
||||
"DG.ItemWindow.Ritual.LearnedSanityDamage": "Learn SAN Damage",
|
||||
|
||||
"DG.MissionPaused": "Mission Paused",
|
||||
|
||||
"DG.Settings.charactersheet.name": "Character Sheet Style",
|
||||
"DG.Settings.charactersheet.hint": "Choose how actor sheets should be styled. 'Program' is a more modern government style, where 'Cowboy/Outlaw' is an older, grittier looking typewriter style.",
|
||||
"DG.Settings.charactersheet.cowboys": "The Cowboys",
|
||||
@@ -419,15 +485,22 @@
|
||||
"DG.Settings.skillFailure.name": "Auto-mark Skill Failure",
|
||||
"DG.Settings.skillFailure.hint": "Activate this setting to always mark a failure checkbox upon failing a skill. The Handler can undo the action from the chat message for the roll.",
|
||||
"DG.Settings.Saved": "The current settings have been saved.",
|
||||
|
||||
"DG.SettingsMenu.automation.name": "Automation Menu",
|
||||
"DG.SettingsMenu.automation.label": "Automation Menu",
|
||||
"DG.SettingsMenu.automation.title": "Automation Settings",
|
||||
|
||||
"DG.SettingsMenu.handler.name": "Handler Settings",
|
||||
"DG.SettingsMenu.handler.label": "Handler Settings",
|
||||
"DG.SettingsMenu.handler.title": "Handler-only Settings",
|
||||
|
||||
"DG.Messages.Skillsmark.Marked": "This skill is marked",
|
||||
"DG.Messages.Skillsmark.Tooltip": "See p. 29 Agent's Handbook"
|
||||
"DG.Messages.Skillsmark.Tooltip": "See p. 29 Agent's Handbook",
|
||||
"DG.Profession.Dialog.OptionPicksRequired": "Select exactly {picks} skill option(s).",
|
||||
"DG.Profession.Dialog.BondNameRequired": "Enter a name for each bond.",
|
||||
"DG.Profession.Dialog.BondRelationshipRequired": "Enter a relationship for each bond.",
|
||||
"DG.Profession.Dialog.BonusSkillRequired": "Select all {count} bonus skills.",
|
||||
"DG.Profession.Dialog.BonusSkillTypeRequired": "Enter a specialization for each typed bonus skill.",
|
||||
"DG.Profession.Dialog.TypedNameRequired": "Enter a name for each {type} skill.",
|
||||
"DG.Physical.Chat.ExhaustedLabel": "Exhausted",
|
||||
"DG.Physical.Chat.RestedLabel": "Rest",
|
||||
"DG.Physical.Chat.StimulantsRegularLabel": "Regular Stimulants",
|
||||
"DG.Physical.Chat.StimulantsHardLabel": "Hard Drugs"
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,56 @@
|
||||
const { ArrayField, SchemaField, StringField, NumberField, AnyField } =
|
||||
foundry.data.fields;
|
||||
|
||||
/**
|
||||
* Delta Green Active Effect system data — matches core change schema with `final` as the default phase.
|
||||
* @extends {foundry.data.ActiveEffectTypeDataModel}
|
||||
*/
|
||||
export default class DGActiveEffectTypeDataModel extends foundry.data
|
||||
.ActiveEffectTypeDataModel {
|
||||
/** @override */
|
||||
static defineSchema() {
|
||||
return {
|
||||
changes: new ArrayField(
|
||||
new SchemaField({
|
||||
key: new StringField({ required: true }),
|
||||
type: new StringField({
|
||||
required: true,
|
||||
blank: false,
|
||||
initial: "add",
|
||||
validate: DGActiveEffectTypeDataModel.#validateType,
|
||||
}),
|
||||
value: new AnyField({
|
||||
required: true,
|
||||
nullable: true,
|
||||
serializable: true,
|
||||
initial: "",
|
||||
}),
|
||||
phase: new StringField({
|
||||
required: true,
|
||||
blank: false,
|
||||
initial: "final",
|
||||
}),
|
||||
priority: new NumberField(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @returns {true}
|
||||
*/
|
||||
static #validateType(type) {
|
||||
if (type.length < 3)
|
||||
throw new Error("must be at least three characters long");
|
||||
if (
|
||||
!/^custom\.-?\d+$/.test(type) &&
|
||||
!type.split(".").every((s) => /^[a-z0-9]+$/i.test(s))
|
||||
) {
|
||||
throw new Error(
|
||||
'A change type must either be a sequence of dot-delimited, alpha-numeric substrings or of the form "custom.{number}"',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Item|undefined}
|
||||
*/
|
||||
function resolveTransferItem(effect) {
|
||||
const item = effect.parent;
|
||||
if (item?.documentName !== "Item") return undefined;
|
||||
|
||||
const { actor } = item;
|
||||
if (actor?.documentName === "Actor") {
|
||||
return actor.items.get(item.id) ?? item;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @returns {{ inactive: boolean, reasonKey: string|null }}
|
||||
*/
|
||||
function getTransferSuppressionState(item) {
|
||||
switch (item.type) {
|
||||
case "weapon":
|
||||
case "armor":
|
||||
case "gear":
|
||||
if (item.system.equipped !== true) {
|
||||
return {
|
||||
inactive: true,
|
||||
reasonKey: "DG.ActiveEffects.SuppressedNotEquipped",
|
||||
};
|
||||
}
|
||||
return { inactive: false, reasonKey: null };
|
||||
case "motivation":
|
||||
if (item.system.acuteEpisode !== true) {
|
||||
return {
|
||||
inactive: true,
|
||||
reasonKey: "DG.ActiveEffects.SuppressedAcuteEpisode",
|
||||
};
|
||||
}
|
||||
return { inactive: false, reasonKey: null };
|
||||
case "tome":
|
||||
case "ritual":
|
||||
return {
|
||||
inactive: true,
|
||||
reasonKey: "DG.ActiveEffects.SuppressedUnsupportedItem",
|
||||
};
|
||||
case "bond":
|
||||
return { inactive: false, reasonKey: null };
|
||||
default:
|
||||
return { inactive: false, reasonKey: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Item} item
|
||||
* @returns {boolean} True when a transfer effect on this item should not apply.
|
||||
*/
|
||||
function isTransferEffectInactive(item) {
|
||||
return getTransferSuppressionState(item).inactive;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {string|null} Localization key for why a transfer effect is inactive.
|
||||
*/
|
||||
export function getTransferSuppressionReasonKey(effect) {
|
||||
const item = resolveTransferItem(effect);
|
||||
if (!item || !effect.transfer) return null;
|
||||
return getTransferSuppressionState(item).reasonKey;
|
||||
}
|
||||
|
||||
/** @extends {foundry.documents.ActiveEffect} */
|
||||
export default class DGActiveEffect extends ActiveEffect {
|
||||
/** @override */
|
||||
get isSuppressed() {
|
||||
if (this.disabled || this.duration?.expired) return true;
|
||||
if (this.system?.isSuppressed) return true;
|
||||
if (!this.transfer) return false;
|
||||
|
||||
const item = resolveTransferItem(this);
|
||||
if (!item) return false;
|
||||
|
||||
return isTransferEffectInactive(item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/** @type {Record<string, { label: string, fields: Record<string, string> }>} */
|
||||
export const EFFECT_FIELD_GROUPS = {
|
||||
statistics: {
|
||||
label: "DG.ActiveEffects.Groups.Statistics",
|
||||
fields: {
|
||||
"system.statistics.str.modifier": "DG.Attributes.str",
|
||||
"system.statistics.con.modifier": "DG.Attributes.con",
|
||||
"system.statistics.dex.modifier": "DG.Attributes.dex",
|
||||
"system.statistics.int.modifier": "DG.Attributes.int",
|
||||
"system.statistics.pow.modifier": "DG.Attributes.pow",
|
||||
"system.statistics.cha.modifier": "DG.Attributes.cha",
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
label: "DG.ActiveEffects.Groups.Resources",
|
||||
fields: {
|
||||
"system.health.maxBonus": "DG.ActiveEffects.Fields.MaxHP",
|
||||
"system.wp.maxBonus": "DG.ActiveEffects.Fields.MaxWP",
|
||||
"system.sanity.maxBonus": "DG.ActiveEffects.Fields.MaxSanity",
|
||||
},
|
||||
},
|
||||
rollTargets: {
|
||||
label: "DG.ActiveEffects.Groups.RollTargets",
|
||||
fields: {
|
||||
"system.rollTarget.allSkills": "DG.ActiveEffects.Fields.AllSkills",
|
||||
"system.rollTarget.sanity": "DG.ActiveEffects.Fields.SanityRolls",
|
||||
"system.rollTarget.statistics": "DG.ActiveEffects.Fields.StatisticRolls",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ROLL_TARGET_FIELD_KEYS = Object.keys(
|
||||
EFFECT_FIELD_GROUPS.rollTargets.fields,
|
||||
);
|
||||
|
||||
export const SUPPORTED_ITEM_TYPES = new Set([
|
||||
"weapon",
|
||||
"armor",
|
||||
"gear",
|
||||
"bond",
|
||||
"motivation",
|
||||
]);
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Record<string, { label: string, fields: Record<string, string> }>}
|
||||
*/
|
||||
export function getEffectFieldGroupsForDocument(effect) {
|
||||
const { parent } = effect;
|
||||
|
||||
if (parent?.documentName === "Actor") {
|
||||
if (parent.type !== "agent") return {};
|
||||
return EFFECT_FIELD_GROUPS;
|
||||
}
|
||||
|
||||
if (parent?.documentName === "Item") {
|
||||
if (!SUPPORTED_ITEM_TYPES.has(parent.type)) return {};
|
||||
return EFFECT_FIELD_GROUPS;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, { label: string, fields: Record<string, string> }>} groups
|
||||
* @returns {Array<{ group: string, groupLabel: string, options: Array<{ value: string, label: string }> }>}
|
||||
*/
|
||||
export function buildEffectFieldSelectOptions(groups) {
|
||||
return Object.entries(groups).map(([groupId, group]) => ({
|
||||
group: groupId,
|
||||
groupLabel: game.i18n.localize(group.label),
|
||||
options: Object.entries(group.fields).map(([value, labelKey]) => ({
|
||||
value,
|
||||
label: game.i18n.localize(labelKey),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -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 default function getEffectiveSuppressExhaustion(actor) {
|
||||
const persisted =
|
||||
actor._source?.system?.physical?.suppressExhaustion ?? false;
|
||||
return Boolean(persisted) || hasActiveStimulantEffect(actor);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
applyAgentResourceMaxBonuses,
|
||||
calculateHealthMax,
|
||||
setSkillTargetProficiencies,
|
||||
calculateMeleeDamageBonusFormula,
|
||||
clampSanityRitualValue,
|
||||
prepareAgentSkillFlags,
|
||||
prepareBreakingPointHit,
|
||||
prepareStatisticsX5,
|
||||
} from "../../data/derived/actor-derived.js";
|
||||
|
||||
/**
|
||||
* @param {number} current
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getActiveEffectModificationClass(current, base) {
|
||||
if (current == null || base == null || current === base) return "";
|
||||
return current > base ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet color for roll-target modifiers (uses modifier sign, not clamped effective vs base).
|
||||
* @param {number} modifier
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getRollTargetDisplayClassFromModifier(modifier) {
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier) return "";
|
||||
return numericModifier > 0 ? "ae-mod-increase" : "ae-mod-decrease";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a roll-target Active Effect modifier with the same clamp rules as percentile rolls.
|
||||
* @param {number} base
|
||||
* @param {number} modifier
|
||||
* @param {{ allowOver99?: boolean }} [options]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampPercentileRollTarget(
|
||||
base,
|
||||
modifier,
|
||||
{ allowOver99 = false } = {},
|
||||
) {
|
||||
const numericBase = Number(base);
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier || !Number.isFinite(numericBase)) return numericBase;
|
||||
|
||||
let target = Math.round(numericBase + numericModifier);
|
||||
target = Math.max(1, target);
|
||||
if (!allowOver99) {
|
||||
target = Math.min(target, 99);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} entry
|
||||
* @param {number} modifier
|
||||
*/
|
||||
function attachRollTargetDisplay(entry, modifier) {
|
||||
const numericModifier = Number(modifier) || 0;
|
||||
if (!numericModifier) {
|
||||
entry.rollTargetDisplayClass = "";
|
||||
return;
|
||||
}
|
||||
|
||||
entry.rollTargetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(numericModifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sheet display for agent roll-target Active Effects (skills, stats x5, SAN).
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentRollTargetDisplay(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const { system } = actor;
|
||||
const { rollTarget } = system;
|
||||
if (!rollTarget) return;
|
||||
|
||||
const allSkillsMod = Number(rollTarget.allSkills) || 0;
|
||||
const sanityMod = Number(rollTarget.sanity) || 0;
|
||||
const statisticsMod = Number(rollTarget.statistics) || 0;
|
||||
|
||||
for (const skill of Object.values(system.skills ?? {})) {
|
||||
attachRollTargetDisplay(skill, allSkillsMod);
|
||||
}
|
||||
|
||||
for (const skill of Object.values(system.typedSkills ?? {})) {
|
||||
attachRollTargetDisplay(skill, allSkillsMod);
|
||||
}
|
||||
|
||||
attachRollTargetDisplay(system.sanity, sanityMod);
|
||||
|
||||
for (const stat of Object.values(system.statistics ?? {})) {
|
||||
attachRollTargetDisplay(stat, statisticsMod);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach sheet display classes comparing prepared data to persisted source (pre–AE).
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentActiveEffectDisplay(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const { system } = actor;
|
||||
const source = actor._source?.system;
|
||||
if (!source) return;
|
||||
|
||||
for (const [key, stat] of Object.entries(system.statistics ?? {})) {
|
||||
const baseValue = source.statistics?.[key]?.value;
|
||||
const effective = stat.effectiveValue ?? stat.value;
|
||||
stat.valueDisplayClass = getActiveEffectModificationClass(
|
||||
effective,
|
||||
baseValue,
|
||||
);
|
||||
stat.statisticAeModifier = stat.valueDisplayClass
|
||||
? effective - (baseValue ?? 0)
|
||||
: 0;
|
||||
}
|
||||
|
||||
const persistedStats = source.statistics ?? system.statistics;
|
||||
const healthFormulaMax = calculateHealthMax(persistedStats, persistedStats);
|
||||
const wpFormulaMax = persistedStats.pow?.value ?? 0;
|
||||
const persistedUnnatural =
|
||||
source.skills?.unnatural?.proficiency ??
|
||||
system.skills?.unnatural?.proficiency ??
|
||||
0;
|
||||
const sanityFormulaMax = 99 - persistedUnnatural;
|
||||
|
||||
const healthBaseMax = healthFormulaMax + (source.health?.maxBonus ?? 0);
|
||||
system.health.maxDisplayClass = getActiveEffectModificationClass(
|
||||
system.health.max,
|
||||
healthBaseMax,
|
||||
);
|
||||
|
||||
const wpBaseMax = wpFormulaMax + (source.wp?.maxBonus ?? 0);
|
||||
system.wp.maxDisplayClass = getActiveEffectModificationClass(
|
||||
system.wp.max,
|
||||
wpBaseMax,
|
||||
);
|
||||
|
||||
const sanityBaseMax = sanityFormulaMax + (source.sanity?.maxBonus ?? 0);
|
||||
system.sanity.maxDisplayClass = getActiveEffectModificationClass(
|
||||
system.sanity.max,
|
||||
sanityBaseMax,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute agent fields that depend on Active Effects (final phase) and refresh sheet display.
|
||||
* Single post-AE pass for agents — do not mirror this logic in AgentData.prepareDerivedData.
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function refreshDerivedAfterActiveEffects(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const { system } = actor;
|
||||
|
||||
const sourceStatistics = actor._source?.system?.statistics;
|
||||
prepareStatisticsX5(system.statistics, sourceStatistics);
|
||||
prepareAgentRollTargetDisplay(actor);
|
||||
applyAgentResourceMaxBonuses(system, sourceStatistics);
|
||||
setSkillTargetProficiencies(system.skills);
|
||||
setSkillTargetProficiencies(system.typedSkills);
|
||||
prepareAgentSkillFlags(system.skills);
|
||||
clampSanityRitualValue(system.sanity);
|
||||
prepareBreakingPointHit(system.sanity);
|
||||
system.statistics.str.meleeDamageBonusFormula =
|
||||
calculateMeleeDamageBonusFormula(
|
||||
system.statistics.str.effectiveValue ?? system.statistics.str.value,
|
||||
);
|
||||
|
||||
prepareAgentActiveEffectDisplay(actor);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import DG from "../../config/index.js";
|
||||
import { ROLL_TARGET_FIELD_KEYS } from "../effect-fields.js";
|
||||
import getEffectiveSuppressExhaustion from "./agent-condition-sync.js";
|
||||
|
||||
/** @type {WeakMap<Actor, Promise<void>>} */
|
||||
const syncLocks = new WeakMap();
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect[]}
|
||||
*/
|
||||
function getExhaustionEffects(actor) {
|
||||
return (
|
||||
actor.effects?.filter((effect) => effect.getFlag(DG.ID, "exhaustion")) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect|undefined}
|
||||
*/
|
||||
function getExhaustionEffect(actor) {
|
||||
return getExhaustionEffects(actor)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} penalty
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function buildExhaustionChanges(penalty) {
|
||||
const value = String(penalty);
|
||||
return ROLL_TARGET_FIELD_KEYS.map((key) => ({
|
||||
key,
|
||||
type: "add",
|
||||
value,
|
||||
phase: "final",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @param {number} penalty
|
||||
* @param {boolean} suppressExhaustion
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function effectMatchesState(effect, penalty, suppressExhaustion) {
|
||||
if (Boolean(effect.disabled) !== Boolean(suppressExhaustion)) return false;
|
||||
const changes = effect.system?.changes ?? [];
|
||||
if (changes.length !== ROLL_TARGET_FIELD_KEYS.length) return false;
|
||||
return changes.every(
|
||||
(change) =>
|
||||
ROLL_TARGET_FIELD_KEYS.includes(change.key) &&
|
||||
change.type === "add" &&
|
||||
String(change.value) === String(penalty),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function pruneDuplicateExhaustionEffects(actor) {
|
||||
const effects = getExhaustionEffects(actor);
|
||||
if (effects.length <= 1) return;
|
||||
for (const effect of effects.slice(1)) {
|
||||
await effect.delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function syncExhaustionEffectInner(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
await pruneDuplicateExhaustionEffects(actor);
|
||||
|
||||
const { exhausted, exhaustedPenalty } = actor.system.physical;
|
||||
const suppressExhaustion = getEffectiveSuppressExhaustion(actor);
|
||||
const effect = getExhaustionEffect(actor);
|
||||
const penalty = exhausted ? -1 * Math.abs(Number(exhaustedPenalty) || 0) : 0;
|
||||
|
||||
if (!exhausted) {
|
||||
if (effect) await effect.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
const changes = buildExhaustionChanges(penalty);
|
||||
const disabled = Boolean(suppressExhaustion);
|
||||
|
||||
if (effect) {
|
||||
if (effectMatchesState(effect, penalty, suppressExhaustion)) return;
|
||||
await effect.update({ changes, disabled });
|
||||
return;
|
||||
}
|
||||
|
||||
const documentClass = foundry.utils.getDocumentClass("ActiveEffect");
|
||||
await documentClass.create(
|
||||
{
|
||||
name: game.i18n.localize("DG.Physical.ExhaustionEffectName"),
|
||||
img: "systems/deltagreen/assets/icons/magic-shield.svg",
|
||||
transfer: false,
|
||||
disabled,
|
||||
changes,
|
||||
flags: { [DG.ID]: { exhaustion: true } },
|
||||
},
|
||||
{ parent: actor },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the embedded exhaustion Active Effect aligned with physical exhaustion data.
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function syncExhaustionEffect(actor) {
|
||||
const pending = syncLocks.get(actor);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = syncExhaustionEffectInner(actor).finally(() => {
|
||||
if (syncLocks.get(actor) === promise) syncLocks.delete(actor);
|
||||
});
|
||||
syncLocks.set(actor, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} data Actor update payload.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function updateTouchesExhaustionPhysical(data) {
|
||||
if (!data) return false;
|
||||
|
||||
const physical = foundry.utils.getProperty(data, "system.physical");
|
||||
if (physical) {
|
||||
if (
|
||||
"exhausted" in physical ||
|
||||
"exhaustedPenalty" in physical ||
|
||||
"suppressExhaustion" in physical
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"system.physical.exhausted",
|
||||
"system.physical.exhaustedPenalty",
|
||||
"system.physical.suppressExhaustion",
|
||||
].some((key) => foundry.utils.hasProperty(data, key));
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import DG from "../../config/index.js";
|
||||
import { syncExhaustionEffect } from "./exhaustion-effect.js";
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect[]}
|
||||
*/
|
||||
function getStimulantEffects(actor) {
|
||||
return (
|
||||
actor.effects?.filter((effect) => effect.getFlag(DG.ID, "stimulant")) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect|undefined}
|
||||
*/
|
||||
function getStimulantEffect(actor) {
|
||||
return getStimulantEffects(actor).find((effect) => !effect.isSuppressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasActiveStimulantEffect(actor) {
|
||||
return getStimulantEffects(actor).some((effect) => !effect.isSuppressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {number}
|
||||
*/
|
||||
function getStimulantRemainingHours(effect) {
|
||||
effect.updateDuration();
|
||||
const { remaining, secondsRemaining, units } = effect.duration ?? {};
|
||||
if (Number.isFinite(remaining) && remaining > 0 && units === "hours") {
|
||||
return Math.ceil(remaining);
|
||||
}
|
||||
if (Number.isFinite(secondsRemaining) && secondsRemaining > 0) {
|
||||
const { calendar } = game.time;
|
||||
const components = calendar.difference(
|
||||
game.time.worldTime + secondsRemaining,
|
||||
game.time.worldTime,
|
||||
);
|
||||
const hours = calendar.componentsToUnit(components, "hour", {
|
||||
roundFn: "ceil",
|
||||
});
|
||||
return Math.max(0, Math.ceil(Math.abs(hours)));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export { default as getEffectiveSuppressExhaustion } from "./agent-condition-sync.js";
|
||||
|
||||
/**
|
||||
* @param {number} hours
|
||||
* @returns {object}
|
||||
*/
|
||||
function buildStimulantEffectData(hours) {
|
||||
return {
|
||||
name: game.i18n.localize("DG.Physical.StimulantsEffectName"),
|
||||
img: "systems/deltagreen/assets/icons/tablet.svg",
|
||||
transfer: false,
|
||||
disabled: false,
|
||||
start: { time: game.time.worldTime },
|
||||
duration: {
|
||||
value: hours,
|
||||
units: "hours",
|
||||
},
|
||||
changes: [],
|
||||
flags: { [DG.ID]: { stimulant: true } },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or refresh the stimulant AE; duration is max(remaining, newHours).
|
||||
* @param {Actor} actor
|
||||
* @param {number} newHours
|
||||
* @returns {Promise<number>} Hours applied on the stimulant AE.
|
||||
*/
|
||||
export async function applyStimulantEffect(actor, newHours) {
|
||||
if (actor.type !== "agent") return 0;
|
||||
|
||||
const rolledHours = Math.max(1, Math.ceil(Number(newHours) || 0));
|
||||
const existing = getStimulantEffect(actor);
|
||||
const remainingHours = existing ? getStimulantRemainingHours(existing) : 0;
|
||||
const hours = Math.max(remainingHours, rolledHours);
|
||||
|
||||
const data = buildStimulantEffectData(hours);
|
||||
|
||||
if (existing) {
|
||||
await existing.update({
|
||||
start: data.start,
|
||||
duration: data.duration,
|
||||
changes: data.changes,
|
||||
disabled: false,
|
||||
});
|
||||
return hours;
|
||||
}
|
||||
|
||||
const documentClass = foundry.utils.getDocumentClass("ActiveEffect");
|
||||
await documentClass.create(data, { parent: actor });
|
||||
return hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {ActiveEffect[]}
|
||||
*/
|
||||
function getExpiredStimulantEffects(actor) {
|
||||
return getStimulantEffects(actor).filter(
|
||||
(effect) => effect.duration?.expired,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stimulant AEs that have expired.
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function pruneExpiredStimulantEffects(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
const expired = getExpiredStimulantEffects(actor);
|
||||
if (!expired.length) return;
|
||||
await actor.deleteEmbeddedDocuments(
|
||||
"ActiveEffect",
|
||||
expired.map((effect) => effect.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all stimulant AEs on the actor (e.g. after a full rest).
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function clearStimulantEffects(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
const effects = getStimulantEffects(actor);
|
||||
if (!effects.length) return;
|
||||
await actor.deleteEmbeddedDocuments(
|
||||
"ActiveEffect",
|
||||
effects.map((effect) => effect.id),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function pruneAllAgentsExpiredStimulants() {
|
||||
if (!game.user.isActiveGM) return;
|
||||
for (const actor of game.actors) {
|
||||
if (actor.type === "agent") {
|
||||
await pruneExpiredStimulantEffects(actor);
|
||||
await syncExhaustionEffect(actor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import DG from "../../config/index.js";
|
||||
import { getStatisticEffectiveValue } from "../../data/derived/actor-derived.js";
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {object} data
|
||||
* @param {object} source
|
||||
*/
|
||||
function sanitizeStatisticValues(actor, data, source) {
|
||||
for (const key of DG.statistics) {
|
||||
const submitted = foundry.utils.getProperty(
|
||||
data,
|
||||
`system.statistics.${key}.value`,
|
||||
);
|
||||
if (submitted !== undefined) {
|
||||
const sourceStat = source.statistics?.[key];
|
||||
const preparedStat = actor.system.statistics?.[key];
|
||||
if (sourceStat && preparedStat) {
|
||||
const base = sourceStat.value;
|
||||
const effective = getStatisticEffectiveValue(preparedStat, sourceStat);
|
||||
|
||||
if (submitted === effective && submitted !== base) {
|
||||
foundry.utils.setProperty(
|
||||
data,
|
||||
`system.statistics.${key}.value`,
|
||||
base,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip form values that match post–AE prepared numbers so persisted data stays at base values.
|
||||
* @param {Actor} actor
|
||||
* @param {object} data Update payload (mutated in place).
|
||||
* @returns {object}
|
||||
*/
|
||||
export default function sanitizeActiveEffectBackedUpdateData(actor, data) {
|
||||
if (!data?.system) return data;
|
||||
|
||||
const source = actor._source?.system;
|
||||
if (!source) return data;
|
||||
|
||||
sanitizeStatisticValues(actor, data, source);
|
||||
|
||||
return data;
|
||||
}
|
||||
+31
-283
@@ -1,296 +1,35 @@
|
||||
import { refreshDerivedAfterActiveEffects } from "../active-effect/runtime/derived.js";
|
||||
import { prepareAgentStatSanityTooltips } from "../utils/roll-target-tooltip.js";
|
||||
import {
|
||||
syncExhaustionEffect,
|
||||
updateTouchesExhaustionPhysical,
|
||||
} 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.
|
||||
* @extends {Actor}
|
||||
*/
|
||||
export default class DeltaGreenActor extends Actor {
|
||||
/**
|
||||
* Augment the basic actor data with additional dynamic data.
|
||||
*/
|
||||
/** @override */
|
||||
prepareData() {
|
||||
super.prepareData();
|
||||
|
||||
const actorData = this;
|
||||
|
||||
// console.log('actor.js prepareData');
|
||||
// console.log(this);
|
||||
|
||||
// Make separate methods for each Actor type (character, npc, etc.) to keep
|
||||
// things organized.
|
||||
if (actorData.type === "agent") {
|
||||
this._prepareAgentData(this);
|
||||
} else if (actorData.type === "unnatural") {
|
||||
this._prepareUnnaturalData(this);
|
||||
} else if (actorData.type === "npc") {
|
||||
this._prepareNpcData(this);
|
||||
} else if (actorData.type === "vehicle") {
|
||||
this._prepareVehicleData(this);
|
||||
}
|
||||
refreshDerivedAfterActiveEffects(this);
|
||||
if (this.type === "agent") prepareAgentStatSanityTooltips(this);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} agent
|
||||
*/
|
||||
|
||||
_prepareVehicleData(actor) {
|
||||
const actorData = actor;
|
||||
|
||||
// calculate total armor rating
|
||||
let protection = 0;
|
||||
for (const i of actor.items) {
|
||||
if (i.type === "armor") {
|
||||
if (i.system.equipped === true) {
|
||||
protection += i.system.protection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actorData.system.health.protection = protection;
|
||||
|
||||
// console.log(actor);
|
||||
}
|
||||
|
||||
_prepareNpcData(actor) {
|
||||
const { system } = actor;
|
||||
|
||||
// Loop through ability scores, and add their modifiers to our sheet output.
|
||||
for (const statistic of Object.values(system.statistics)) {
|
||||
// the x5 is just whatever the raw statistic is x 5 to turn it into a d100 percentile
|
||||
statistic.x5 = statistic.value * 5;
|
||||
}
|
||||
|
||||
// initialize sanity, don't set these afterwards, as they need to be manually edited
|
||||
if (system.sanity.value >= 100) {
|
||||
system.sanity.value = system.statistics.pow.x5;
|
||||
system.sanity.currentBreakingPoint =
|
||||
system.sanity.value - system.statistics.pow.value;
|
||||
}
|
||||
|
||||
system.sanity.max = 99 - system.skills.unnatural.proficiency;
|
||||
|
||||
system.wp.max = system.statistics.pow.value;
|
||||
|
||||
try {
|
||||
system.health.max = Math.ceil(
|
||||
(system.statistics.con.value + system.statistics.str.value) / 2,
|
||||
);
|
||||
} catch {
|
||||
system.health.max = 10;
|
||||
}
|
||||
|
||||
const strength = system.statistics.str;
|
||||
system.statistics.str.meleeDamageBonusFormula =
|
||||
this._calculateMeleeDamageBonusFormula(strength.value);
|
||||
|
||||
try {
|
||||
delete system.skills.ritual; // try to remove legacy skill for ritual if it exists
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
system.sanity.ritual = 99 - system.sanity.value;
|
||||
|
||||
if (system.sanity.ritual > 99) {
|
||||
system.sanity.ritual = 99;
|
||||
} else if (system.sanity.ritual < 1) {
|
||||
system.sanity.ritual = 1;
|
||||
}
|
||||
|
||||
// calculate total armor rating
|
||||
let protection = 0;
|
||||
for (const i of actor.items) {
|
||||
if (i.type === "armor") {
|
||||
if (i.system.equipped === true) {
|
||||
protection += i.system.protection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
system.health.protection = protection;
|
||||
|
||||
for (const skill of Object.values(system.skills)) {
|
||||
skill.targetProficiency = skill.proficiency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} agent
|
||||
*/
|
||||
_prepareUnnaturalData(actor) {
|
||||
const { system } = actor;
|
||||
|
||||
// Loop through ability scores, and add their modifiers to our sheet output.
|
||||
for (const statistic of Object.values(system.statistics)) {
|
||||
// the x5 is just whatever the raw statistic is x 5 to turn it into a d100 percentile
|
||||
statistic.x5 = statistic.value * 5;
|
||||
}
|
||||
|
||||
if (system.wp.maxNeedsUpdate) {
|
||||
system.wp.max = system.statistics.pow.value;
|
||||
}
|
||||
|
||||
if (system.health.maxNeedsUpdate) {
|
||||
try {
|
||||
system.health.max = Math.ceil(
|
||||
(system.statistics.con.value + system.statistics.str.value) / 2,
|
||||
);
|
||||
} catch {
|
||||
system.health.max = 10;
|
||||
}
|
||||
}
|
||||
|
||||
// calculate total armor rating
|
||||
let protection = 0;
|
||||
for (const i of actor.items) {
|
||||
if (i.type === "armor") {
|
||||
if (i.system.equipped === true) {
|
||||
protection += i.system.protection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
system.health.protection = protection;
|
||||
|
||||
for (const skill of Object.values(system.skills)) {
|
||||
skill.targetProficiency = skill.proficiency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare Agent type specific data
|
||||
*/
|
||||
_prepareAgentData(agent) {
|
||||
const { system } = agent;
|
||||
|
||||
// Make modifications to data here. For example:
|
||||
|
||||
// Loop through ability scores, and add their modifiers to our sheet output.
|
||||
for (const statistic of Object.values(system.statistics)) {
|
||||
// the x5 is just whatever the raw statistic is x 5 to turn it into a d100 percentile
|
||||
statistic.x5 = statistic.value * 5;
|
||||
}
|
||||
|
||||
try {
|
||||
delete system.skills.ritual; // try to remove legacy skill for ritual if it exists
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
system.sanity.ritual = 99 - system.sanity.value;
|
||||
|
||||
if (system.sanity.ritual > 99) {
|
||||
system.sanity.ritual = 99;
|
||||
} else if (system.sanity.ritual < 1) {
|
||||
system.sanity.ritual = 1;
|
||||
}
|
||||
|
||||
// The unnatural skill is sort of special
|
||||
// It cannot be improved via failure, so add in a special property to reflect this
|
||||
// Mostly to make it easy to deactivate the failure checkbox in the GUI
|
||||
for (const [key, skill] of Object.entries(system.skills)) {
|
||||
if (key === "unnatural") {
|
||||
skill.cannotBeImprovedByFailure = true;
|
||||
} else if (key === "luck") {
|
||||
skill.cannotBeImprovedByFailure = true;
|
||||
} else if (key === "ritual") {
|
||||
skill.cannotBeImprovedByFailure = true;
|
||||
} else {
|
||||
skill.cannotBeImprovedByFailure = false;
|
||||
}
|
||||
|
||||
// For ritual skill, it's calculated, so add some logic to turn off changing that entirely.
|
||||
if (key === "ritual") {
|
||||
skill.isCalculatedValue = true;
|
||||
} else {
|
||||
skill.isCalculatedValue = false;
|
||||
}
|
||||
|
||||
// Add field for calculation
|
||||
skill.targetProficiency = skill.proficiency;
|
||||
}
|
||||
|
||||
system.wp.max = system.statistics.pow.value;
|
||||
|
||||
system.health.max = Math.ceil(
|
||||
(system.statistics.con.value + system.statistics.str.value) / 2,
|
||||
/** @override */
|
||||
async update(data, options = {}) {
|
||||
const sanitized = sanitizeActiveEffectBackedUpdateData(
|
||||
this,
|
||||
foundry.utils.deepClone(data),
|
||||
);
|
||||
|
||||
// initialize sanity, don't set these afterwards, as they need to be manually edited
|
||||
if (system.sanity.value >= 100) {
|
||||
system.sanity.value = system.statistics.pow.x5;
|
||||
system.sanity.currentBreakingPoint =
|
||||
system.sanity.value - system.statistics.pow.value;
|
||||
}
|
||||
|
||||
system.sanity.max = 99 - system.skills.unnatural.proficiency;
|
||||
|
||||
// Sanity Loss Adaptations Logic
|
||||
const { adaptations } = system.sanity;
|
||||
|
||||
if (
|
||||
adaptations.violence.incident1 &&
|
||||
adaptations.violence.incident2 &&
|
||||
adaptations.violence.incident3
|
||||
) {
|
||||
system.sanity.adaptations.violence.isAdapted = true;
|
||||
} else {
|
||||
system.sanity.adaptations.violence.isAdapted = false;
|
||||
}
|
||||
|
||||
if (
|
||||
adaptations.helplessness.incident1 &&
|
||||
adaptations.helplessness.incident2 &&
|
||||
adaptations.helplessness.incident3
|
||||
) {
|
||||
system.sanity.adaptations.helplessness.isAdapted = true;
|
||||
} else {
|
||||
system.sanity.adaptations.helplessness.isAdapted = false;
|
||||
}
|
||||
|
||||
if (system.sanity.value <= system.sanity.currentBreakingPoint) {
|
||||
system.sanity.breakingPointHit = true;
|
||||
} else {
|
||||
system.sanity.breakingPointHit = false;
|
||||
}
|
||||
|
||||
// calculate total armor rating
|
||||
let protection = 0;
|
||||
for (const i of agent.items) {
|
||||
if (i.type === "armor") {
|
||||
if (i.system.equipped === true) {
|
||||
protection += i.system.protection;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
system.health.protection = protection;
|
||||
|
||||
const strength = system.statistics.str;
|
||||
system.statistics.str.meleeDamageBonusFormula =
|
||||
this._calculateMeleeDamageBonusFormula(strength.value);
|
||||
|
||||
if (system.physical.exhaustedPenalty > 0) {
|
||||
system.physical.exhaustedPenalty =
|
||||
-1 * Math.abs(system.physical.exhaustedPenalty);
|
||||
}
|
||||
}
|
||||
|
||||
// Returns Bonus From Strength in Hand-to-hand Combat (melee/unarmed)
|
||||
_calculateMeleeDamageBonusFormula(strengthValue) {
|
||||
if (strengthValue < 5) {
|
||||
return "-2";
|
||||
}
|
||||
if (strengthValue < 9) {
|
||||
return "-1";
|
||||
}
|
||||
if (strengthValue > 12 && strengthValue < 17) {
|
||||
return "+1";
|
||||
}
|
||||
if (strengthValue > 16) {
|
||||
return "+2";
|
||||
}
|
||||
return "";
|
||||
const shouldSyncExhaustion =
|
||||
this.type === "agent" && updateTouchesExhaustionPhysical(sanitized);
|
||||
const result = await super.update(sanitized, options);
|
||||
if (shouldSyncExhaustion) await syncExhaustionEffect(this);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
@@ -450,4 +189,13 @@ export default class DeltaGreenActor extends Actor {
|
||||
|
||||
await this.createEmbeddedDocuments("Item", [weaponData]);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async onUpdateEffectDurations(effects, event, context) {
|
||||
await super.onUpdateEffectDurations(effects, event, context);
|
||||
if (!game.user.isActiveGM || this.type !== "agent") return;
|
||||
if (!effects.some((effect) => effect.parent === this)) return;
|
||||
await pruneExpiredStimulantEffects(this);
|
||||
await syncExhaustionEffect(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config/index.js";
|
||||
import {
|
||||
bindDialogTabs,
|
||||
getDialogContentRoot,
|
||||
showDgDialog,
|
||||
} from "./dg-dialog.js";
|
||||
import {
|
||||
BONUS_SKILL_COUNT,
|
||||
buildBonusSkillCatalog,
|
||||
buildSortedFixedSkillRows,
|
||||
buildSortedTypedSkillRows,
|
||||
computeSkillValues,
|
||||
formatProfessionValidationMessages,
|
||||
getTypedGroupDisplayName,
|
||||
parseProfessionSkillKey,
|
||||
prepareProfessionSkillRows,
|
||||
splitProfessionSkillMap,
|
||||
} from "../profession/index.js";
|
||||
import {
|
||||
reorderForColumnSorting,
|
||||
splitIntoColumns,
|
||||
} from "../utils/skill-layout.js";
|
||||
import {
|
||||
applySkillTooltipDisplayMode,
|
||||
buildSkillTooltip,
|
||||
} from "../utils/skill-tooltip.js";
|
||||
|
||||
const CHARACTER_CREATION_OPTION_COLUMNS = 2;
|
||||
const ADD_PROFESSION_SKILL_COLUMNS = 2;
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/**
|
||||
* @param {Item} professionItem
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export default async function showPickSkillsDialog(professionItem, actor) {
|
||||
// eslint-disable-next-line no-use-before-define -- class defined below in this module
|
||||
const controller = new AddProfessionDialogController(professionItem, actor);
|
||||
return controller.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean, tooltip?: string }[]} rows
|
||||
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]}
|
||||
*/
|
||||
function orderSkillRowsForDisplay(rows) {
|
||||
const sorted = [...rows].sort((a, b) =>
|
||||
a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang),
|
||||
);
|
||||
|
||||
const ordered = game.settings.get("deltagreen", "sortSkills")
|
||||
? reorderForColumnSorting(sorted, ADD_PROFESSION_SKILL_COLUMNS)
|
||||
: sorted;
|
||||
|
||||
return ordered.map(({ rowId, label, value, isModified, tooltip }) => ({
|
||||
rowId,
|
||||
label,
|
||||
value,
|
||||
isModified: Boolean(isModified),
|
||||
tooltip: tooltip ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
class AddProfessionDialogController {
|
||||
/**
|
||||
* @param {Item} professionItem
|
||||
* @param {Actor} actor
|
||||
*/
|
||||
constructor(professionItem, actor) {
|
||||
this.professionItem = professionItem;
|
||||
this.actor = actor;
|
||||
|
||||
const { optionPicks, skills: optionSkillMap } = splitProfessionSkillMap(
|
||||
professionItem.system.optionSkills ?? {},
|
||||
);
|
||||
this.automaticSkills = { ...(professionItem.system.automaticSkills ?? {}) };
|
||||
this.automaticSkillMeta = {
|
||||
...(professionItem.system.automaticSkillMeta ?? {}),
|
||||
};
|
||||
this.optionSkills = { ...optionSkillMap };
|
||||
this.optionSkillMeta = { ...(professionItem.system.optionSkillMeta ?? {}) };
|
||||
this.optionPicks = optionPicks;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
this.checkedOptionKeys = new Set();
|
||||
/** @type {Record<string, string>} */
|
||||
this.chooseOneLabels = {};
|
||||
/** @type {string[]} */
|
||||
this.bonusCatalogIds = Array(BONUS_SKILL_COUNT).fill("");
|
||||
/** @type {string[]} */
|
||||
this.bonusTypedLabels = Array(BONUS_SKILL_COUNT).fill("");
|
||||
/** @type {string[]} */
|
||||
this.bondNames = [];
|
||||
/** @type {string[]} */
|
||||
this.bondRelationships = [];
|
||||
|
||||
/** @type {import("foundry.applications.api.DialogV2").default | null} */
|
||||
this.dialog = null;
|
||||
this.submitted = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async run() {
|
||||
const content = await this.#buildTemplate();
|
||||
this.submitted = false;
|
||||
|
||||
return showDgDialog({
|
||||
modifier: "character-creation",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Profession.Dialog.Title"),
|
||||
},
|
||||
position: { width: 1080, height: 780 },
|
||||
form: { closeOnSubmit: false },
|
||||
onRender: async (dialog) => {
|
||||
this.dialog = dialog;
|
||||
const root = this.#contentRoot();
|
||||
if (root) {
|
||||
bindDialogTabs(root);
|
||||
applySkillTooltipDisplayMode(root);
|
||||
}
|
||||
this.#bindListeners();
|
||||
await this.#refreshUi();
|
||||
},
|
||||
close: () => this.submitted,
|
||||
buttons: [
|
||||
{
|
||||
action: "cancel",
|
||||
label: game.i18n.localize("Cancel"),
|
||||
callback: async (_event, _button, dialog) => {
|
||||
await dialog.close();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "submit",
|
||||
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
|
||||
default: true,
|
||||
disabled: true,
|
||||
callback: async () => {
|
||||
await this.#onSubmit();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
#getFormState() {
|
||||
this.#syncFormStateFromDom();
|
||||
return {
|
||||
checkedOptionKeys: this.checkedOptionKeys,
|
||||
chooseOneLabels: { ...this.chooseOneLabels },
|
||||
bonusCatalogIds: [...this.bonusCatalogIds],
|
||||
bonusTypedLabels: [...this.bonusTypedLabels],
|
||||
bondNames: [...this.bondNames],
|
||||
bondRelationships: [...this.bondRelationships],
|
||||
};
|
||||
}
|
||||
|
||||
#bondCount() {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.trunc(Number(this.professionItem.system.bonds) || 1),
|
||||
);
|
||||
}
|
||||
|
||||
#computeContext() {
|
||||
return {
|
||||
automaticMeta: this.automaticSkillMeta,
|
||||
optionMeta: this.optionSkillMeta,
|
||||
bondCount: this.#bondCount(),
|
||||
};
|
||||
}
|
||||
|
||||
#compute() {
|
||||
return computeSkillValues(
|
||||
this.automaticSkills,
|
||||
this.optionSkills,
|
||||
this.optionPicks,
|
||||
this.#getFormState(),
|
||||
this.#computeContext(),
|
||||
);
|
||||
}
|
||||
|
||||
#buildAutomaticChooseOneRows() {
|
||||
return Object.entries(this.automaticSkills)
|
||||
.map(([key]) => {
|
||||
if (!this.automaticSkillMeta[key]?.chooseOne) return null;
|
||||
const ref = parseProfessionSkillKey(key);
|
||||
if (!ref || ref.kind !== "typed") return null;
|
||||
return {
|
||||
key,
|
||||
groupLabel: getTypedGroupDisplayName(ref.group),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<computeSkillValues>} computed
|
||||
* @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string, sortLabel: string }[]}
|
||||
*/
|
||||
#buildSkillDisplayRows(computed) {
|
||||
const modifiedFixed = new Set(computed.modifiedFixedKeys ?? []);
|
||||
const modifiedTyped = new Set(computed.modifiedTypedKeys ?? []);
|
||||
const fixedRows = buildSortedFixedSkillRows(computed.fixedValues);
|
||||
const typedRows = buildSortedTypedSkillRows(computed.typedValues);
|
||||
return orderSkillRowsForDisplay([
|
||||
...fixedRows.map((r) => ({
|
||||
rowId: r.key,
|
||||
label: r.label,
|
||||
value: r.value,
|
||||
sortLabel: r.sortLabel,
|
||||
isModified: modifiedFixed.has(r.key),
|
||||
tooltip: buildSkillTooltip("dialog", null, {
|
||||
kind: "fixed",
|
||||
key: r.key,
|
||||
}),
|
||||
})),
|
||||
...typedRows.map((r) => {
|
||||
const data = computed.typedValues[r.storageKey];
|
||||
return {
|
||||
rowId: r.storageKey,
|
||||
label: r.label,
|
||||
value: r.value,
|
||||
sortLabel: r.sortLabel,
|
||||
isModified: modifiedTyped.has(r.storageKey),
|
||||
tooltip: buildSkillTooltip("dialog", null, {
|
||||
kind: "typed",
|
||||
group: data?.group ?? "",
|
||||
label: r.label,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async #buildTemplate() {
|
||||
const computed = this.#compute();
|
||||
const chaScore =
|
||||
this.actor.system.statistics.cha.effectiveValue ??
|
||||
this.actor.system.statistics.cha.value;
|
||||
|
||||
const skillRows = this.#buildSkillDisplayRows(computed);
|
||||
|
||||
const optionRows = prepareProfessionSkillRows(
|
||||
this.optionSkills,
|
||||
this.optionSkillMeta,
|
||||
).map((row) => ({
|
||||
...row,
|
||||
isChooseOne: Boolean(this.optionSkillMeta[row.key]?.chooseOne),
|
||||
}));
|
||||
const optionColumns = splitIntoColumns(
|
||||
optionRows,
|
||||
CHARACTER_CREATION_OPTION_COLUMNS,
|
||||
);
|
||||
|
||||
const automaticChooseOneRows = this.#buildAutomaticChooseOneRows();
|
||||
|
||||
const bondCount = this.#bondCount();
|
||||
const bondRows = Array.from({ length: bondCount }, (_, index) => ({
|
||||
index,
|
||||
number: index + 1,
|
||||
chaScore,
|
||||
}));
|
||||
|
||||
const bonusSlots = Array.from(
|
||||
{ length: BONUS_SKILL_COUNT },
|
||||
(_, index) => ({
|
||||
index,
|
||||
}),
|
||||
);
|
||||
|
||||
const showSkillOptionsTab = this.optionPicks > 0;
|
||||
|
||||
return renderTemplate(`${BASE_TEMPLATE_PATH}/dialog/add-profession.html`, {
|
||||
skillRows,
|
||||
showSkillOptionsTab,
|
||||
defaultTabSkillOptions: showSkillOptionsTab,
|
||||
defaultTabBonus: !showSkillOptionsTab,
|
||||
optionPicks: this.optionPicks,
|
||||
optionColumns,
|
||||
automaticChooseOneRows,
|
||||
catalog: buildBonusSkillCatalog(),
|
||||
bonusSlots,
|
||||
...this.#getDisplayMessages(computed),
|
||||
bondRows,
|
||||
});
|
||||
}
|
||||
|
||||
#contentRoot() {
|
||||
return this.dialog ? getDialogContentRoot(this.dialog) : null;
|
||||
}
|
||||
|
||||
#bindListeners() {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return;
|
||||
|
||||
root.querySelectorAll('[name="optionSkillChecked"]').forEach((el) => {
|
||||
el.addEventListener("change", () => this.#onOptionCheckChange(el));
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-choose-one-key]").forEach((el) => {
|
||||
el.addEventListener("input", () => this.#refreshUi());
|
||||
});
|
||||
|
||||
root.querySelectorAll("select[data-bonus-index]").forEach((el) => {
|
||||
el.addEventListener("change", () => this.#onBonusChange(el));
|
||||
});
|
||||
|
||||
root.querySelectorAll("[data-bonus-typed-index]").forEach((el) => {
|
||||
el.addEventListener("input", () => this.#refreshUi());
|
||||
});
|
||||
|
||||
for (let i = 0; i < this.#bondCount(); i++) {
|
||||
root
|
||||
.querySelector(`[name="bondName-${i}"]`)
|
||||
?.addEventListener("input", () => this.#refreshUi());
|
||||
root
|
||||
.querySelector(`[name="bondRelationship-${i}"]`)
|
||||
?.addEventListener("input", () => this.#refreshUi());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLInputElement} checkbox
|
||||
*/
|
||||
#onOptionCheckChange(checkbox) {
|
||||
const key = checkbox.value;
|
||||
const isChooseOne = checkbox.dataset.isChooseOne === "true";
|
||||
|
||||
if (checkbox.checked) {
|
||||
if (this.checkedOptionKeys.size >= this.optionPicks) {
|
||||
checkbox.checked = false;
|
||||
return;
|
||||
}
|
||||
this.checkedOptionKeys.add(key);
|
||||
if (isChooseOne) {
|
||||
const nameInput = this.#getOptionChooseOneInput(key);
|
||||
if (nameInput) nameInput.disabled = false;
|
||||
}
|
||||
} else {
|
||||
this.checkedOptionKeys.delete(key);
|
||||
if (isChooseOne) {
|
||||
const nameInput = this.#getOptionChooseOneInput(key);
|
||||
if (nameInput) {
|
||||
nameInput.disabled = true;
|
||||
nameInput.value = "";
|
||||
}
|
||||
delete this.chooseOneLabels[key];
|
||||
}
|
||||
}
|
||||
this.#refreshUi();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {HTMLInputElement | null}
|
||||
*/
|
||||
#getOptionChooseOneInput(key) {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return null;
|
||||
return root.querySelector(
|
||||
`[data-option-choose-one="true"][data-choose-one-key="${CSS.escape(
|
||||
key,
|
||||
)}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLSelectElement} select
|
||||
*/
|
||||
#onBonusChange(select) {
|
||||
const index = Number(select.dataset.bonusIndex);
|
||||
this.bonusCatalogIds[index] = select.value;
|
||||
|
||||
const root = this.#contentRoot();
|
||||
const typedInput = root?.querySelector(`[name="bonusTypedLabel-${index}"]`);
|
||||
const isTyped = select.value.startsWith("typed:");
|
||||
if (typedInput) {
|
||||
typedInput.classList.toggle("hidden", !isTyped);
|
||||
if (!isTyped) {
|
||||
typedInput.value = "";
|
||||
this.bonusTypedLabels[index] = "";
|
||||
}
|
||||
}
|
||||
this.#syncFormStateFromDom();
|
||||
this.#refreshUi();
|
||||
}
|
||||
|
||||
#syncFormStateFromDom() {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return;
|
||||
|
||||
root.querySelectorAll("[data-choose-one-key]").forEach((input) => {
|
||||
const key = input.dataset.chooseOneKey;
|
||||
if (key) this.chooseOneLabels[key] = input.value;
|
||||
});
|
||||
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const select = root.querySelector(`[name="bonusSkill-${i}"]`);
|
||||
this.bonusCatalogIds[i] = select?.value ?? "";
|
||||
const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`);
|
||||
this.bonusTypedLabels[i] = typedInput?.value ?? "";
|
||||
}
|
||||
|
||||
const bondCount = this.#bondCount();
|
||||
this.bondNames.length = bondCount;
|
||||
this.bondRelationships.length = bondCount;
|
||||
for (let i = 0; i < bondCount; i++) {
|
||||
this.bondNames[i] =
|
||||
root.querySelector(`[name="bondName-${i}"]`)?.value ?? "";
|
||||
this.bondRelationships[i] =
|
||||
root.querySelector(`[name="bondRelationship-${i}"]`)?.value ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
#restoreFormSelections() {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return;
|
||||
|
||||
for (const key of this.checkedOptionKeys) {
|
||||
const cb = root.querySelector(
|
||||
`[name="optionSkillChecked"][value="${CSS.escape(key)}"]`,
|
||||
);
|
||||
if (cb) cb.checked = true;
|
||||
if (this.optionSkillMeta[key]?.chooseOne) {
|
||||
const nameInput = this.#getOptionChooseOneInput(key);
|
||||
if (nameInput) {
|
||||
nameInput.disabled = false;
|
||||
nameInput.value = this.chooseOneLabels[key] ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of this.#buildAutomaticChooseOneRows()) {
|
||||
const nameInput = root.querySelector(
|
||||
`.character-creation-choose-one-section [data-choose-one-key="${CSS.escape(
|
||||
row.key,
|
||||
)}"]`,
|
||||
);
|
||||
if (nameInput) nameInput.value = this.chooseOneLabels[row.key] ?? "";
|
||||
}
|
||||
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const select = root.querySelector(`[name="bonusSkill-${i}"]`);
|
||||
if (select) select.value = this.bonusCatalogIds[i] ?? "";
|
||||
const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`);
|
||||
const isTyped = (this.bonusCatalogIds[i] ?? "").startsWith("typed:");
|
||||
if (typedInput) {
|
||||
typedInput.classList.toggle("hidden", !isTyped);
|
||||
typedInput.value = this.bonusTypedLabels[i] ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<computeSkillValues>} computed
|
||||
* @returns {{
|
||||
* validationMessages: string[],
|
||||
* capWarnings: { warning: string }[],
|
||||
* wasteMessages: string[],
|
||||
* }}
|
||||
*/
|
||||
#getDisplayMessages(computed) {
|
||||
const wastePrefix = "bonusWaste:";
|
||||
const mainErrors = computed.validationErrors.filter(
|
||||
(code) => !code.startsWith(wastePrefix),
|
||||
);
|
||||
const wasteErrors = computed.validationErrors.filter((code) =>
|
||||
code.startsWith(wastePrefix),
|
||||
);
|
||||
|
||||
return {
|
||||
validationMessages: formatProfessionValidationMessages(mainErrors, {
|
||||
optionPicks: this.optionPicks,
|
||||
}),
|
||||
capWarnings: computed.capWarnings.map((w) => ({
|
||||
warning: game.i18n.format("DG.Profession.Dialog.CapWarning", {
|
||||
skill: w.label,
|
||||
attempted: w.attempted,
|
||||
waste: w.waste,
|
||||
}),
|
||||
})),
|
||||
wasteMessages: formatProfessionValidationMessages(wasteErrors, {
|
||||
optionPicks: this.optionPicks,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<computeSkillValues>} computed
|
||||
* @returns {string}
|
||||
*/
|
||||
#buildMessagesHtml(computed) {
|
||||
const { validationMessages, capWarnings, wasteMessages } =
|
||||
this.#getDisplayMessages(computed);
|
||||
|
||||
const toErrorHtml = (messages) =>
|
||||
messages
|
||||
.map(
|
||||
(message) =>
|
||||
`<p class="dg-dialog__message--error">${foundry.utils.escapeHTML(
|
||||
message,
|
||||
)}</p>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
const capHtml = capWarnings
|
||||
.map(
|
||||
(w) =>
|
||||
`<p class="dg-dialog__message--warning">${foundry.utils.escapeHTML(
|
||||
w.warning,
|
||||
)}</p>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
toErrorHtml(validationMessages) + capHtml + toErrorHtml(wasteMessages)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tooltip
|
||||
* @returns {string}
|
||||
*/
|
||||
#tooltipAttr(tooltip) {
|
||||
if (!tooltip) return "";
|
||||
return ` data-tooltip="${foundry.utils.escapeHTML(tooltip)}"`;
|
||||
}
|
||||
|
||||
async #refreshUi() {
|
||||
const root = this.#contentRoot();
|
||||
if (!root) return;
|
||||
|
||||
this.#syncFormStateFromDom();
|
||||
|
||||
const computed = this.#compute();
|
||||
|
||||
const rows = this.#buildSkillDisplayRows(computed);
|
||||
|
||||
const body = root.querySelector("[data-profession-skills-body]");
|
||||
if (body) {
|
||||
body.innerHTML = rows
|
||||
.map((r) => {
|
||||
const modifiedClass = r.isModified ? " is-modified" : "";
|
||||
return `<div class="dg-dialog__skill-entry${modifiedClass}" data-skill-row="${foundry.utils.escapeHTML(
|
||||
r.rowId,
|
||||
)}"><span class="dg-dialog__skill-label"${this.#tooltipAttr(
|
||||
r.tooltip,
|
||||
)}>${foundry.utils.escapeHTML(
|
||||
r.label,
|
||||
)}</span><span class="dg-dialog__skill-value" data-skill-value>${
|
||||
r.value
|
||||
}%</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
applySkillTooltipDisplayMode(body);
|
||||
}
|
||||
|
||||
const messagesEl = root.querySelector("[data-profession-messages]");
|
||||
if (messagesEl) {
|
||||
messagesEl.innerHTML = this.#buildMessagesHtml(computed);
|
||||
}
|
||||
|
||||
const submitBtn = this.dialog?.element?.querySelector(
|
||||
'button[data-action="submit"]',
|
||||
);
|
||||
if (submitBtn) submitBtn.disabled = !computed.isValid;
|
||||
|
||||
this.#restoreFormSelections();
|
||||
}
|
||||
|
||||
async #onSubmit() {
|
||||
this.#syncFormStateFromDom();
|
||||
const computed = this.#compute();
|
||||
if (!computed.isValid) return;
|
||||
|
||||
const updateData = {};
|
||||
for (const [key, value] of Object.entries(computed.fixedValues)) {
|
||||
updateData[`system.skills.${key}.proficiency`] = value;
|
||||
updateData[`system.skills.${key}.failure`] = false;
|
||||
}
|
||||
|
||||
const typedSkills = { ...(this.actor.system.typedSkills ?? {}) };
|
||||
for (const [, data] of Object.entries(computed.typedValues)) {
|
||||
const id = foundry.utils.randomID();
|
||||
typedSkills[id] = {
|
||||
label: data.label,
|
||||
group: data.group,
|
||||
proficiency: data.value,
|
||||
failure: false,
|
||||
};
|
||||
}
|
||||
updateData["system.typedSkills"] = typedSkills;
|
||||
|
||||
updateData["system.biography.profession"] = this.professionItem.name;
|
||||
|
||||
await this.actor.update(updateData);
|
||||
|
||||
const chaScore =
|
||||
this.actor.system.statistics.cha.effectiveValue ??
|
||||
this.actor.system.statistics.cha.value;
|
||||
const bondCount = this.#bondCount();
|
||||
const bondDocs = [];
|
||||
|
||||
for (let i = 0; i < bondCount; i++) {
|
||||
const name = this.bondNames[i]?.trim() ?? "";
|
||||
const relationship = this.bondRelationships[i]?.trim() ?? "";
|
||||
|
||||
bondDocs.push({
|
||||
name,
|
||||
type: "bond",
|
||||
system: {
|
||||
score: chaScore,
|
||||
relationship,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.actor.createEmbeddedDocuments("Item", bondDocs);
|
||||
|
||||
this.submitted = true;
|
||||
await this.dialog?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config/index.js";
|
||||
import {
|
||||
STAT_KEYS,
|
||||
STAT_MAX,
|
||||
STAT_MIN,
|
||||
applyAgentStatistics,
|
||||
buildStatisticRows,
|
||||
getDefaultPointBuyValues,
|
||||
validatePointBuyValues,
|
||||
} from "../profession/stat-setup.js";
|
||||
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @typedef {'submitted' | 'cancelled'} AssignStatsResult */
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @param {Record<string, number>} values
|
||||
*/
|
||||
function syncValuesFromDom(dialog, values) {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
|
||||
for (const key of STAT_KEYS) {
|
||||
const input = root.querySelector(`[data-stat-key="${key}"]`);
|
||||
if (input) {
|
||||
let value = Number(input.value);
|
||||
if (!Number.isFinite(value)) value = STAT_MIN;
|
||||
values[key] = Math.clamp(Math.trunc(value), STAT_MIN, STAT_MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @param {Record<string, number>} values
|
||||
*/
|
||||
function refreshAssignStatsUi(dialog, values) {
|
||||
syncValuesFromDom(dialog, values);
|
||||
const { isValid, remaining } = validatePointBuyValues(values);
|
||||
|
||||
const root = getDialogContentRoot(dialog);
|
||||
const remainingEl = root?.querySelector("[data-points-remaining]");
|
||||
if (remainingEl) {
|
||||
remainingEl.textContent = game.i18n.format(
|
||||
"DG.ProfessionSetup.AssignStats.PointsRemaining",
|
||||
{ remaining },
|
||||
);
|
||||
remainingEl.classList.toggle("is-invalid", remaining !== 0);
|
||||
}
|
||||
|
||||
const submitBtn = dialog.element?.querySelector(
|
||||
'button[data-action="submit"]',
|
||||
);
|
||||
if (submitBtn) submitBtn.disabled = !isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @param {Record<string, number>} values
|
||||
*/
|
||||
function bindAssignStatsListeners(dialog, values) {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
|
||||
root.querySelectorAll("[data-stat-key]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const key = input.dataset.statKey;
|
||||
if (!key) return;
|
||||
|
||||
let value = Number(input.value);
|
||||
if (!Number.isFinite(value)) value = STAT_MIN;
|
||||
value = Math.clamp(Math.trunc(value), STAT_MIN, STAT_MAX);
|
||||
input.value = String(value);
|
||||
values[key] = value;
|
||||
refreshAssignStatsUi(dialog, values);
|
||||
});
|
||||
input.addEventListener("change", () =>
|
||||
refreshAssignStatsUi(dialog, values),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {Promise<AssignStatsResult>}
|
||||
*/
|
||||
export default async function showAssignStatsDialog(actor) {
|
||||
/** @type {Record<string, number>} */
|
||||
const values = getDefaultPointBuyValues();
|
||||
const { remaining } = validatePointBuyValues(values);
|
||||
/** @type {AssignStatsResult} */
|
||||
let result = "cancelled";
|
||||
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/assign-stats.html`,
|
||||
{
|
||||
stats: buildStatisticRows(),
|
||||
values,
|
||||
statMin: STAT_MIN,
|
||||
statMax: STAT_MAX,
|
||||
remaining,
|
||||
},
|
||||
);
|
||||
|
||||
return showDgDialog({
|
||||
modifier: "assign-stats",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ProfessionSetup.AssignStats.Title"),
|
||||
},
|
||||
position: { width: 420 },
|
||||
form: { closeOnSubmit: false },
|
||||
onRender: (dialog) => {
|
||||
bindAssignStatsListeners(dialog, values);
|
||||
refreshAssignStatsUi(dialog, values);
|
||||
},
|
||||
close: () => result,
|
||||
buttons: [
|
||||
{
|
||||
action: "cancel",
|
||||
label: game.i18n.localize("Cancel"),
|
||||
callback: async (_event, _button, dialog) => {
|
||||
result = "cancelled";
|
||||
await dialog.close();
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "submit",
|
||||
label: game.i18n.localize("DG.ProfessionSetup.AssignStats.Submit"),
|
||||
default: true,
|
||||
disabled: true,
|
||||
callback: async (_event, _button, dialog) => {
|
||||
syncValuesFromDom(dialog, values);
|
||||
const { isValid } = validatePointBuyValues(values);
|
||||
if (!isValid) return false;
|
||||
|
||||
await applyAgentStatistics(actor, { ...values });
|
||||
result = "submitted";
|
||||
await dialog.close();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import DG from "../config/index.js";
|
||||
import {
|
||||
buildEffectFieldSelectOptions,
|
||||
getEffectFieldGroupsForDocument,
|
||||
} from "../active-effect/effect-fields.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { ActiveEffectConfig } = foundry.applications.sheets;
|
||||
|
||||
const DG_CHANGE_TYPES = ["add", "subtract", "override"];
|
||||
|
||||
/** @extends {foundry.applications.sheets.ActiveEffectConfig} */
|
||||
export default class DGActiveEffectConfig extends ActiveEffectConfig {
|
||||
/** @type {Record<string, string>|null} */
|
||||
#dgChangeTypes = null;
|
||||
|
||||
/** @type {Array<object>|null} */
|
||||
#fieldGroups = null;
|
||||
|
||||
/** @returns {Record<string, string>} */
|
||||
#getDgChangeTypes() {
|
||||
if (!this.#dgChangeTypes) {
|
||||
this.#dgChangeTypes = DG_CHANGE_TYPES.reduce((types, type) => {
|
||||
const label = ActiveEffect.CHANGE_TYPES[type]?.label;
|
||||
types[type] = label ? game.i18n.localize(label) : type;
|
||||
return types;
|
||||
}, {});
|
||||
}
|
||||
return this.#dgChangeTypes;
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} */
|
||||
#getFieldGroups() {
|
||||
if (!this.#fieldGroups) {
|
||||
const groups = getEffectFieldGroupsForDocument(this.document);
|
||||
this.#fieldGroups = buildEffectFieldSelectOptions(groups);
|
||||
}
|
||||
return this.#fieldGroups;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_processChangeSubmission(change, index) {
|
||||
super._processChangeSubmission(change, index);
|
||||
if (!change.phase) change.phase = "final";
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _renderChange(context) {
|
||||
const { change, index } = context;
|
||||
if (typeof change.value !== "string") {
|
||||
change.value = JSON.stringify(change.value);
|
||||
}
|
||||
if (!change.phase) change.phase = "final";
|
||||
|
||||
Object.assign(
|
||||
change,
|
||||
["key", "type", "value", "phase", "priority"].reduce(
|
||||
(paths, fieldName) => {
|
||||
paths[`${fieldName}Path`] = `system.changes.${index}.${fieldName}`;
|
||||
return paths;
|
||||
},
|
||||
{},
|
||||
),
|
||||
);
|
||||
|
||||
return renderTemplate(
|
||||
`systems/${DG.ID}/templates/active-effect/change-row.html`,
|
||||
{
|
||||
...context,
|
||||
fieldGroups: this.#getFieldGroups(),
|
||||
dgChangeTypes: this.#getDgChangeTypes(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import DG from "../config/index.js";
|
||||
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
|
||||
/** @type {readonly string[]} */
|
||||
export const DG_DIALOG_APP_CLASSES = ["deltagreen", "dg-dialog-app"];
|
||||
|
||||
/**
|
||||
* @returns {"program-style" | "cowboy-style" | "outlaw-style"}
|
||||
*/
|
||||
export function getCharacterSheetThemeClass() {
|
||||
const theme = game.settings.get(DG.ID, "characterSheetStyle");
|
||||
return `${theme}-style`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {HTMLElement | null}
|
||||
*/
|
||||
export function getDialogContentRoot(dialog) {
|
||||
return (
|
||||
dialog.element?.querySelector(".dialog-content") ??
|
||||
dialog.element?.querySelector(".dialog-form") ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applyDialogTheme(dialog) {
|
||||
const windowContent = dialog.element?.querySelector("section.window-content");
|
||||
if (!windowContent) return;
|
||||
windowContent.classList.add(getCharacterSheetThemeClass());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [modifier]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function buildDialogAppClasses(modifier) {
|
||||
const classes = [...DG_DIALOG_APP_CLASSES];
|
||||
if (modifier) classes.push(`dg-dialog-app--${modifier}`);
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @param {string} tabId
|
||||
* @returns {void}
|
||||
*/
|
||||
export function activateDialogTab(root, tabId) {
|
||||
root.querySelectorAll(".tabs [data-action='tab']").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tab === tabId);
|
||||
});
|
||||
root.querySelectorAll(".tab[data-tab]").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tab === tabId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} root
|
||||
* @returns {void}
|
||||
*/
|
||||
export function bindDialogTabs(root) {
|
||||
root.querySelectorAll(".tabs [data-action='tab']").forEach((tab) => {
|
||||
tab.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
const tabId = tab.dataset.tab;
|
||||
if (tabId) activateDialogTab(root, tabId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options — DialogV2.wait options plus DG extensions
|
||||
* @param {string} [options.modifier] — app class modifier: dg-dialog-app--{modifier}
|
||||
* @param {(dialog: DialogV2, event: Event) => void | Promise<void>} [options.onRender]
|
||||
* @param {string[]} [options.classes] — extra app classes merged after base
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
export function showDgDialog(options) {
|
||||
const {
|
||||
modifier,
|
||||
onRender,
|
||||
classes: extraClasses = [],
|
||||
render: userRender,
|
||||
...dialogOptions
|
||||
} = options;
|
||||
|
||||
const classes = [...buildDialogAppClasses(modifier), ...extraClasses];
|
||||
|
||||
return DialogV2.wait({
|
||||
...dialogOptions,
|
||||
classes,
|
||||
render: async (event, dialog) => {
|
||||
applyDialogTheme(dialog);
|
||||
if (userRender) await userRender(event, dialog);
|
||||
if (onRender) await onRender(dialog, event);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
@@ -33,6 +33,8 @@ export default class ActorEditStatForm extends foundry.applications.api.Handleba
|
||||
return {
|
||||
...(await super._prepareContext(options)),
|
||||
actor: this.actor,
|
||||
sourceStatistics:
|
||||
this.actor._source?.system?.statistics ?? this.actor.system.statistics,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config/index.js";
|
||||
import { showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @typedef {'roll' | 'assign' | 'skip'} PickStatisticsChoice */
|
||||
|
||||
/**
|
||||
* @returns {Promise<PickStatisticsChoice | null>}
|
||||
*/
|
||||
export default async function showPickStatisticsDialog() {
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/pick-statistics.html`,
|
||||
{},
|
||||
);
|
||||
|
||||
return showDgDialog({
|
||||
modifier: "pick-statistics",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ProfessionSetup.PickStatistics.Title"),
|
||||
},
|
||||
position: { width: 480 },
|
||||
close: () => null,
|
||||
buttons: [
|
||||
{
|
||||
action: "roll",
|
||||
label: game.i18n.localize(
|
||||
"DG.ProfessionSetup.PickStatistics.RollStats",
|
||||
),
|
||||
tooltip: game.i18n.localize(
|
||||
"DG.ProfessionSetup.PickStatistics.RollStatsTooltip",
|
||||
),
|
||||
callback: () => "roll",
|
||||
},
|
||||
{
|
||||
action: "assign",
|
||||
label: game.i18n.localize(
|
||||
"DG.ProfessionSetup.PickStatistics.AssignStats",
|
||||
),
|
||||
tooltip: game.i18n.localize(
|
||||
"DG.ProfessionSetup.PickStatistics.AssignStatsTooltip",
|
||||
),
|
||||
callback: () => "assign",
|
||||
},
|
||||
{
|
||||
action: "skip",
|
||||
label: game.i18n.localize(
|
||||
"DG.ProfessionSetup.PickStatistics.LeaveStatsAlone",
|
||||
),
|
||||
callback: () => "skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import showPickSkillsDialog from "./add-profession-dialog.js";
|
||||
import showAssignStatsDialog from "./assign-stats-dialog.js";
|
||||
import showPickStatisticsDialog from "./pick-statistics-dialog.js";
|
||||
import showRollStatsDialog from "./roll-stats-dialog.js";
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {object} itemData
|
||||
* @returns {Item}
|
||||
*/
|
||||
function createProfessionStub(actor, itemData) {
|
||||
const data = foundry.utils.duplicate(itemData);
|
||||
delete data._id;
|
||||
const ItemDocument = foundry.utils.getDocumentClass("Item");
|
||||
return new ItemDocument(data, { parent: actor });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full profession setup flow: Pick Statistics → optional stat setup → Character Creation.
|
||||
*
|
||||
* @param {Item} professionItem Profession item or unsaved stub on the actor
|
||||
* @param {Actor} actor
|
||||
* @param {object} [options]
|
||||
* @param {TokenDocument|null} [options.token]
|
||||
* @returns {Promise<boolean>} True if Character Creation was submitted successfully.
|
||||
*/
|
||||
async function runProfessionSetupFlow(
|
||||
professionItem,
|
||||
actor,
|
||||
{ token = null } = {},
|
||||
) {
|
||||
let statsComplete = false;
|
||||
|
||||
while (!statsComplete) {
|
||||
const choice = await showPickStatisticsDialog();
|
||||
if (choice === null) return false;
|
||||
|
||||
if (choice === "skip") {
|
||||
statsComplete = true;
|
||||
} else if (choice === "roll") {
|
||||
const result = await showRollStatsDialog(actor, { token });
|
||||
if (result === "submitted") statsComplete = true;
|
||||
} else if (choice === "assign") {
|
||||
const result = await showAssignStatsDialog(actor);
|
||||
if (result === "submitted") statsComplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
return showPickSkillsDialog(professionItem, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a profession to an agent: full setup flow, then embed the item only on success.
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @param {object} itemData Item source data (e.g. from drop `toObject()` or create template)
|
||||
* @param {object} [options]
|
||||
* @param {TokenDocument|null} [options.token]
|
||||
* @returns {Promise<Item|null>}
|
||||
*/
|
||||
export default async function assignProfessionToAgent(
|
||||
actor,
|
||||
itemData,
|
||||
{ token = null } = {},
|
||||
) {
|
||||
if (actor.items.some((i) => i.type === "profession")) {
|
||||
ui.notifications.info(game.i18n.localize("DG.Profession.AlreadyAssigned"), {
|
||||
localize: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const stub = createProfessionStub(actor, itemData);
|
||||
const submitted = await runProfessionSetupFlow(stub, actor, { token });
|
||||
if (!submitted) return null;
|
||||
|
||||
const createData = stub.toObject();
|
||||
delete createData._id;
|
||||
const created = await actor.createEmbeddedDocuments("Item", [createData]);
|
||||
return created[0] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config/index.js";
|
||||
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/**
|
||||
* @param {Item} professionItem
|
||||
* @returns {Promise<boolean>} True if the name was updated.
|
||||
*/
|
||||
export default async function showRenameProfessionDialog(professionItem) {
|
||||
if (professionItem.type !== "profession") return false;
|
||||
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/rename-profession.html`,
|
||||
{ name: professionItem.name },
|
||||
);
|
||||
|
||||
const result = await showDgDialog({
|
||||
modifier: "rename-profession",
|
||||
content,
|
||||
position: { width: 360 },
|
||||
window: {
|
||||
title: game.i18n.localize("DG.AgentSheet.RenameProfession.Title"),
|
||||
},
|
||||
default: "submit",
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
action: "submit",
|
||||
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
|
||||
callback: (_event, _button, dialog) => {
|
||||
const name = getDialogContentRoot(dialog)
|
||||
?.querySelector("[name='profession-rename']")
|
||||
?.value?.trim();
|
||||
return name || null;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!result || result === professionItem.name) return false;
|
||||
|
||||
await professionItem.update({ name: result });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../config/index.js";
|
||||
import { createDGChatMessage } from "../chat/dg-chat-card.js";
|
||||
import {
|
||||
STAT_KEYS,
|
||||
applyAgentStatistics,
|
||||
buildRollStatsChatContent,
|
||||
buildStatisticRows,
|
||||
rollStatisticScores,
|
||||
} from "../profession/stat-setup.js";
|
||||
import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @typedef {'submitted' | 'cancelled'} RollStatsResult */
|
||||
|
||||
/**
|
||||
* @param {object} state
|
||||
* @param {DialogV2} dialog
|
||||
* @param {number|null} rollIndex
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function findStatKeyForRollIndex(state, rollIndex) {
|
||||
for (const key of STAT_KEYS) {
|
||||
if (state.assignments[key] === rollIndex) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} state
|
||||
* @param {DialogV2} dialog
|
||||
*/
|
||||
function syncDomFromAssignments(state, dialog) {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
|
||||
const pool = root.querySelector("[data-roll-stats-pool]");
|
||||
if (!pool) return;
|
||||
|
||||
root.querySelectorAll(".roll-stat-token").forEach((token) => {
|
||||
pool.appendChild(token);
|
||||
});
|
||||
|
||||
for (const key of STAT_KEYS) {
|
||||
const rollIndex = state.assignments[key];
|
||||
if (rollIndex !== null) {
|
||||
const slot = root.querySelector(
|
||||
`[data-stat-slot][data-stat-key="${key}"]`,
|
||||
);
|
||||
const token = pool.querySelector(`[data-roll-index="${rollIndex}"]`);
|
||||
if (slot && token) {
|
||||
slot.appendChild(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} state
|
||||
* @param {DialogV2} dialog
|
||||
*/
|
||||
function refreshRollStatsUi(state, dialog) {
|
||||
const submitBtn = dialog.element?.querySelector(
|
||||
'button[data-action="submit"]',
|
||||
);
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = !STAT_KEYS.every(
|
||||
(key) => state.assignments[key] !== null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} state
|
||||
* @param {DialogV2} dialog
|
||||
*/
|
||||
function bindRollStatsListeners(state, dialog) {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
if (!root) return;
|
||||
if (root.dataset.rollStatsListenersBound === "true") return;
|
||||
root.dataset.rollStatsListenersBound = "true";
|
||||
|
||||
root.querySelectorAll(".roll-stat-token").forEach((token) => {
|
||||
token.addEventListener("dragstart", (event) => {
|
||||
const rollIndex = Number(token.dataset.rollIndex);
|
||||
state.dragRollIndex = rollIndex;
|
||||
token.classList.add("is-dragging");
|
||||
event.dataTransfer?.setData("text/plain", String(rollIndex));
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
token.addEventListener("dragend", () => {
|
||||
token.classList.remove("is-dragging");
|
||||
state.dragRollIndex = null;
|
||||
root
|
||||
.querySelectorAll(".drag-over")
|
||||
.forEach((el) => el.classList.remove("drag-over"));
|
||||
});
|
||||
});
|
||||
|
||||
const readDragRollIndex = (event) => {
|
||||
const fromData = event.dataTransfer?.getData("text/plain");
|
||||
if (fromData !== undefined && fromData !== "") {
|
||||
const parsed = Number(fromData);
|
||||
if (Number.isInteger(parsed)) return parsed;
|
||||
}
|
||||
if (state.dragRollIndex !== null) return state.dragRollIndex;
|
||||
return null;
|
||||
};
|
||||
|
||||
root.querySelectorAll("[data-stat-slot]").forEach((slot) => {
|
||||
slot.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
slot.classList.add("drag-over");
|
||||
});
|
||||
slot.addEventListener("dragleave", () =>
|
||||
slot.classList.remove("drag-over"),
|
||||
);
|
||||
slot.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
slot.classList.remove("drag-over");
|
||||
|
||||
const rollIndex = readDragRollIndex(event);
|
||||
if (rollIndex === null) return;
|
||||
|
||||
const { statKey } = slot.dataset;
|
||||
if (!statKey) return;
|
||||
|
||||
const sourceStatKey = findStatKeyForRollIndex(state, rollIndex);
|
||||
const displacedIndex = state.assignments[statKey];
|
||||
|
||||
if (sourceStatKey === statKey) return;
|
||||
|
||||
if (sourceStatKey) {
|
||||
state.assignments[sourceStatKey] = null;
|
||||
}
|
||||
|
||||
if (displacedIndex !== null && displacedIndex !== rollIndex) {
|
||||
if (sourceStatKey) {
|
||||
state.assignments[sourceStatKey] = displacedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
state.assignments[statKey] = rollIndex;
|
||||
syncDomFromAssignments(state, dialog);
|
||||
refreshRollStatsUi(state, dialog);
|
||||
});
|
||||
});
|
||||
|
||||
const pool = root.querySelector("[data-roll-stats-pool]");
|
||||
if (!pool) return;
|
||||
|
||||
pool.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
pool.classList.add("drag-over");
|
||||
});
|
||||
pool.addEventListener("dragleave", (event) => {
|
||||
if (
|
||||
event.currentTarget === event.target ||
|
||||
!event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
pool.classList.remove("drag-over");
|
||||
}
|
||||
});
|
||||
pool.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
pool.classList.remove("drag-over");
|
||||
|
||||
const rollIndex = readDragRollIndex(event);
|
||||
if (rollIndex === null) return;
|
||||
|
||||
const sourceStatKey = findStatKeyForRollIndex(state, rollIndex);
|
||||
if (sourceStatKey) {
|
||||
state.assignments[sourceStatKey] = null;
|
||||
syncDomFromAssignments(state, dialog);
|
||||
refreshRollStatsUi(state, dialog);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {object} [options]
|
||||
* @param {TokenDocument|null} [options.token]
|
||||
* @returns {Promise<RollStatsResult>}
|
||||
*/
|
||||
export default async function showRollStatsDialog(
|
||||
actor,
|
||||
{ token = null } = {},
|
||||
) {
|
||||
const rolled = await rollStatisticScores({ actor, token });
|
||||
const rolls = rolled.map((entry, index) => ({
|
||||
index,
|
||||
total: entry.total,
|
||||
}));
|
||||
|
||||
await createDGChatMessage({
|
||||
actor,
|
||||
token,
|
||||
label: game.i18n.localize("DG.ProfessionSetup.RollStats.ChatLabel"),
|
||||
content: buildRollStatsChatContent(rolls),
|
||||
});
|
||||
|
||||
const state = {
|
||||
rolls,
|
||||
assignments: Object.fromEntries(STAT_KEYS.map((key) => [key, null])),
|
||||
dragRollIndex: null,
|
||||
};
|
||||
/** @type {RollStatsResult} */
|
||||
let result = "cancelled";
|
||||
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/roll-stats.html`,
|
||||
{
|
||||
rolls: state.rolls,
|
||||
stats: buildStatisticRows(),
|
||||
},
|
||||
);
|
||||
|
||||
return showDgDialog({
|
||||
modifier: "roll-stats",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ProfessionSetup.RollStats.Title"),
|
||||
},
|
||||
position: { width: 520 },
|
||||
form: { closeOnSubmit: false },
|
||||
onRender: (dialog) => {
|
||||
bindRollStatsListeners(state, dialog);
|
||||
refreshRollStatsUi(state, dialog);
|
||||
},
|
||||
close: () => result,
|
||||
buttons: [
|
||||
{
|
||||
action: "cancel",
|
||||
label: game.i18n.localize("Cancel"),
|
||||
callback: async (_event, _button, dialog) => {
|
||||
result = "cancelled";
|
||||
await dialog.close();
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "submit",
|
||||
label: game.i18n.localize("DG.ProfessionSetup.RollStats.Submit"),
|
||||
default: true,
|
||||
disabled: true,
|
||||
callback: async (_event, _button, dialog) => {
|
||||
const isComplete = STAT_KEYS.every(
|
||||
(key) => state.assignments[key] !== null,
|
||||
);
|
||||
if (!isComplete) return false;
|
||||
|
||||
/** @type {Record<string, number>} */
|
||||
const valuesByKey = {};
|
||||
for (const key of STAT_KEYS) {
|
||||
const rollIndex = state.assignments[key];
|
||||
const roll = state.rolls.find((entry) => entry.index === rollIndex);
|
||||
if (!roll) return false;
|
||||
valuesByKey[key] = roll.total;
|
||||
}
|
||||
|
||||
await applyAgentStatistics(actor, valuesByKey);
|
||||
result = "submitted";
|
||||
await dialog.close();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
/* global TokenDocument */
|
||||
|
||||
import DG from "../config/index.js";
|
||||
|
||||
const CHAT_CARD_TEMPLATE = "systems/deltagreen/templates/chat/dg-chat-card.hbs";
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/**
|
||||
* Resolve a TokenDocument from sheet/macro context.
|
||||
* @param {TokenDocument|object|string|null} token
|
||||
* @param {Scene|SceneDocument|string|null} [scene]
|
||||
* @returns {TokenDocument|null}
|
||||
*/
|
||||
export function resolveDGTokenDocument(token, scene = null) {
|
||||
if (!token) return null;
|
||||
if (token.documentName === "Token" || token instanceof TokenDocument) {
|
||||
return token;
|
||||
}
|
||||
if (token.document?.documentName === "Token") {
|
||||
return token.document;
|
||||
}
|
||||
const tokenId = typeof token === "string" ? token : token.id ?? token._id;
|
||||
if (!tokenId) return null;
|
||||
|
||||
const sceneId =
|
||||
typeof scene === "string"
|
||||
? scene
|
||||
: scene?.id ?? token.parent?.id ?? canvas.scene?.id;
|
||||
const sceneDoc = sceneId ? game.scenes.get(sceneId) : canvas.scene;
|
||||
return (
|
||||
sceneDoc?.tokens.get(tokenId) ??
|
||||
canvas.tokens.get(tokenId)?.document ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Speaker for Delta Green chat; uses token name for unlinked tokens.
|
||||
* @param {object} params
|
||||
* @param {Actor|null} [params.actor]
|
||||
* @param {TokenDocument|object|string|null} [params.token]
|
||||
* @param {Scene|SceneDocument|string|null} [params.scene]
|
||||
* @returns {ChatMessage.SpeakerData}
|
||||
*/
|
||||
export function getDGSpeaker({
|
||||
actor = null,
|
||||
token = null,
|
||||
scene = null,
|
||||
} = {}) {
|
||||
const tokenDoc = resolveDGTokenDocument(token, scene);
|
||||
const sceneDoc = scene ?? tokenDoc?.parent ?? canvas.scene ?? null;
|
||||
const alias = tokenDoc?.name ?? actor?.name;
|
||||
|
||||
return ChatMessage.getSpeaker({
|
||||
actor,
|
||||
token: tokenDoc,
|
||||
scene: sceneDoc,
|
||||
alias,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Token document from an existing chat speaker payload.
|
||||
* @param {ChatMessage.SpeakerData|null} speaker
|
||||
* @returns {TokenDocument|null}
|
||||
*/
|
||||
export function getTokenFromChatSpeaker(speaker) {
|
||||
if (!speaker?.token) return null;
|
||||
return resolveDGTokenDocument(speaker.token, speaker.scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best token for a roll from sheet or macro context.
|
||||
* @param {Actor|null} actor
|
||||
* @param {TokenDocument|object|string|null} [sheetToken]
|
||||
* @returns {TokenDocument|null}
|
||||
*/
|
||||
export function getDGRollToken(actor, sheetToken = null) {
|
||||
const fromSheet = resolveDGTokenDocument(sheetToken);
|
||||
if (fromSheet) return fromSheet;
|
||||
|
||||
if (!actor) return null;
|
||||
|
||||
const controlled = canvas.tokens?.controlled?.find((t) => t.actor === actor);
|
||||
if (controlled?.document) return controlled.document;
|
||||
|
||||
const active = actor.getActiveTokens?.(true, true)?.[0];
|
||||
return active ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ChatMessage.SpeakerData|null} speaker
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getDGChatSpeakerName(speaker) {
|
||||
if (!speaker) {
|
||||
return game.i18n.localize("CHAT.UnknownUser");
|
||||
}
|
||||
return speaker.alias ?? game.i18n.localize("CHAT.UnknownUser");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {string} params.speakerName
|
||||
* @param {string} [params.label]
|
||||
* @param {string} params.content
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function renderDGChatCard({ speakerName, label = "", content }) {
|
||||
return renderTemplate(CHAT_CARD_TEMPLATE, {
|
||||
speakerName,
|
||||
label,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether HTML already includes an inline dice roll block.
|
||||
* @param {string} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function contentIncludesRollDisplay(content) {
|
||||
return typeof content === "string" && content.includes("dice-roll");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render roll HTML for embedding in a chat card body.
|
||||
* @param {Roll[]} rolls
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function renderRollsHTML(rolls) {
|
||||
let html = "";
|
||||
for (const roll of rolls) {
|
||||
if (!roll._evaluated) await roll.evaluate();
|
||||
html += await roll.render();
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap message content in the Delta Green chat card shell.
|
||||
* @param {object} messageData
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.label] Card subtitle (falls back to flavor)
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function wrapDGChatMessageData(messageData, { label } = {}) {
|
||||
const labelText = label ?? messageData.flavor;
|
||||
if (!labelText) return messageData;
|
||||
|
||||
const speakerName = getDGChatSpeakerName(messageData.speaker);
|
||||
const wrappedContent = await renderDGChatCard({
|
||||
speakerName,
|
||||
label: labelText,
|
||||
content: messageData.content ?? "",
|
||||
});
|
||||
|
||||
const data = foundry.utils.deepClone(messageData);
|
||||
data.content = wrappedContent;
|
||||
data.flavor = "";
|
||||
foundry.utils.setProperty(data, `flags.${DG.ID}.chatCard`, true);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chat message data for a roll with optional Delta Green card wrapping.
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {Roll} params.roll
|
||||
* @param {Actor|null} [params.actor]
|
||||
* @param {TokenDocument|null} [params.token]
|
||||
* @param {Scene|SceneDocument|null} [params.scene]
|
||||
* @param {string} [params.label]
|
||||
* @param {string} [params.content]
|
||||
* @param {string} [params.messageMode]
|
||||
* @param {object} [params.flags]
|
||||
* @returns {Promise<{ messageData: object, mappedMode: string }>}
|
||||
*/
|
||||
export async function prepareDGRollChatMessageData({
|
||||
roll,
|
||||
actor = null,
|
||||
token = null,
|
||||
scene = null,
|
||||
label = "",
|
||||
content,
|
||||
messageMode,
|
||||
flags = {},
|
||||
}) {
|
||||
const mode = messageMode ?? game.settings.get("core", "messageMode");
|
||||
const mappedMode = foundry.dice.Roll._mapLegacyRollMode(mode);
|
||||
|
||||
if (!roll._evaluated) {
|
||||
await roll.evaluate({ allowInteractive: mappedMode !== "blind" });
|
||||
}
|
||||
|
||||
const speaker = getDGSpeaker({
|
||||
actor: actor ?? roll.actor ?? null,
|
||||
token: token ?? roll.options?.token ?? null,
|
||||
scene,
|
||||
});
|
||||
|
||||
let bodyContent = content ?? String(roll.total);
|
||||
|
||||
// Foundry only auto-injects roll HTML when content has no child elements.
|
||||
// DG chat cards always wrap content in a <section>, so embed rolls in the body.
|
||||
if (label && !contentIncludesRollDisplay(bodyContent)) {
|
||||
bodyContent = `${bodyContent}${await renderRollsHTML([roll])}`;
|
||||
}
|
||||
|
||||
let messageData = {
|
||||
author: game.user.id,
|
||||
speaker,
|
||||
content: bodyContent,
|
||||
sound: CONFIG.sounds.dice,
|
||||
rolls: [roll],
|
||||
flags: foundry.utils.mergeObject(
|
||||
{ [DG.ID]: { chatCard: Boolean(label) } },
|
||||
flags,
|
||||
),
|
||||
};
|
||||
|
||||
if (label) {
|
||||
messageData = await wrapDGChatMessageData(messageData, { label });
|
||||
}
|
||||
|
||||
return { messageData, mappedMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a roll chat message with the Delta Green card shell and an explicit rolls array.
|
||||
* Matches the Dolmenwood/DSN pattern: styled content in `content`, Roll objects in `rolls`.
|
||||
*
|
||||
* @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration
|
||||
* @param {object} params
|
||||
* @param {Roll} params.roll Evaluated or unevaluated roll to attach to the message.
|
||||
* @param {Actor|null} [params.actor]
|
||||
* @param {TokenDocument|null} [params.token]
|
||||
* @param {Scene|SceneDocument|null} [params.scene]
|
||||
* @param {string} [params.label] Card subtitle
|
||||
* @param {string} [params.content] Card body HTML
|
||||
* @param {string} [params.messageMode]
|
||||
* @param {object} [params.flags]
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export async function createDGRollChatMessage(params) {
|
||||
const { messageData, mappedMode } =
|
||||
await prepareDGRollChatMessageData(params);
|
||||
|
||||
const ChatMessageDocument = foundry.utils.getDocumentClass("ChatMessage");
|
||||
const msg = new ChatMessageDocument(messageData);
|
||||
msg.applyMode(mappedMode);
|
||||
return ChatMessageDocument.create(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {Actor} params.actor
|
||||
* @param {TokenDocument|null} [params.token]
|
||||
* @param {Scene|SceneDocument|null} [params.scene]
|
||||
* @param {string} [params.label] Card subtitle
|
||||
* @param {string} params.content Card body HTML
|
||||
* @param {string} [params.messageMode]
|
||||
* @param {object} [params.flags]
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export async function createDGChatMessage({
|
||||
actor,
|
||||
token = null,
|
||||
scene = null,
|
||||
label = "",
|
||||
content,
|
||||
messageMode,
|
||||
flags = {},
|
||||
}) {
|
||||
const speaker = getDGSpeaker({ actor, token, scene });
|
||||
const speakerName = getDGChatSpeakerName(speaker);
|
||||
const wrappedContent = await renderDGChatCard({
|
||||
speakerName,
|
||||
label,
|
||||
content,
|
||||
});
|
||||
|
||||
return ChatMessage.create({
|
||||
speaker,
|
||||
content: wrappedContent,
|
||||
flavor: "",
|
||||
messageMode: messageMode ?? game.settings.get("core", "messageMode"),
|
||||
flags: foundry.utils.mergeObject({ [DG.ID]: { chatCard: true } }, flags),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createDGRollChatMessage } from "./dg-chat-card.js";
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {Actor} params.actor
|
||||
* @param {TokenDocument|null} [params.token]
|
||||
* @param {string} params.contentKey i18n key for message body.
|
||||
* @param {string} params.labelKey i18n key for card subtitle.
|
||||
* @param {Record<string, string|number>} 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.
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export default async function createAgentResourceChatMessage({
|
||||
actor,
|
||||
token = null,
|
||||
contentKey,
|
||||
labelKey,
|
||||
i18nData,
|
||||
roll,
|
||||
extraContentKey = null,
|
||||
}) {
|
||||
let content = game.i18n.format(contentKey, i18nData);
|
||||
if (extraContentKey) {
|
||||
content = `${content}<br>${game.i18n.format(extraContentKey, i18nData)}`;
|
||||
}
|
||||
const label = game.i18n.localize(labelKey);
|
||||
const messageMode = game.settings.get("core", "messageMode");
|
||||
|
||||
return createDGRollChatMessage({
|
||||
actor,
|
||||
token,
|
||||
roll,
|
||||
label,
|
||||
content,
|
||||
messageMode,
|
||||
});
|
||||
}
|
||||
@@ -47,7 +47,6 @@ const DG = /** @type {const} */ ({
|
||||
"swim",
|
||||
"unarmed_combat",
|
||||
"unnatural",
|
||||
"ritual",
|
||||
],
|
||||
|
||||
skillImprovementFormulas: {
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Rich-text field specs keyed by actor sheet part id.
|
||||
* @type {Record<string, Record<string, { path: string, key: string }[]>>}
|
||||
*/
|
||||
const ACTOR_RICH_TEXT_BY_PART = {
|
||||
agent: {
|
||||
rightBar: [{ path: "physical.description", key: "description" }],
|
||||
},
|
||||
npc: {
|
||||
skills: [{ path: "notes", key: "description" }],
|
||||
},
|
||||
unnatural: {
|
||||
skills: [{ path: "notes", key: "description" }],
|
||||
},
|
||||
vehicle: {
|
||||
notes: [{ path: "description", key: "description" }],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Rich-text field specs keyed by item sheet part id.
|
||||
* @type {Record<string, { path: string, key: string, types?: string[] }[]>}
|
||||
*/
|
||||
const ITEM_RICH_TEXT_BY_PART = {
|
||||
description: [{ path: "description", key: "description" }],
|
||||
handler: [
|
||||
{
|
||||
path: "handlerNotes",
|
||||
key: "handlerNotes",
|
||||
types: ["tome", "ritual"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {"Actor"|"Item"} documentName
|
||||
* @param {string} partId
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.actorType]
|
||||
* @param {string} [options.itemType]
|
||||
* @param {boolean} [options.showNotesInSkills]
|
||||
* @returns {{ path: string, key: string }[]}
|
||||
*/
|
||||
export default function getRichTextFieldsForPart(
|
||||
documentName,
|
||||
partId,
|
||||
{ actorType, itemType, showNotesInSkills = true } = {},
|
||||
) {
|
||||
if (documentName === "Actor") {
|
||||
if (partId === "skills" && !showNotesInSkills) return [];
|
||||
return ACTOR_RICH_TEXT_BY_PART[actorType]?.[partId] ?? [];
|
||||
}
|
||||
|
||||
if (documentName === "Item") {
|
||||
return (ITEM_RICH_TEXT_BY_PART[partId] ?? []).filter(
|
||||
(spec) => !spec.types || spec.types.includes(itemType),
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
+56
-10
@@ -1,20 +1,35 @@
|
||||
import HumanSkillsActorData from "./base/human-skills.js";
|
||||
import CharacterData from "./base/character.js";
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
import { agentResourceField } from "./base/general.js";
|
||||
import {
|
||||
computeEquippedArmorProtection,
|
||||
initializeSanityIfUnset,
|
||||
prepareSanityAdaptations,
|
||||
prepareStatisticsX5,
|
||||
removeLegacyRitualSkill,
|
||||
} from "../derived/actor-derived.js";
|
||||
|
||||
const { SchemaField, NumberField, StringField, BooleanField, HTMLField } =
|
||||
const { SchemaField, NumberField, StringField, BooleanField } =
|
||||
foundry.data.fields;
|
||||
|
||||
export default class AgentData extends CharacterData {
|
||||
static defineSchema() {
|
||||
const superData = super.defineSchema();
|
||||
|
||||
const { health: _health, wp: _wp, ...restSuper } = superData;
|
||||
|
||||
return {
|
||||
...superData,
|
||||
...restSuper,
|
||||
health: agentResourceField(10, 10),
|
||||
wp: agentResourceField(10, 10),
|
||||
...HumanSkillsActorData.defineSchema(),
|
||||
schemaVersion: new NumberField({ initial: 1.0 }),
|
||||
schemaVersion: new NumberField({ initial: 2.0 }),
|
||||
sanity: new SchemaField({
|
||||
value: new NumberField({ initial: 100 }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101 }),
|
||||
value: new NumberField({ initial: 100, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: 99, min: 0, integer: true }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101, integer: true }),
|
||||
maxBonus: new NumberField({ initial: 0, integer: true }),
|
||||
adaptations: new SchemaField({
|
||||
violence: new SchemaField({
|
||||
incident1: new BooleanField({ initial: false }),
|
||||
@@ -28,16 +43,18 @@ export default class AgentData extends CharacterData {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
rollTarget: new SchemaField({
|
||||
allSkills: new NumberField({ initial: 0, integer: true }),
|
||||
sanity: new NumberField({ initial: 0, integer: true }),
|
||||
statistics: new NumberField({ initial: 0, integer: true }),
|
||||
}),
|
||||
physical: new SchemaField({
|
||||
description: new HTMLField({
|
||||
initial: "",
|
||||
blank: true,
|
||||
textSearch: true,
|
||||
}),
|
||||
description: DGHTMLField(),
|
||||
wounds: new StringField({ initial: "" }),
|
||||
firstAidAttempted: new BooleanField({ initial: false }),
|
||||
exhausted: new BooleanField({ initial: false }),
|
||||
exhaustedPenalty: new NumberField({ initial: -20 }),
|
||||
suppressExhaustion: new BooleanField({ initial: false }),
|
||||
}),
|
||||
biography: new SchemaField({
|
||||
profession: new StringField({ initial: "" }),
|
||||
@@ -55,4 +72,33 @@ export default class AgentData extends CharacterData {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareBaseData() {
|
||||
// rollTarget is modified by add-type Active Effects each prepare cycle.
|
||||
this.rollTarget.allSkills = 0;
|
||||
this.rollTarget.sanity = 0;
|
||||
this.rollTarget.statistics = 0;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
// Pre–final-AE setup only. Post-AE derived fields (max, x5 refresh,
|
||||
// breaking point, etc.) are applied in DeltaGreenActor.prepareData via
|
||||
// refreshDerivedAfterActiveEffects after the final Active Effect phase.
|
||||
prepareStatisticsX5(
|
||||
this.statistics,
|
||||
this.parent?._source?.system?.statistics,
|
||||
);
|
||||
removeLegacyRitualSkill(this);
|
||||
initializeSanityIfUnset(this.sanity, this.statistics);
|
||||
prepareSanityAdaptations(this.sanity.adaptations);
|
||||
|
||||
this.health.protection = computeEquippedArmorProtection(this.parent.items);
|
||||
|
||||
if (this.physical.exhaustedPenalty > 0) {
|
||||
this.physical.exhaustedPenalty =
|
||||
-1 * Math.abs(this.physical.exhaustedPenalty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Default proficiency for fixed agent skills (single source of truth for schema initials). */
|
||||
const AGENT_SKILL_DEFAULTS = /** @type {Record<string, number>} */ ({
|
||||
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,
|
||||
});
|
||||
|
||||
export default AGENT_SKILL_DEFAULTS;
|
||||
@@ -3,7 +3,12 @@ const { SchemaField, NumberField, StringField, BooleanField } =
|
||||
|
||||
export function skillField(initialValue, label) {
|
||||
return new SchemaField({
|
||||
proficiency: new NumberField({ initial: initialValue }),
|
||||
proficiency: new NumberField({
|
||||
initial: initialValue,
|
||||
min: 0,
|
||||
max: 99,
|
||||
integer: true,
|
||||
}),
|
||||
label: new StringField({ initial: label }),
|
||||
failure: new BooleanField({ initial: false }),
|
||||
});
|
||||
@@ -11,15 +16,26 @@ export function skillField(initialValue, label) {
|
||||
|
||||
export function resourceField(initialValue, initialMax) {
|
||||
return new SchemaField({
|
||||
min: new NumberField({ initial: 0 }),
|
||||
value: new NumberField({ initial: initialValue }),
|
||||
max: new NumberField({ initial: initialMax }),
|
||||
min: new NumberField({ initial: 0, min: 0, integer: true }),
|
||||
value: new NumberField({ initial: initialValue, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: initialMax, min: 0, integer: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Agent resources that support Active Effect maximum bonuses. */
|
||||
export function agentResourceField(initialValue, initialMax) {
|
||||
return new SchemaField({
|
||||
min: new NumberField({ initial: 0, min: 0, integer: true }),
|
||||
value: new NumberField({ initial: initialValue, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: initialMax, min: 0, integer: true }),
|
||||
maxBonus: new NumberField({ initial: 0, integer: true }),
|
||||
});
|
||||
}
|
||||
|
||||
export function statisticsField(initialValue) {
|
||||
return new SchemaField({
|
||||
value: new NumberField({ initial: initialValue }),
|
||||
value: new NumberField({ initial: initialValue, min: 0, integer: true }),
|
||||
modifier: new NumberField({ initial: 0, integer: true }),
|
||||
distinguishing_feature: new StringField({ initial: "" }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,46 +7,52 @@ 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 }),
|
||||
proficiency: new NumberField({
|
||||
initial: 0,
|
||||
min: 0,
|
||||
max: 99,
|
||||
integer: true,
|
||||
}),
|
||||
label: new StringField({ initial: "Unnatural" }),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import HumanSkillsActorData from "./base/human-skills.js";
|
||||
import CharacterData from "./base/character.js";
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
import {
|
||||
calculateHealthMax,
|
||||
calculateMeleeDamageBonusFormula,
|
||||
clampSanityRitualValue,
|
||||
cleanDerivedNumber,
|
||||
computeEquippedArmorProtection,
|
||||
initializeSanityIfUnset,
|
||||
prepareStatisticsX5,
|
||||
removeLegacyRitualSkill,
|
||||
setSkillTargetProficiencies,
|
||||
} from "../derived/actor-derived.js";
|
||||
|
||||
const { SchemaField, NumberField, StringField, BooleanField, HTMLField } =
|
||||
const { SchemaField, NumberField, StringField, BooleanField } =
|
||||
foundry.data.fields;
|
||||
|
||||
export default class NPCData extends CharacterData {
|
||||
@@ -11,17 +23,48 @@ export default class NPCData extends CharacterData {
|
||||
return {
|
||||
...superData,
|
||||
...HumanSkillsActorData.defineSchema(),
|
||||
schemaVersion: new NumberField({ initial: 1.0 }),
|
||||
schemaVersion: new NumberField({ initial: 2.0 }),
|
||||
sanity: new SchemaField({
|
||||
value: new NumberField({ initial: 100 }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101 }),
|
||||
value: new NumberField({ initial: 100, min: 0, integer: true }),
|
||||
max: new NumberField({ initial: 99, min: 0, integer: true }),
|
||||
currentBreakingPoint: new NumberField({ initial: 101, integer: true }),
|
||||
}),
|
||||
biography: new SchemaField({
|
||||
profession: new StringField({ initial: "" }),
|
||||
}),
|
||||
notes: new HTMLField({ initial: "", blank: true, textSearch: true }),
|
||||
notes: DGHTMLField(),
|
||||
shortDescription: new StringField({ initial: "" }),
|
||||
showUntrainedSkills: new BooleanField({ initial: true }),
|
||||
};
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
const sourceStatistics = this.parent?._source?.system?.statistics;
|
||||
prepareStatisticsX5(this.statistics, sourceStatistics);
|
||||
initializeSanityIfUnset(this.sanity, this.statistics);
|
||||
this.sanity.max = cleanDerivedNumber(
|
||||
this,
|
||||
"sanity.max",
|
||||
99 - this.skills.unnatural.proficiency,
|
||||
);
|
||||
this.wp.max = cleanDerivedNumber(
|
||||
this,
|
||||
"wp.max",
|
||||
this.statistics.pow.effectiveValue ?? this.statistics.pow.value,
|
||||
);
|
||||
this.health.max = cleanDerivedNumber(
|
||||
this,
|
||||
"health.max",
|
||||
calculateHealthMax(this.statistics, sourceStatistics),
|
||||
);
|
||||
this.statistics.str.meleeDamageBonusFormula =
|
||||
calculateMeleeDamageBonusFormula(
|
||||
this.statistics.str.effectiveValue ?? this.statistics.str.value,
|
||||
);
|
||||
removeLegacyRitualSkill(this);
|
||||
clampSanityRitualValue(this.sanity);
|
||||
this.health.protection = computeEquippedArmorProtection(this.parent.items);
|
||||
setSkillTargetProficiencies(this.skills);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import UnnaturalSkillsActorData from "./base/unnatural-skills.js";
|
||||
import CharacterData from "./base/character.js";
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
import {
|
||||
calculateHealthMax,
|
||||
cleanDerivedNumber,
|
||||
computeEquippedArmorProtection,
|
||||
prepareStatisticsX5,
|
||||
removeLegacyRitualSkill,
|
||||
setSkillTargetProficiencies,
|
||||
} from "../derived/actor-derived.js";
|
||||
|
||||
const { SchemaField, NumberField, StringField, BooleanField, HTMLField } =
|
||||
const { SchemaField, NumberField, StringField, BooleanField } =
|
||||
foundry.data.fields;
|
||||
|
||||
export default class UnnaturalData extends CharacterData {
|
||||
@@ -16,15 +25,36 @@ export default class UnnaturalData extends CharacterData {
|
||||
failedLoss: new StringField({ initial: "1D4" }),
|
||||
successLoss: new StringField({ initial: "1" }),
|
||||
}),
|
||||
schemaVersion: new NumberField({ initial: 1.0 }),
|
||||
notes: new HTMLField({
|
||||
initial: "",
|
||||
blank: true,
|
||||
textSearch: true,
|
||||
allowColor: true,
|
||||
}),
|
||||
schemaVersion: new NumberField({ initial: 2.0 }),
|
||||
notes: DGHTMLField(),
|
||||
shortDescription: new StringField({ initial: "" }),
|
||||
showUntrainedSkills: new BooleanField({ initial: true }),
|
||||
};
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
const sourceStatistics = this.parent?._source?.system?.statistics;
|
||||
prepareStatisticsX5(this.statistics, sourceStatistics);
|
||||
|
||||
if (this.wp.maxNeedsUpdate) {
|
||||
this.wp.max = cleanDerivedNumber(
|
||||
this,
|
||||
"wp.max",
|
||||
this.statistics.pow.effectiveValue ?? this.statistics.pow.value,
|
||||
);
|
||||
}
|
||||
|
||||
if (this.health.maxNeedsUpdate) {
|
||||
this.health.max = cleanDerivedNumber(
|
||||
this,
|
||||
"health.max",
|
||||
calculateHealthMax(this.statistics, sourceStatistics),
|
||||
);
|
||||
}
|
||||
|
||||
removeLegacyRitualSkill(this);
|
||||
this.health.protection = computeEquippedArmorProtection(this.parent.items);
|
||||
setSkillTargetProficiencies(this.skills);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import SheetSettingsActorData from "./base/sheet-settings.js";
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
import { computeEquippedArmorProtection } from "../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 {
|
||||
@@ -8,11 +10,7 @@ export default class VehicleData extends foundry.abstract.TypeDataModel {
|
||||
return {
|
||||
...SheetSettingsActorData.defineSchema(),
|
||||
name: new StringField({ initial: "" }),
|
||||
description: new HTMLField({
|
||||
initial: "",
|
||||
blank: true,
|
||||
textSearch: true,
|
||||
}),
|
||||
description: DGHTMLField(),
|
||||
shortDescription: new StringField({ initial: "" }),
|
||||
health: new SchemaField({
|
||||
value: new NumberField({ initial: 10 }),
|
||||
@@ -24,4 +22,9 @@ export default class VehicleData extends foundry.abstract.TypeDataModel {
|
||||
passengers: new ArrayField(new StringField()),
|
||||
};
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
prepareDerivedData() {
|
||||
this.health.protection = computeEquippedArmorProtection(this.parent.items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Shared derived-data helpers for actor TypeDataModels.
|
||||
*
|
||||
* Active Effect validation layers (do not duplicate clamp logic elsewhere):
|
||||
* - Schema `NumberField` min/max: enforced when values pass through `field.clean()`
|
||||
* (document updates, Active Effect `applyChange` on registered paths).
|
||||
* - `cleanDerivedNumber`: use when assigning derived fields in code (e.g. `health.max`,
|
||||
* `sanity.max`); plain assignment bypasses schema cleaners unless the path has a NumberField.
|
||||
* - `clampStatisticValue`: effective ratings after statistic modifier AEs (min 0).
|
||||
* - `applyAgentResourceMaxBonuses`: recompute resource max after AE;
|
||||
* agents call these only from `refreshDerivedAfterActiveEffects` (post–final phase).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Item[]} items
|
||||
* @returns {number}
|
||||
*/
|
||||
export function computeEquippedArmorProtection(items) {
|
||||
let protection = 0;
|
||||
for (const item of items) {
|
||||
if (item.type === "armor" && item.system.equipped === true) {
|
||||
protection += item.system.protection;
|
||||
}
|
||||
}
|
||||
return protection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} strengthValue
|
||||
* @returns {string}
|
||||
*/
|
||||
export function calculateMeleeDamageBonusFormula(strengthValue) {
|
||||
if (strengthValue < 5) return "-2";
|
||||
if (strengthValue < 9) return "-1";
|
||||
if (strengthValue > 12 && strengthValue < 17) return "+1";
|
||||
if (strengthValue > 16) return "+2";
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampStatisticValue(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return 0;
|
||||
return Math.max(0, Math.round(numeric));
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective statistic rating: persisted value plus AE modifier (minimum 0).
|
||||
* @param {object} stat Prepared statistic.
|
||||
* @param {object} [sourceStat] Persisted statistic from `_source`.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getStatisticEffectiveValue(stat, sourceStat) {
|
||||
const base = clampStatisticValue(sourceStat?.value ?? stat?.value);
|
||||
const modifier = Number(stat?.modifier) || 0;
|
||||
return clampStatisticValue(base + modifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} statistics
|
||||
* @param {object} [sourceStatistics] Persisted statistics from `_source`.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareStatisticsX5(statistics, sourceStatistics) {
|
||||
for (const [key, statistic] of Object.entries(statistics ?? {})) {
|
||||
statistic.effectiveValue = getStatisticEffectiveValue(
|
||||
statistic,
|
||||
sourceStatistics?.[key],
|
||||
);
|
||||
statistic.x5 = statistic.effectiveValue * 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a derived numeric value through the TypeDataModel field definition when available.
|
||||
* @param {foundry.abstract.TypeDataModel} model
|
||||
* @param {string} path Dot-delimited path (e.g. "health.max")
|
||||
* @param {number} raw
|
||||
* @returns {number}
|
||||
*/
|
||||
export function cleanDerivedNumber(model, path, raw) {
|
||||
const field = model.getFieldForProperty?.(path);
|
||||
const numeric = Number(raw);
|
||||
if (!field) {
|
||||
return Number.isFinite(numeric) ? Math.max(0, Math.round(numeric)) : 0;
|
||||
}
|
||||
return field.clean(numeric, { persisted: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} statistics
|
||||
* @returns {number}
|
||||
*/
|
||||
export function calculateHealthMax(statistics, sourceStatistics) {
|
||||
try {
|
||||
return Math.ceil(
|
||||
(getStatisticEffectiveValue(statistics.con, sourceStatistics?.con) +
|
||||
getStatisticEffectiveValue(statistics.str, sourceStatistics?.str)) /
|
||||
2,
|
||||
);
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {foundry.abstract.TypeDataModel} model Agent system TypeDataModel.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applyAgentResourceMaxBonuses(model, sourceStatistics) {
|
||||
const sourceStats = sourceStatistics ?? model._source?.statistics;
|
||||
model.health.max = cleanDerivedNumber(
|
||||
model,
|
||||
"health.max",
|
||||
calculateHealthMax(model.statistics, sourceStats) +
|
||||
(model.health.maxBonus ?? 0),
|
||||
);
|
||||
model.wp.max = cleanDerivedNumber(
|
||||
model,
|
||||
"wp.max",
|
||||
getStatisticEffectiveValue(model.statistics.pow, sourceStats?.pow) +
|
||||
(model.wp.maxBonus ?? 0),
|
||||
);
|
||||
model.sanity.max = cleanDerivedNumber(
|
||||
model,
|
||||
"sanity.max",
|
||||
99 - model.skills.unnatural.proficiency + (model.sanity.maxBonus ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function setSkillTargetProficiencies(skills) {
|
||||
for (const skill of Object.values(skills)) {
|
||||
skill.targetProficiency = skill.proficiency;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} sanity
|
||||
* @param {object} statistics
|
||||
* @returns {void}
|
||||
*/
|
||||
export function initializeSanityIfUnset(sanity, statistics) {
|
||||
if (sanity.value >= 100) {
|
||||
sanity.value = statistics.pow.x5;
|
||||
sanity.currentBreakingPoint =
|
||||
sanity.value - (statistics.pow.effectiveValue ?? statistics.pow.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} sanity
|
||||
* @returns {void}
|
||||
*/
|
||||
export function clampSanityRitualValue(sanity) {
|
||||
sanity.ritual = 99 - sanity.value;
|
||||
|
||||
if (sanity.ritual > 99) {
|
||||
sanity.ritual = 99;
|
||||
} else if (sanity.ritual < 1) {
|
||||
sanity.ritual = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} adaptations
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareSanityAdaptations(adaptations) {
|
||||
adaptations.violence.isAdapted =
|
||||
adaptations.violence.incident1 &&
|
||||
adaptations.violence.incident2 &&
|
||||
adaptations.violence.incident3;
|
||||
|
||||
adaptations.helplessness.isAdapted =
|
||||
adaptations.helplessness.incident1 &&
|
||||
adaptations.helplessness.incident2 &&
|
||||
adaptations.helplessness.incident3;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} sanity
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareBreakingPointHit(sanity) {
|
||||
sanity.breakingPointHit = sanity.value <= sanity.currentBreakingPoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} skills
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentSkillFlags(skills) {
|
||||
for (const [key, skill] of Object.entries(skills)) {
|
||||
skill.cannotBeImprovedByFailure =
|
||||
key === "unnatural" || key === "luck" || key === "ritual";
|
||||
skill.isCalculatedValue = key === "ritual";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} system
|
||||
* @returns {void}
|
||||
*/
|
||||
export function removeLegacyRitualSkill(system) {
|
||||
try {
|
||||
delete system.skills.ritual;
|
||||
} catch {
|
||||
// Legacy field may be absent or non-configurable.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const { HTMLField } = foundry.data.fields;
|
||||
|
||||
/**
|
||||
* Standard Delta Green HTML / ProseMirror field defaults.
|
||||
* @param {Partial<foundry.data.fields.StringFieldOptions>} [options]
|
||||
* @returns {foundry.data.fields.HTMLField}
|
||||
*/
|
||||
export default function DGHTMLField(options = {}) {
|
||||
return new HTMLField({
|
||||
required: false,
|
||||
blank: true,
|
||||
initial: "",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const { HTMLField } = foundry.data.fields;
|
||||
import DGHTMLField from "../fields/html-content-field.js";
|
||||
|
||||
/**
|
||||
* Shared system fields for all Delta Green item types (from legacy template base).
|
||||
@@ -6,6 +6,6 @@ const { HTMLField } = foundry.data.fields;
|
||||
*/
|
||||
export default function defineBaseItemSystemFields() {
|
||||
return {
|
||||
description: new HTMLField({ initial: "", blank: true, textSearch: true }),
|
||||
description: DGHTMLField(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export default class MotivationItemData extends foundry.abstract.TypeDataModel {
|
||||
disorder: new StringField({ initial: "" }),
|
||||
crossedOut: new BooleanField({ initial: false }),
|
||||
disorderCured: new BooleanField({ initial: false }),
|
||||
acuteEpisode: new BooleanField({ initial: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import defineBaseItemSystemFields from "./base-fields.js";
|
||||
|
||||
const { NumberField, ObjectField } = foundry.data.fields;
|
||||
|
||||
/** Reserved key inside {@link ProfessionItemData.optionSkills}. */
|
||||
export const PROFESSION_OPTION_PICKS_KEY = "optionPicks";
|
||||
|
||||
export default class ProfessionItemData extends foundry.abstract.TypeDataModel {
|
||||
/** @inheritdoc */
|
||||
static migrateData(source, options, _state) {
|
||||
const bonds = Number(source.bonds);
|
||||
if (!Number.isFinite(bonds) || bonds < 1) source.bonds = 1;
|
||||
return super.migrateData(source, options, _state);
|
||||
}
|
||||
|
||||
static defineSchema() {
|
||||
return {
|
||||
...defineBaseItemSystemFields(),
|
||||
bonds: new NumberField({ initial: 1, min: 1, max: 5, integer: true }),
|
||||
automaticSkills: new ObjectField({ initial: {} }),
|
||||
automaticSkillMeta: new ObjectField({ initial: {} }),
|
||||
optionSkills: new ObjectField({
|
||||
initial: { [PROFESSION_OPTION_PICKS_KEY]: 0 },
|
||||
}),
|
||||
optionSkillMeta: new ObjectField({ initial: {} }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** @typedef {{ chooseOne?: boolean }} ProfessionSkillMeta */
|
||||
@@ -1,6 +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 {
|
||||
@@ -22,11 +23,7 @@ export default class RitualItemData extends foundry.abstract.TypeDataModel {
|
||||
activationCosts: new StringField({ initial: "" }),
|
||||
activationTime: new StringField({ initial: "" }),
|
||||
complexity: new StringField({ initial: "Simple" }),
|
||||
handlerNotes: new HTMLField({
|
||||
initial: "",
|
||||
blank: true,
|
||||
textSearch: true,
|
||||
}),
|
||||
handlerNotes: DGHTMLField(),
|
||||
revealed: new BooleanField({ initial: false }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +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 {
|
||||
@@ -16,11 +17,7 @@ export default class TomeItemData extends foundry.abstract.TypeDataModel {
|
||||
failedLoss: new StringField({ initial: "1D6" }),
|
||||
successLoss: new StringField({ initial: "1D4" }),
|
||||
}),
|
||||
handlerNotes: new HTMLField({
|
||||
initial: "",
|
||||
blank: true,
|
||||
textSearch: true,
|
||||
}),
|
||||
handlerNotes: DGHTMLField(),
|
||||
revealed: new BooleanField({ initial: false }),
|
||||
};
|
||||
}
|
||||
|
||||
+134
-35
@@ -1,26 +1,32 @@
|
||||
// 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";
|
||||
import DeltaGreenItemSheet from "./item/item-sheet.js";
|
||||
import * as DGRolls from "./roll/roll.js";
|
||||
import DGItemSheet from "./sheets/base-item-sheet.js";
|
||||
import {
|
||||
DGRoll,
|
||||
DGPercentileRoll,
|
||||
DGLethalityRoll,
|
||||
DGDamageRoll,
|
||||
DGSanityDamageRoll,
|
||||
} from "./roll/roll.js";
|
||||
import registerSystemSettings from "./settings.js";
|
||||
import preloadHandlebarsTemplates from "./templates.js";
|
||||
import registerHandlebarsHelpers from "./other/register-helpers.js";
|
||||
import ParseDeltaGreenStatBlock from "./other/stat-parser-macro.js";
|
||||
import registerHandlebarsHelpers from "./utils/register-helpers.js";
|
||||
import ParseDeltaGreenStatBlock from "./macros/stat-parser-macro.js";
|
||||
import {
|
||||
createDeltaGreenMacro,
|
||||
rollItemMacro,
|
||||
rollItemSkillCheckMacro,
|
||||
rollSkillMacro,
|
||||
rollSkillTestAndDamageForOwnedItem,
|
||||
} from "./other/macro-functions.js";
|
||||
import { handleInlineActions } from "./other/inline.js";
|
||||
} from "./macros/macro-functions.js";
|
||||
import { handleInlineActions } from "./chat/inline.js";
|
||||
import runWorldMigration from "./utils/world-migration.js";
|
||||
import DGNPCSheet from "./sheets/npc-sheet.js";
|
||||
import DGUnnaturalSheet from "./sheets/unnatural-sheet.js";
|
||||
import DGVehicleSheet from "./sheets/vehicle-sheet.js";
|
||||
import DGAgentSheetV2 from "./sheets/agent-sheet-v2.js";
|
||||
import AgentData from "./data/actor/agent.js";
|
||||
import UnnaturalData from "./data/actor/unnatural.js";
|
||||
import NPCData from "./data/actor/npc.js";
|
||||
@@ -32,8 +38,18 @@ import BondItemData from "./data/item/bond.js";
|
||||
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 "./active-effect/documents/dg-active-effect.js";
|
||||
import DGActiveEffectConfig from "./applications/dg-active-effect-config.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 "./active-effect/runtime/stimulant-effect.js";
|
||||
|
||||
const { Actors, Items } = foundry.documents.collections;
|
||||
const { DocumentSheetConfig } = foundry.applications.apps;
|
||||
|
||||
Hooks.once("init", async () => {
|
||||
Object.assign(CONFIG.Actor.dataModels, {
|
||||
@@ -52,11 +68,26 @@ Hooks.once("init", async () => {
|
||||
gear: GearItemData,
|
||||
tome: TomeItemData,
|
||||
ritual: RitualItemData,
|
||||
profession: ProfessionItemData,
|
||||
});
|
||||
|
||||
CONFIG.ActiveEffect.documentClass = DGActiveEffect;
|
||||
CONFIG.ActiveEffect.dataModels.base = DGActiveEffectTypeDataModel;
|
||||
|
||||
DocumentSheetConfig.registerSheet(
|
||||
DGActiveEffect,
|
||||
DG.ID,
|
||||
DGActiveEffectConfig,
|
||||
{
|
||||
makeDefault: true,
|
||||
label: "DG.ActiveEffects.SheetLabel",
|
||||
},
|
||||
);
|
||||
|
||||
game.deltagreen = {
|
||||
DeltaGreenActor,
|
||||
DeltaGreenItem,
|
||||
DGActiveEffect,
|
||||
rollItemMacro,
|
||||
rollItemSkillCheckMacro,
|
||||
rollSkillMacro,
|
||||
@@ -74,7 +105,13 @@ Hooks.once("init", async () => {
|
||||
};
|
||||
|
||||
// Register custom dice rolls
|
||||
Object.values(DGRolls).forEach((cls) => CONFIG.Dice.rolls.push(cls));
|
||||
[
|
||||
DGRoll,
|
||||
DGPercentileRoll,
|
||||
DGLethalityRoll,
|
||||
DGDamageRoll,
|
||||
DGSanityDamageRoll,
|
||||
].forEach((cls) => CONFIG.Dice.rolls.push(cls));
|
||||
|
||||
// Register System Settings
|
||||
registerSystemSettings();
|
||||
@@ -103,16 +140,8 @@ Hooks.once("init", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Don't register new sheet yet.
|
||||
// Actors.registerSheet(DG.ID, DGAgentSheetV2, {
|
||||
// makeDefault: false,
|
||||
// themes: null,
|
||||
// label: `Agent Sheet V2`,
|
||||
// types: ["agent"],
|
||||
// });
|
||||
|
||||
// Register item sheet.
|
||||
Items.registerSheet(DG.ID, DeltaGreenItemSheet, {
|
||||
Items.registerSheet(DG.ID, DGItemSheet, {
|
||||
makeDefault: true,
|
||||
label: "DG.Sheet.class.item",
|
||||
themes: null,
|
||||
@@ -125,7 +154,79 @@ Hooks.once("init", async () => {
|
||||
registerHandlebarsHelpers();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Actor|undefined}
|
||||
*/
|
||||
function getAgentFromEffect(effect) {
|
||||
const { parent } = effect;
|
||||
if (parent?.documentName === "Actor" && parent.type === "agent") {
|
||||
return parent;
|
||||
}
|
||||
if (parent?.documentName === "Item") {
|
||||
const { actor } = parent;
|
||||
if (actor?.type === "agent") return actor;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function onAgentActiveEffectChange(effect) {
|
||||
const actor = getAgentFromEffect(effect);
|
||||
if (!actor) return;
|
||||
actor.reset();
|
||||
if (game.user.isActiveGM) await pruneExpiredStimulantEffects(actor);
|
||||
await syncExhaustionEffect(actor);
|
||||
}
|
||||
|
||||
Hooks.on("createActiveEffect", (effect) => {
|
||||
onAgentActiveEffectChange(effect);
|
||||
});
|
||||
|
||||
Hooks.on("updateActiveEffect", (effect) => {
|
||||
onAgentActiveEffectChange(effect);
|
||||
});
|
||||
|
||||
Hooks.on("deleteActiveEffect", (effect) => {
|
||||
onAgentActiveEffectChange(effect);
|
||||
});
|
||||
|
||||
Hooks.on("updateWorldTime", () => {
|
||||
if (!game.user.isActiveGM) return;
|
||||
pruneAllAgentsExpiredStimulants();
|
||||
});
|
||||
|
||||
Hooks.on("updateItem", (item, changes) => {
|
||||
const flat = foundry.utils.flattenObject(changes);
|
||||
const transferStateKeys = [
|
||||
"system.equipped",
|
||||
"system.acuteEpisode",
|
||||
"system.crossedOut",
|
||||
"system.disorderCured",
|
||||
];
|
||||
if (transferStateKeys.some((key) => key in flat)) {
|
||||
const { actor } = item;
|
||||
if (actor) {
|
||||
actor.reset();
|
||||
if (actor.sheet?.rendered) actor.sheet.render();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
item.type === "profession" &&
|
||||
"name" in flat &&
|
||||
item.actor?.type === "agent"
|
||||
) {
|
||||
item.actor.update({ "system.biography.profession": item.name });
|
||||
}
|
||||
});
|
||||
|
||||
Hooks.once("ready", async () => {
|
||||
await runWorldMigration();
|
||||
|
||||
// Wait to register hotbar drop hook on ready so that modules could register earlier if they want to
|
||||
// TODO: Fix eslint issue on next line
|
||||
// eslint-disable-next-line consistent-return
|
||||
@@ -164,32 +265,25 @@ Hooks.on("preCreateItem", (item) => {
|
||||
});
|
||||
|
||||
// Hook into the render call for the Actors Directory to add an extra button
|
||||
Hooks.on("renderActorDirectory", (app, html, data) => {
|
||||
if (!game.user.isGM) {
|
||||
return;
|
||||
}
|
||||
Hooks.on("renderActorDirectory", (app, element) => {
|
||||
if (!game.user.isGM) return;
|
||||
|
||||
if (html.querySelector("#statParserButton")) {
|
||||
return;
|
||||
}
|
||||
const footer = element?.querySelector?.(".directory-footer");
|
||||
if (!footer || footer.querySelector("#statParserButton")) return;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.id = "statParserButton";
|
||||
button.type = "button";
|
||||
|
||||
const icon = document.createElement("i");
|
||||
|
||||
icon.classList.add("fas", "fa-file-import");
|
||||
|
||||
button.appendChild(icon);
|
||||
button.append("Delta Green Stat Block Parser");
|
||||
|
||||
html.querySelector(".directory-footer").appendChild(button);
|
||||
|
||||
if (button) {
|
||||
button.addEventListener("click", function (event) {
|
||||
ParseDeltaGreenStatBlock();
|
||||
});
|
||||
}
|
||||
footer.appendChild(button);
|
||||
button.addEventListener("click", () => {
|
||||
ParseDeltaGreenStatBlock();
|
||||
});
|
||||
});
|
||||
|
||||
// Note - this event is fired on ALL connected clients...
|
||||
@@ -244,8 +338,13 @@ Hooks.on("renderChatLog", async (app, element) => {
|
||||
addEventListenerToChatMessage(element);
|
||||
});
|
||||
|
||||
Hooks.on("renderChatMessageHTML", async (app, element, context) => {
|
||||
Hooks.on("renderChatMessageHTML", async (message, element, context) => {
|
||||
// ignore non chat card notifications
|
||||
if (!context.canClose) return;
|
||||
|
||||
if (message.getFlag(DG.ID, "chatCard")) {
|
||||
element.classList.add("dg-chat-card-message");
|
||||
}
|
||||
|
||||
addEventListenerToChatMessage(element);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getDGSpeaker } from "../chat/dg-chat-card.js";
|
||||
|
||||
/**
|
||||
* Whether Dice So Nice is active and its Roll API is available.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDiceSoNiceAvailable() {
|
||||
return Boolean(
|
||||
game.modules.get("dice-so-nice")?.active &&
|
||||
typeof game.dice3d?.showForRoll === "function",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a 3D dice animation for a Foundry Roll via the DSN Roll API.
|
||||
* Used for rolls that are not posted as chat messages (e.g. stat generation).
|
||||
* Chat-based rolls should rely on DSN's createChatMessage hook instead.
|
||||
*
|
||||
* @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration
|
||||
* @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/API/Roll
|
||||
* @param {Roll} roll
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.synchronize=true] Broadcast animation to other clients.
|
||||
* @param {ChatMessage.SpeakerData|null} [options.speaker] Chat speaker for NPC roll hiding.
|
||||
* @param {string[]|null} [options.whisper] User IDs who can see the roll; null for everyone.
|
||||
* @param {boolean} [options.blind=false] Whether the roll is blind for the rolling user.
|
||||
* @param {string|null} [options.chatMessageId] Chat message to reveal when the animation ends.
|
||||
* @returns {Promise<boolean>} True if animation was displayed.
|
||||
*/
|
||||
export async function showDiceSoNiceRoll(
|
||||
roll,
|
||||
{
|
||||
synchronize = true,
|
||||
speaker = null,
|
||||
whisper = null,
|
||||
blind = false,
|
||||
chatMessageId = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!isDiceSoNiceAvailable()) return false;
|
||||
|
||||
return game.dice3d.showForRoll(
|
||||
roll,
|
||||
game.user,
|
||||
synchronize,
|
||||
whisper,
|
||||
blind,
|
||||
chatMessageId,
|
||||
speaker ?? undefined,
|
||||
{ ghost: false, secret: false },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge evaluated rolls into a pool and show one 3D animation for all of them.
|
||||
*
|
||||
* @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration#step-4-select-roll-order-for-multiple-rolls
|
||||
* @param {Roll[]} rolls
|
||||
* @param {object} [options]
|
||||
* @param {Actor|null} [options.actor]
|
||||
* @param {TokenDocument|null} [options.token]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function showDiceSoNicePooledRolls(
|
||||
rolls,
|
||||
{ actor = null, token = null } = {},
|
||||
) {
|
||||
if (!isDiceSoNiceAvailable() || !rolls?.length) return;
|
||||
|
||||
const pool = foundry.dice.terms.PoolTerm.fromRolls(rolls);
|
||||
const pooledRoll = Roll.fromTerms([pool]);
|
||||
const speaker = actor ? getDGSpeaker({ actor, token }) : null;
|
||||
await showDiceSoNiceRoll(pooledRoll, { speaker });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the 3D animation tied to a chat message, if DSN is available.
|
||||
*
|
||||
* @param {string} messageId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitForDiceSoNiceMessageAnimation(messageId) {
|
||||
if (!isDiceSoNiceAvailable()) return;
|
||||
if (typeof game.dice3d.waitFor3DAnimationByMessageID !== "function") return;
|
||||
await game.dice3d.waitFor3DAnimationByMessageID(messageId);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import {
|
||||
DGPercentileRoll,
|
||||
DGLethalityRoll,
|
||||
DGDamageRoll,
|
||||
DGSanityDamageRoll,
|
||||
} from "../roll/roll.js";
|
||||
|
||||
/**
|
||||
* Extend the basic ItemSheet with some very simple modifications
|
||||
* @extends {ItemSheet}
|
||||
*/
|
||||
export default class DeltaGreenItemSheet extends foundry.appv1.sheets
|
||||
.ItemSheet {
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["deltagreen", "sheet", "item"],
|
||||
width: 520,
|
||||
height: 600,
|
||||
tabs: [
|
||||
{
|
||||
navSelector: ".sheet-tabs",
|
||||
contentSelector: ".sheet-body",
|
||||
initial: "attributes",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
get template() {
|
||||
const path = "systems/deltagreen/templates/item";
|
||||
|
||||
// unique item sheet by type, like `weapon-sheet.html`.
|
||||
return `${path}/item-${this.item.type}-sheet.html`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData() {
|
||||
const data = super.getData();
|
||||
|
||||
data.enrichedDescription =
|
||||
await foundry.applications.ux.TextEditor.implementation.enrichHTML(
|
||||
this.object.system.description,
|
||||
{ async: true },
|
||||
);
|
||||
|
||||
if (data.item.type === "tome" || data.item.type === "ritual") {
|
||||
data.enrichedHandlerNotes =
|
||||
await foundry.applications.ux.TextEditor.implementation.enrichHTML(
|
||||
this.object.system.handlerNotes,
|
||||
{ async: true },
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
setPosition(options = {}) {
|
||||
const position = super.setPosition(options);
|
||||
const sheetBody = this.element.find(".sheet-body");
|
||||
const bodyHeight = position.height - 192;
|
||||
sheetBody.css("height", bodyHeight);
|
||||
return position;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
// Everything below here is only needed if the sheet is editable
|
||||
// eslint-disable-next-line no-useless-return
|
||||
if (!this.options.editable) return;
|
||||
|
||||
// Rollable abilities - bind to everything with the 'Rollable' class
|
||||
html.find(".rollable").click(this._onRoll.bind(this));
|
||||
html.find(".rollable").contextmenu(this._onRoll.bind(this)); // this is for right-click, which triggers the roll modifier dialogue for most rolls
|
||||
|
||||
// toggle whether information is shown to a player for a tome or not
|
||||
html.find(".tome-show-hide-info").click((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.item.system.revealed = !this.item.system.revealed;
|
||||
|
||||
this.item.update({ "system.revealed": this.item.system.revealed });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clickable rolls.
|
||||
*
|
||||
* @param {Event} event The originating click event
|
||||
* @async
|
||||
* @private
|
||||
*/
|
||||
async _onRoll(event) {
|
||||
event.preventDefault();
|
||||
const element = event.currentTarget;
|
||||
const { dataset } = element;
|
||||
|
||||
if (event && event.which === 2) {
|
||||
// probably don't want rolls to trigger from a middle mouse click so just kill it here
|
||||
return;
|
||||
}
|
||||
|
||||
// const item = this.actor.items.get(dataset.iid);
|
||||
const rollOptions = {
|
||||
rollType: dataset.rolltype,
|
||||
key: dataset.key,
|
||||
// actor: this,
|
||||
specialTrainingName: dataset?.name || null, // Only applies to Special Training Rolls
|
||||
item: this.item,
|
||||
};
|
||||
|
||||
// Create a default 1d100 roll just in case.
|
||||
let roll = new Roll("1d100", {});
|
||||
switch (dataset.rolltype) {
|
||||
case "stat":
|
||||
case "skill":
|
||||
case "sanity":
|
||||
case "special-training":
|
||||
case "weapon":
|
||||
roll = new DGPercentileRoll("1D100", {}, rollOptions);
|
||||
break;
|
||||
case "lethality":
|
||||
roll = new DGLethalityRoll("1D100", {}, rollOptions);
|
||||
break;
|
||||
case "damage": {
|
||||
let diceFormula = this.item.system.damage;
|
||||
const { skill } = this.item.system;
|
||||
if (
|
||||
(this.actor.type === "agent" || this.actor.type === "npc") &&
|
||||
(skill === "unarmed_combat" || skill === "melee_weapons")
|
||||
) {
|
||||
diceFormula +=
|
||||
this.actor.system.statistics.str.meleeDamageBonusFormula;
|
||||
}
|
||||
roll = new DGDamageRoll(diceFormula, {}, rollOptions);
|
||||
break;
|
||||
}
|
||||
case "sanity-damage": {
|
||||
// Applies only to rituals, as far as Jalen can tell. Tomes have "study damage", but it just uses `sanity`.
|
||||
const isLearntDamage = element.hasAttribute("data-san-on-learn");
|
||||
|
||||
const { successLoss, failedLoss } = isLearntDamage
|
||||
? this.item.system.learnedSanity
|
||||
: this.item.system.sanity;
|
||||
const combinedFormula = `{${successLoss}, ${failedLoss}}`;
|
||||
|
||||
roll = new DGSanityDamageRoll(combinedFormula, {}, rollOptions);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.processRoll(event, roll, rollOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a dialog for the roll and then send to chat.
|
||||
* Broke this logic out from `_onRoll()` so that other files can call it,
|
||||
* namely the macro logic.
|
||||
*
|
||||
* TODO: Move this logic to the roll.js.
|
||||
*
|
||||
* @param {Event} event The originating click event
|
||||
* @param {Event} roll The roll to show a dialog for and then send to chat.
|
||||
* @async
|
||||
*/
|
||||
async processRoll(event, roll) {
|
||||
// Open dialog if user requests it (no dialog for Sanity Damage rolls)
|
||||
if (
|
||||
(event.shiftKey || event.which === 3) &&
|
||||
!(roll instanceof DGSanityDamageRoll)
|
||||
) {
|
||||
const dialogData = await roll.showDialog();
|
||||
if (!dialogData) return;
|
||||
if (dialogData.newFormula) {
|
||||
roll = new DGDamageRoll(dialogData.newFormula, {}, roll.options);
|
||||
}
|
||||
roll.modifier += dialogData.targetModifier;
|
||||
roll.options.rollMode = dialogData.rollMode;
|
||||
}
|
||||
// Evaluate the roll.
|
||||
await roll.evaluate();
|
||||
// Send the roll to chat.
|
||||
roll.toChat();
|
||||
}
|
||||
}
|
||||
+19
-22
@@ -1,3 +1,5 @@
|
||||
import { getDGRollToken } from "../chat/dg-chat-card.js";
|
||||
import appendMeleeDamageBonus from "../roll/melee-damage.js";
|
||||
import { DGDamageRoll, DGLethalityRoll } from "../roll/roll.js";
|
||||
|
||||
/**
|
||||
@@ -5,18 +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();
|
||||
|
||||
// Get the Item's data
|
||||
const itemData = this;
|
||||
const actorData = this.actor || {};
|
||||
const { system } = itemData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clickable rolls.
|
||||
* @param {Event} event The originating click event
|
||||
@@ -26,23 +16,25 @@ 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(
|
||||
"1D100",
|
||||
{},
|
||||
{ rollType: "lethality", actor, item },
|
||||
{
|
||||
rollType: "lethality",
|
||||
actor,
|
||||
item,
|
||||
token: getDGRollToken(actor, actor.sheet?.token),
|
||||
},
|
||||
);
|
||||
} 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})`;
|
||||
@@ -51,7 +43,12 @@ export default class DeltaGreenItem extends Item {
|
||||
roll = new DGDamageRoll(
|
||||
diceFormula,
|
||||
{},
|
||||
{ rollType: "damage", actor, item },
|
||||
{
|
||||
rollType: "damage",
|
||||
actor,
|
||||
item,
|
||||
token: getDGRollToken(actor, actor.sheet?.token),
|
||||
},
|
||||
);
|
||||
}
|
||||
return actor.sheet.processRoll({}, roll);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTokenFromChatSpeaker } from "../chat/dg-chat-card.js";
|
||||
import * as DGRolls from "../roll/roll.js";
|
||||
|
||||
const { fromUuid } = foundry.utils;
|
||||
@@ -108,11 +109,11 @@ export function rollItemSkillCheckMacro(itemId) {
|
||||
);
|
||||
|
||||
const skillName = item.system.skill.toString();
|
||||
|
||||
const token = getTokenFromChatSpeaker(speaker);
|
||||
const roll = new DGRolls.DGPercentileRoll(
|
||||
"1D100",
|
||||
{},
|
||||
{ rollType: "weapon", key: skillName, actor, item },
|
||||
{ rollType: "weapon", key: skillName, actor, item, token },
|
||||
);
|
||||
return actor.sheet.processRoll({}, roll);
|
||||
}
|
||||
@@ -130,13 +131,13 @@ export function rollSkillMacro(skillName) {
|
||||
|
||||
if (!skill) return ui.notifications.warn("Bad skill name passed to macro.");
|
||||
|
||||
const token = getTokenFromChatSpeaker(speaker);
|
||||
const roll = new DGRolls.DGPercentileRoll(
|
||||
"1D100",
|
||||
{},
|
||||
{ rollType: "skill", key: skillName, actor },
|
||||
{ rollType: "skill", key: skillName, actor, token },
|
||||
);
|
||||
return actor.sheet.processRoll({}, roll);
|
||||
// sendPercentileTestToChat(actor, translatedSkillLabel, skill.proficiency);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,12 +168,15 @@ export async function rollSkillTestAndDamageForOwnedItem(
|
||||
return ui.notifications.warn("Invalid item targeted in macro.");
|
||||
}
|
||||
|
||||
const speaker = ChatMessage.getSpeaker();
|
||||
const token = getTokenFromChatSpeaker(speaker);
|
||||
const rollOptions = {
|
||||
rollType: "weapon",
|
||||
key: item.system.skill,
|
||||
actor: item.parent,
|
||||
specialTrainingName: null, // Only applies to Special Training Rolls
|
||||
item,
|
||||
token,
|
||||
};
|
||||
|
||||
const roll = new DGRolls.DGPercentileRoll("1D100", {}, rollOptions);
|
||||
@@ -1,3 +1,5 @@
|
||||
import { showDgDialog } from "../applications/dg-dialog.js";
|
||||
|
||||
function GetAttacksFromInput(inputText) {
|
||||
const attacks = [];
|
||||
try {
|
||||
@@ -111,9 +113,11 @@ 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]) : 5;
|
||||
weaponData.armorPiercing = armorPiercing;
|
||||
weaponData.system.armorPiercing = armorPiercing;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,14 +661,16 @@ async function GetUserInput() {
|
||||
</div>
|
||||
</form>`;
|
||||
|
||||
new foundry.applications.api.DialogV2({
|
||||
window: { title: "Stat Block Parser" },
|
||||
await showDgDialog({
|
||||
modifier: "stat-parser",
|
||||
content,
|
||||
window: { title: "Stat Block Parser" },
|
||||
buttons: [
|
||||
{
|
||||
label: "PARSE",
|
||||
action: "roll",
|
||||
callback: (event, button, dialog) => {
|
||||
default: true,
|
||||
callback: (_event, _button, dialog) => {
|
||||
const textInput =
|
||||
dialog.element.querySelector("[name=parse-input]")?.value;
|
||||
|
||||
@@ -675,9 +681,9 @@ async function GetUserInput() {
|
||||
},
|
||||
},
|
||||
],
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
export default async function ParseDeltaGreenStatBlock() {
|
||||
GetUserInput();
|
||||
await GetUserInput();
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import DGUtils from "./utility-functions.js";
|
||||
|
||||
export default function registerHandlebarsHelpers() {
|
||||
// Add Handlebars helpers
|
||||
Handlebars.registerHelper("localizeWithFallback", (value, fallbackValue) => {
|
||||
return DGUtils.localizeWithFallback(value, fallbackValue);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("concat", (...args) => {
|
||||
let outStr = "";
|
||||
for (const arg in args) {
|
||||
if (typeof args[arg] !== "object") {
|
||||
outStr += args[arg];
|
||||
}
|
||||
}
|
||||
return outStr;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toLowerCase", (str) => {
|
||||
try {
|
||||
return str.toLowerCase();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toUpperCase", (str) => {
|
||||
try {
|
||||
return str.toUpperCase();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_eq", (a, b, opts) => {
|
||||
if (a === b) {
|
||||
return opts.fn(this);
|
||||
}
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_not_eq", (a, b, opts) => {
|
||||
if (a !== b) {
|
||||
return opts.fn(this);
|
||||
}
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => {
|
||||
if (a > b) {
|
||||
return trueVal;
|
||||
}
|
||||
return falseVal;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("cite_ahb", (page) => {
|
||||
return `See page ${page} of the Agent's Handbook.`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("formatLethality", (lethality) => {
|
||||
if (lethality > 0) {
|
||||
return `${lethality.toString()}%`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Is this used anywhere?
|
||||
Handlebars.registerHelper(
|
||||
"getActorSkillProp",
|
||||
(actorData, skillName, prop) => {
|
||||
try {
|
||||
if (skillName !== "" && prop !== "") {
|
||||
const { skills } = actorData.data;
|
||||
const skill = skills[skillName];
|
||||
const propVal = skill[prop];
|
||||
return propVal;
|
||||
}
|
||||
return "";
|
||||
} catch (ex) {
|
||||
console.log(ex);
|
||||
return "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Handlebars.registerHelper("getAvailableRollModes", () => {
|
||||
try {
|
||||
return CONFIG.Dice.rollModes;
|
||||
} catch (error) {
|
||||
return console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("getDefaultRollMode", () => {
|
||||
try {
|
||||
return game.settings.get("core", "rollMode");
|
||||
} catch (error) {
|
||||
return console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper(
|
||||
"calculateHandToHandCombatDamageFormulaBonus",
|
||||
(strength) => {
|
||||
try {
|
||||
let bonus = "";
|
||||
|
||||
if (strength.value < 5) {
|
||||
bonus = " - 2";
|
||||
} else if (strength.value < 9) {
|
||||
bonus = " - 1";
|
||||
} else if (strength.value > 12 && strength < 17) {
|
||||
bonus = " + 1";
|
||||
} else if (strength.value > 16) {
|
||||
bonus = " + 2";
|
||||
}
|
||||
|
||||
return bonus;
|
||||
} catch (error) {
|
||||
return console.log(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Handlebars.registerHelper("localizeWeaponSkill", (skill) => {
|
||||
let label = skill;
|
||||
|
||||
try {
|
||||
if (skill === "dex") {
|
||||
label = game.i18n.localize("DG.Attributes.dex");
|
||||
}
|
||||
if (skill === "DG.Skills.custom") {
|
||||
label = game.i18n.localize("DG.ItemWindow.Custom");
|
||||
} else {
|
||||
label = game.i18n.localize(`DG.Skills.${skill}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
return label;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper(
|
||||
"hideSkillBasedOnProficiencyAndUserChoice",
|
||||
(hideUntrainedSkills, proficiency) => {
|
||||
let showValue = true;
|
||||
|
||||
try {
|
||||
if (hideUntrainedSkills === false) {
|
||||
showValue = true;
|
||||
} else if (proficiency > 0) {
|
||||
showValue = true;
|
||||
} else {
|
||||
showValue = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
return showValue;
|
||||
},
|
||||
);
|
||||
|
||||
// looks at system setting for what font to use and returns the class that is then used in the handlebars template that
|
||||
// generates the character sheet.
|
||||
Handlebars.registerHelper("getFontFamilySystemSettingClass", () => {
|
||||
const setting = game.settings.get("deltagreen", "characterSheetFont");
|
||||
|
||||
const characterSheetStyle = game.settings.get(
|
||||
"deltagreen",
|
||||
"characterSheetStyle",
|
||||
);
|
||||
|
||||
/*
|
||||
if(setting === "TypeWriterCondensed"){
|
||||
return "typewriter-condensed-font";
|
||||
}
|
||||
else if(setting === "Martel"){
|
||||
return "martel-font";
|
||||
}
|
||||
else if(setting === "Signika"){
|
||||
return "signika-font";
|
||||
}
|
||||
else if(setting === "atwriter"){
|
||||
return "atwriter-font";
|
||||
}
|
||||
else if(setting === "SpecialElite"){
|
||||
return "special-elite-font";
|
||||
}
|
||||
else if(setting === "PublicSans"){
|
||||
return "public-sans-font";
|
||||
}
|
||||
else{
|
||||
return "martel-font";
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
if(characterSheetStyle === "cowboy"){
|
||||
return "special-elite-font";
|
||||
}
|
||||
else{
|
||||
return "public-sans-font";
|
||||
}
|
||||
*/
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("getCharacterSheetStyle", () => {
|
||||
const characterSheetStyle = game.settings.get(
|
||||
"deltagreen",
|
||||
"characterSheetStyle",
|
||||
);
|
||||
|
||||
if (characterSheetStyle === "cowboy") {
|
||||
return "cowboy-style";
|
||||
}
|
||||
if (characterSheetStyle === "outlaw") {
|
||||
return "outlaw-style";
|
||||
}
|
||||
return "program-style";
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("keepSanityPrivate", () => {
|
||||
let setting = false;
|
||||
|
||||
try {
|
||||
setting = game.settings.get("deltagreen", "keepSanityPrivate");
|
||||
|
||||
if (game.user.isGM) {
|
||||
setting = false;
|
||||
}
|
||||
} catch (ex) {
|
||||
setting = false;
|
||||
}
|
||||
|
||||
return setting;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("playerHasGamemasterPrivileges", () => {
|
||||
return game.user.isGM;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("showImpossibleLandscapesContent", () => {
|
||||
let result = false;
|
||||
const setting = game.settings.get(
|
||||
"deltagreen",
|
||||
"showImpossibleLandscapesContent",
|
||||
);
|
||||
|
||||
if (game.user.isGM === true && setting === true) {
|
||||
result = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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<string, number>} baseFixed
|
||||
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
|
||||
* @param {Record<string, number>} 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 over the creation cap (skill below 80).
|
||||
*
|
||||
* @param {Record<string, number>} baseFixed
|
||||
* @param {Record<string, { group: string, label: string, value: number }>} baseTyped
|
||||
* @param {string[]} bonusCatalogIds
|
||||
* @param {string[]} bonusTypedLabels
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function collectBonusCapValidationErrors(
|
||||
baseFixed,
|
||||
baseTyped,
|
||||
bonusCatalogIds,
|
||||
bonusTypedLabels,
|
||||
) {
|
||||
const defaults = getAgentSkillDefaults();
|
||||
/** @type {Record<string, number>} */
|
||||
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);
|
||||
} else {
|
||||
const ref = catalogIdToSkillRef(catalogId, bonusTypedLabels?.[i] ?? "");
|
||||
if (!ref) {
|
||||
slotTrackKeys.push(null);
|
||||
} else {
|
||||
const trackKey = getBonusTrackKey(ref);
|
||||
slotTrackKeys.push(trackKey);
|
||||
counts[trackKey] = (counts[trackKey] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Set<string>} */
|
||||
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)) {
|
||||
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);
|
||||
|
||||
errors.push(`bonusWaste:${i}|${waste}|${trackKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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<string, number>} automaticSkills
|
||||
* @param {Record<string, number>} optionSkills
|
||||
* @param {number} optionPicks
|
||||
* @param {object} formState
|
||||
* @param {Set<string>} formState.checkedOptionKeys
|
||||
* @param {Record<string, string>} 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<string, ProfessionSkillMeta>} [context.automaticMeta]
|
||||
* @param {Record<string, ProfessionSkillMeta>} [context.optionMeta]
|
||||
* @param {number} [context.bondCount]
|
||||
* @returns {{
|
||||
* fixedValues: Record<string, number>,
|
||||
* typedValues: Record<string, { group: string, label: string, value: number }>,
|
||||
* capWarnings: { label: string, attempted: number, waste: number }[],
|
||||
* isValid: boolean,
|
||||
* validationErrors: string[],
|
||||
* modifiedFixedKeys: string[],
|
||||
* modifiedTypedKeys: string[],
|
||||
* }}
|
||||
*/
|
||||
export default function computeSkillValues(
|
||||
automaticSkills,
|
||||
optionSkills,
|
||||
optionPicks,
|
||||
formState,
|
||||
{ automaticMeta = {}, optionMeta = {}, bondCount = 0 } = {},
|
||||
) {
|
||||
const defaults = getAgentSkillDefaults();
|
||||
/** @type {Record<string, number>} */
|
||||
const fixedValues = { ...defaults };
|
||||
/** @type {Record<string, { group: string, label: string, value: number }>} */
|
||||
const typedValues = {};
|
||||
/** @type {Set<string>} */
|
||||
const modifiedFixedKeys = new Set();
|
||||
/** @type {Set<string>} */
|
||||
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) {
|
||||
let labelOverride;
|
||||
let skip = false;
|
||||
if (ref.kind === "typed" && automaticMeta[key]?.chooseOne) {
|
||||
labelOverride = formState.chooseOneLabels?.[key]?.trim();
|
||||
if (!labelOverride) skip = true;
|
||||
}
|
||||
if (!skip) {
|
||||
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) {
|
||||
const ref = parseProfessionSkillKey(key);
|
||||
if (ref) {
|
||||
let labelOverride;
|
||||
let skip = false;
|
||||
if (ref.kind === "typed" && optionMeta[key]?.chooseOne) {
|
||||
labelOverride = formState.chooseOneLabels?.[key]?.trim();
|
||||
if (!labelOverride) skip = true;
|
||||
} else if (ref.kind === "typed") {
|
||||
labelOverride = ref.label?.trim();
|
||||
if (!labelOverride) skip = true;
|
||||
}
|
||||
if (!skip) {
|
||||
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<string, number>} */
|
||||
const bonusCounts = {};
|
||||
|
||||
const bonusIds = formState.bonusCatalogIds ?? [];
|
||||
const bonusLabels = formState.bonusTypedLabels ?? [];
|
||||
|
||||
for (let i = 0; i < BONUS_SKILL_COUNT; i++) {
|
||||
const catalogId = bonusIds[i];
|
||||
if (catalogId) {
|
||||
const typedLabel = bonusLabels[i] ?? "";
|
||||
const ref = catalogIdToSkillRef(catalogId, typedLabel);
|
||||
if (ref) {
|
||||
if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key);
|
||||
else modifiedTypedKeys.add(formatProfessionSkillKey(ref));
|
||||
|
||||
let trackKey;
|
||||
if (ref.kind === "fixed") {
|
||||
trackKey = `fixed:${ref.key}`;
|
||||
} else {
|
||||
trackKey = formatProfessionSkillKey(ref);
|
||||
}
|
||||
bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-bonus base for cap warnings */
|
||||
const baseFixed = { ...fixedValues };
|
||||
const baseTyped = foundry.utils.deepClone(typedValues);
|
||||
|
||||
for (const [trackKey, count] of Object.entries(bonusCounts)) {
|
||||
const bonus = count * BONUS_SKILL_INCREMENT;
|
||||
if (trackKey.startsWith("fixed:")) {
|
||||
const key = trackKey.slice(6);
|
||||
fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus;
|
||||
} else {
|
||||
const ref = parseProfessionSkillKey(trackKey);
|
||||
if (ref?.kind === "typed") {
|
||||
const base = typedValues[trackKey]?.value ?? 0;
|
||||
typedValues[trackKey] = {
|
||||
group: ref.group,
|
||||
label: ref.label,
|
||||
value: base + bonus,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @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],
|
||||
};
|
||||
}
|
||||
@@ -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<string>} */
|
||||
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})(?::(.+))?$/;
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
formatProfessionSkillKeyLabel,
|
||||
formatProfessionSkillLabel,
|
||||
} from "./keys.js";
|
||||
|
||||
/**
|
||||
* Sorted fixed skill rows for display.
|
||||
* @param {Record<string, number>} 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<string, { group: string, label: string, value: number }>} 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<string, number>} skillMap
|
||||
* @param {Record<string, ProfessionSkillMeta>} [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;
|
||||
}
|
||||
@@ -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 { default as computeSkillValues } from "./compute.js";
|
||||
export {
|
||||
validateProfessionFormState,
|
||||
formatProfessionValidationMessages,
|
||||
} from "./validation.js";
|
||||
export {
|
||||
buildSortedFixedSkillRows,
|
||||
buildSortedTypedSkillRows,
|
||||
prepareProfessionSkillRows,
|
||||
} from "./display.js";
|
||||
@@ -0,0 +1,272 @@
|
||||
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<string, ProfessionSkillMeta>} automaticMeta
|
||||
* @param {Record<string, ProfessionSkillMeta>} optionMeta
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isChooseOneProfessionSkillKey(
|
||||
mapKey,
|
||||
automaticMeta = {},
|
||||
optionMeta = {},
|
||||
) {
|
||||
return Boolean(
|
||||
automaticMeta[mapKey]?.chooseOne ?? optionMeta[mapKey]?.chooseOne,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
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} 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 {
|
||||
const [, , , fallbackLabel] = internalMatch;
|
||||
label = fallbackLabel;
|
||||
}
|
||||
}
|
||||
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 {object} params
|
||||
* @param {string} params.group
|
||||
* @param {string} params.label
|
||||
* @param {boolean} params.chooseOne
|
||||
* @param {Record<string, number>} params.automaticSkills
|
||||
* @param {Record<string, number>} params.optionSkills
|
||||
* @param {Record<string, ProfessionSkillMeta>} [params.automaticMeta]
|
||||
* @param {Record<string, ProfessionSkillMeta>} [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) {
|
||||
const ref = parseProfessionSkillKey(mapKey);
|
||||
if (
|
||||
ref &&
|
||||
ref.kind === "typed" &&
|
||||
normalizeTypedGroup(ref.group) === normalizedGroup &&
|
||||
!metaMap[mapKey]?.chooseOne &&
|
||||
normalizeTypedSkillName(ref.label) === normalizedNew
|
||||
) {
|
||||
return mapKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 {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<string, ProfessionSkillMeta>} [metaContext.automaticMeta]
|
||||
* @param {Record<string, ProfessionSkillMeta>} [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<string, number>} obj
|
||||
* @returns {{ optionPicks: number, skills: Record<string, number> }}
|
||||
*/
|
||||
export function splitProfessionSkillMap(obj) {
|
||||
const source = obj ?? {};
|
||||
const optionPicks = Number(source[PROFESSION_OPTION_PICKS_KEY]) || 0;
|
||||
/** @type {Record<string, number>} */
|
||||
const skills = {};
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key !== PROFESSION_OPTION_PICKS_KEY) {
|
||||
skills[key] = Number(value);
|
||||
}
|
||||
}
|
||||
return { optionPicks, skills };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
export const STAT_MIN = 3;
|
||||
export const STAT_MAX = 18;
|
||||
export const ROLL_STAT_FORMULA = "4d6dl";
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getStatisticLabel(key) {
|
||||
return game.i18n.localize(`DG.Attributes.${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ key: string, label: string }[]}
|
||||
*/
|
||||
export function buildStatisticRows() {
|
||||
return STAT_KEYS.map((key) => ({
|
||||
key,
|
||||
label: getStatisticLabel(key),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {Record<string, number>} valuesByKey
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function applyAgentStatistics(actor, valuesByKey) {
|
||||
const updateData = {};
|
||||
for (const key of STAT_KEYS) {
|
||||
updateData[`system.statistics.${key}.value`] = valuesByKey[key];
|
||||
}
|
||||
await actor.update(updateData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, number>} values
|
||||
* @returns {{ isValid: boolean, remaining: number }}
|
||||
*/
|
||||
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) {
|
||||
return { isValid: false, remaining: POINT_BUY_TOTAL - sum };
|
||||
}
|
||||
sum += value;
|
||||
}
|
||||
const remaining = POINT_BUY_TOTAL - sum;
|
||||
return { isValid: remaining === 0, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options]
|
||||
* @param {Actor|null} [options.actor]
|
||||
* @param {TokenDocument|null} [options.token]
|
||||
* @param {boolean} [options.animate=true]
|
||||
* @returns {Promise<{ total: number, roll: Roll }[]>}
|
||||
*/
|
||||
export async function rollStatisticScores({
|
||||
actor = null,
|
||||
token = null,
|
||||
animate = true,
|
||||
} = {}) {
|
||||
const rollPromises = [];
|
||||
for (let i = 0; i < STAT_KEYS.length; i++) {
|
||||
rollPromises.push(new Roll(ROLL_STAT_FORMULA).evaluate());
|
||||
}
|
||||
const rolls = await Promise.all(rollPromises);
|
||||
|
||||
if (animate) {
|
||||
await showDiceSoNicePooledRolls(rolls, { actor, token });
|
||||
}
|
||||
|
||||
return rolls.map((roll, index) => ({
|
||||
index,
|
||||
total: roll.total,
|
||||
roll,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ total: number }[]} rolls
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildRollStatsChatContent(rolls) {
|
||||
const cells = rolls.map(
|
||||
(entry, index) =>
|
||||
`<td class="roll-stats-chat-value roll-stats-chat-value--${
|
||||
index % 2 === 0 ? "a" : "b"
|
||||
}">${foundry.utils.escapeHTML(String(entry.total))}</td>`,
|
||||
);
|
||||
const rows = [];
|
||||
for (let i = 0; i < cells.length; i += 3) {
|
||||
rows.push(`<tr>${cells.slice(i, i + 3).join("")}</tr>`);
|
||||
}
|
||||
return `<table class="roll-stats-chat-table"><tbody>${rows.join(
|
||||
"",
|
||||
)}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
export function getDefaultPointBuyValues() {
|
||||
return Object.fromEntries(STAT_KEYS.map((key) => [key, STAT_MIN]));
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { BONUS_SKILL_COUNT } 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<string, number>} [context.automaticSkills]
|
||||
* @param {Record<string, number>} [context.optionSkills]
|
||||
* @param {Record<string, ProfessionSkillMeta>} [context.automaticMeta]
|
||||
* @param {Record<string, ProfessionSkillMeta>} [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 = {},
|
||||
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}`);
|
||||
} else {
|
||||
const ref = catalogIdToSkillRef(
|
||||
catalogId,
|
||||
formState.bonusTypedLabels?.[i] ?? "",
|
||||
);
|
||||
if (!ref) {
|
||||
errors.push(`bonus${i}`);
|
||||
} else if (
|
||||
ref.kind === "typed" &&
|
||||
!formState.bonusTypedLabels?.[i]?.trim()
|
||||
) {
|
||||
errors.push(`bonusType${i}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
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") {
|
||||
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") {
|
||||
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<string>} */
|
||||
const typedNameRequiredGroups = new Set();
|
||||
/** @type {string[]} */
|
||||
const bonusWasteTrackKeys = [];
|
||||
|
||||
for (const code of errors) {
|
||||
if (code === "optionPicks") {
|
||||
needsOptionPicks = true;
|
||||
} else if (code.startsWith("bondName")) {
|
||||
needsBondName = true;
|
||||
} else if (code.startsWith("bondRelationship")) {
|
||||
needsBondRelationship = true;
|
||||
} else {
|
||||
const bonusWaste = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/);
|
||||
if (bonusWaste) {
|
||||
bonusWasteTrackKeys.push(bonusWaste[2]);
|
||||
} else if (code.startsWith("bonusType")) {
|
||||
needsBonusSkillType = true;
|
||||
} else if (/^bonus\d+$/.test(code)) {
|
||||
needsBonusSkill = true;
|
||||
} else {
|
||||
const typedNameRequired = code.match(/^typedNameRequired:(.+)$/);
|
||||
if (typedNameRequired) {
|
||||
typedNameRequiredGroups.add(typedNameRequired[1]);
|
||||
} else {
|
||||
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 bonusWasteTrackKeys) {
|
||||
push(
|
||||
game.i18n.format("DG.Profession.Dialog.BonusSkillWasteTooHigh", {
|
||||
skill: getBonusTrackLabel(trackKey),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -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<ChatMessage>} - the created chat message.
|
||||
* @override
|
||||
*/
|
||||
async toChat() {
|
||||
let label = this.formula;
|
||||
try {
|
||||
label = `<b>${game.i18n
|
||||
.localize("DG.Roll.Damage")
|
||||
.toUpperCase()}</b> ${game.i18n.localize("DG.Roll.For")} ${
|
||||
this.item.name
|
||||
} (<b>${
|
||||
this.item.system.armorPiercing
|
||||
} </b><img class="armor-piercing-chat-card-img" src="systems/deltagreen/assets/icons/supersonic-bullet.svg" alt="armor penetration"/>)`;
|
||||
} catch (ex) {
|
||||
// console.log(ex);
|
||||
label = `<b>DAMAGE</b> for <b>${label.toUpperCase()}</b>`;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage>} - 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 = `<b>${game.i18n
|
||||
.localize("DG.Roll.Lethality")
|
||||
.toUpperCase()}</b> ${game.i18n.localize(
|
||||
"DG.Roll.For",
|
||||
)} <b>${this.item.name.toUpperCase()}</b> ${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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
/** @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 "../../integrations/dice-so-nice.js";
|
||||
import {
|
||||
clampPercentileRollTarget,
|
||||
getRollTargetDisplayClassFromModifier,
|
||||
} from "../../active-effect/runtime/derived.js";
|
||||
import { DGRoll } from "./dg-roll.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
export class DGPercentileRoll extends DGRoll {
|
||||
/**
|
||||
* Creates D100 rolls, the base die of the system.
|
||||
*
|
||||
* This constructor embeds the following info into the roll:
|
||||
* 1. Target number that the roll needs to beat.
|
||||
* 2. Localized name for the roll.
|
||||
*
|
||||
* Note: In order for all of our custom data to persist, our constructor must use the same parameters as its parent class.
|
||||
* So, even though percentile rolls will always have a formula of "1d100" and we don't use the `data` object,
|
||||
* we still have to keep them as parameters.
|
||||
*
|
||||
* @param {string} formula Unused - The string formula to parse (from Foundry) - Always "1d100" for percentile rolls.
|
||||
* @param {Object} data Unused - The data object against which to parse attributes within the formula
|
||||
* @param {Object} [options] Additional data which is preserved in the database
|
||||
* @param {Number} [options.rollType] The type of roll (stat, skill, sanity, etc).
|
||||
* @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll.
|
||||
* @param {DeltaGreenActor} [options.actor] The actor that this roll originates from.
|
||||
* @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates.
|
||||
* @param {DeltaGreenItem} [options.specialTrainingName] Optional - Special training rolls have names that are different from the roll key.
|
||||
*/
|
||||
// eslint-disable-next-line default-param-last, no-unused-vars
|
||||
constructor(formula = "1D100", data = {}, options) {
|
||||
super("1D100", {}, options);
|
||||
|
||||
// Set roll info for Skill, Stat, Typed Skill, and non-custom Weapon Percentile rolls.
|
||||
const { target, localizedKey, skillPath } = this.getRollInfoFromKey();
|
||||
this.target = target;
|
||||
this.localizedKey = localizedKey;
|
||||
this.skillPath = skillPath;
|
||||
|
||||
// Set roll info for other Percentile rolls
|
||||
switch (this.type) {
|
||||
case "special-training":
|
||||
this.specialTrainingName = options.specialTrainingName;
|
||||
this.localizedKey = `${this.specialTrainingName} - (${this.localizedKey})`;
|
||||
break;
|
||||
case "weapon":
|
||||
// If this weapon uses a custom target for rolls, we set that explicitly.
|
||||
if (this.key === "custom") {
|
||||
this.target = this.item.system.customSkillTarget;
|
||||
this.localizedKey = game.i18n.localize("DG.ItemWindow.Custom");
|
||||
}
|
||||
// Add a the weapon's internal modifier.
|
||||
this.modifier += this.item.system.skillModifier;
|
||||
break;
|
||||
case "sanity":
|
||||
this.target = this.actor.system.sanity.value;
|
||||
this.localizedKey = game.i18n.localize("DG.Attributes.SAN");
|
||||
break;
|
||||
case "luck":
|
||||
this.target = 50;
|
||||
this.localizedKey = game.i18n.localize("DG.Luck");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog that can modify the roll.
|
||||
*
|
||||
* @returns {Promise<Object|void>} - the results of the dialog.
|
||||
*/
|
||||
async showDialog() {
|
||||
const privateSanSetting = game.settings.get(
|
||||
"deltagreen",
|
||||
"keepSanityPrivate",
|
||||
);
|
||||
|
||||
let hideSanTarget = false;
|
||||
if (
|
||||
privateSanSetting &&
|
||||
(this.type === "sanity" || this.key === "ritual") &&
|
||||
!game.user.isGM
|
||||
) {
|
||||
hideSanTarget = true;
|
||||
}
|
||||
|
||||
let customModifierTarget = 20;
|
||||
|
||||
if (this.actor != null) {
|
||||
try {
|
||||
customModifierTarget = parseInt(
|
||||
this.actor.system.settings.rolling.defaultPercentileModifier,
|
||||
);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
const { rollTargetModifier } = this;
|
||||
const targetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(rollTargetModifier);
|
||||
|
||||
return showPercentileRollModifyDialog({
|
||||
label: this.localizedKey,
|
||||
target: this.target,
|
||||
targetDisplayClass,
|
||||
hideTarget: hideSanTarget,
|
||||
defaultModifier: customModifierTarget,
|
||||
actor: this.actor,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares data for a chat message and then passes that data
|
||||
* to a method that actually creates a ChatMessage.
|
||||
*
|
||||
* Lays out and styles message based on outcome of the roll.
|
||||
*
|
||||
* @returns {Promise<ChatMessage>} - the created chat message.
|
||||
*/
|
||||
async toChat() {
|
||||
// if using private san rolls, must hide any SAN roll unless user is a GM
|
||||
const privateSanSetting = game.settings.get(
|
||||
"deltagreen",
|
||||
"keepSanityPrivate",
|
||||
);
|
||||
if (
|
||||
privateSanSetting &&
|
||||
(this.type === "sanity" || this.key === "ritual") &&
|
||||
!game.user.isGM
|
||||
) {
|
||||
this.options.messageMode = "blind";
|
||||
}
|
||||
|
||||
const label = this.createLabel();
|
||||
|
||||
let resultString = "";
|
||||
let styleOverride = "";
|
||||
|
||||
if (this.isSuccess) {
|
||||
if (this.isCritical) {
|
||||
resultString = `${game.i18n.localize("DG.Roll.CriticalSuccess")}`;
|
||||
resultString = `${resultString.toUpperCase()}`;
|
||||
styleOverride = "color: green";
|
||||
} else {
|
||||
resultString = `${game.i18n.localize("DG.Roll.Success")}`;
|
||||
}
|
||||
} else if (this.isCritical) {
|
||||
resultString = `${game.i18n.localize("DG.Roll.CriticalFailure")}`;
|
||||
resultString = `${resultString.toUpperCase()}`;
|
||||
styleOverride = "color: red";
|
||||
} else {
|
||||
resultString = `${game.i18n.localize("DG.Roll.Failure")}`;
|
||||
}
|
||||
|
||||
const failureMark =
|
||||
this.actor?.type === "agent" &&
|
||||
!this.isSuccess &&
|
||||
this.skillPath &&
|
||||
this.key !== "unnatural" &&
|
||||
!foundry.utils.getProperty(this.actor, `${this.skillPath}.failure`) &&
|
||||
game.settings.get(DG.ID, "skillFailure");
|
||||
|
||||
const html = await renderTemplate(
|
||||
"systems/deltagreen/templates/roll/percentile-roll.hbs",
|
||||
{
|
||||
styleOverride,
|
||||
resultString,
|
||||
formula: this.formula,
|
||||
total: this.total,
|
||||
failureMark,
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: add setting for it?
|
||||
if (failureMark) {
|
||||
const keyForUpdate = `${this.skillPath}.failure`;
|
||||
const diceSoNice = isDiceSoNiceAvailable();
|
||||
|
||||
const message = await this.toMessage({
|
||||
flags: {
|
||||
deltagreen: {
|
||||
rollbacks: {
|
||||
[keyForUpdate]: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
content: html,
|
||||
label,
|
||||
});
|
||||
|
||||
if (diceSoNice) {
|
||||
await waitForDiceSoNiceMessageAnimation(message.id);
|
||||
}
|
||||
|
||||
// TODO: auto-update actor or post icon with manual apply
|
||||
await this.actor.update({
|
||||
[keyForUpdate]: true,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return this.toMessage({ content: html, label });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve roll target, localized label, and skill path from `this.key` and `this.actor`.
|
||||
*
|
||||
* Used for Stat, Skill, Typed Skill, Weapon, and Special Training Rolls.
|
||||
*
|
||||
* @returns {{ target: number|null, localizedKey: string|null, skillPath: string|null }}
|
||||
*/
|
||||
getRollInfoFromKey() {
|
||||
const actorData = this.actor.system;
|
||||
const skillKeys = Object.keys(actorData.skills);
|
||||
const typedSkillKeys = Object.keys(actorData.typedSkills);
|
||||
const statKeys = Object.keys(actorData.statistics);
|
||||
|
||||
let target = null;
|
||||
let localizedKey = null;
|
||||
let skillPath = null; // For optimization of failure checks
|
||||
if (statKeys.includes(this.key)) {
|
||||
target = actorData.statistics[this.key].x5;
|
||||
localizedKey = game.i18n.localize(`DG.Attributes.${this.key}`);
|
||||
}
|
||||
if (skillKeys.includes(this.key)) {
|
||||
// use calculated target proficiency (effects and etc like aim + 20%)
|
||||
target =
|
||||
actorData.skills[this.key].targetProficiency ||
|
||||
actorData.skills[this.key].proficiency;
|
||||
localizedKey = game.i18n.localize(`DG.Skills.${this.key}`);
|
||||
skillPath = `system.skills.${this.key}`;
|
||||
}
|
||||
if (typedSkillKeys.includes(this.key)) {
|
||||
const skill = actorData.typedSkills[this.key];
|
||||
target = skill.targetProficiency ?? skill.proficiency;
|
||||
localizedKey = `${skill.group} (${skill.label})`;
|
||||
skillPath = `system.typedSkills.${this.key}`;
|
||||
}
|
||||
if (this.key === "ritual") {
|
||||
target = actorData.sanity.ritual;
|
||||
localizedKey = game.i18n.localize(`DG.Skills.ritual`);
|
||||
}
|
||||
return { target, localizedKey, skillPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create label based on result of roll
|
||||
*
|
||||
* todo: do we want make isInhuman more similar to base label?
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
createLabel() {
|
||||
const startOfLabel = `<b>${this.localizedKey}`;
|
||||
const { rollTargetModifier } = this;
|
||||
const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${
|
||||
this.effectiveTarget
|
||||
}`;
|
||||
|
||||
let label = this.isInhuman
|
||||
? // "Inhuman" stat being rolled. See function for details.
|
||||
`${startOfLabel} [${game.i18n
|
||||
.localize("DG.Roll.Inhuman")
|
||||
.toUpperCase()}]</b> ${endOfLabel}`
|
||||
: `${startOfLabel}</b><br> ${endOfLabel}%`;
|
||||
|
||||
if (this.modifier || rollTargetModifier) {
|
||||
label += ` (${this.target}%`;
|
||||
|
||||
if (this.modifier) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(this.modifier)}%`;
|
||||
}
|
||||
|
||||
if (rollTargetModifier) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(rollTargetModifier)}%`;
|
||||
}
|
||||
|
||||
label += `)`;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active Effect roll-target modifier for this roll type (excludes luck).
|
||||
* @returns {number}
|
||||
*/
|
||||
get rollTargetModifier() {
|
||||
if (this.type === "luck") return 0;
|
||||
|
||||
try {
|
||||
const { rollTarget } = this.actor.system;
|
||||
if (!rollTarget) return 0;
|
||||
|
||||
if (this.type === "sanity") return Number(rollTarget.sanity) || 0;
|
||||
if (this.type === "stat") return Number(rollTarget.statistics) || 0;
|
||||
return Number(rollTarget.allSkills) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Inhuman" stat being rolled, logic is different per page 188 of the Handler's Guide.
|
||||
* Note - originally implemented by Uriele, but my attempt at merging conficts went poorly, so re-implementing.
|
||||
* For an inhuman check, the roll succeeds except on a roll of 100 which fails AND fumbles.
|
||||
* If the roll is a matching digit roll, it is a critical as normal.
|
||||
* Also, if the roll is below the regular (non-x5) value of the stat, it is a critical. E.g. a CON of 25, a d100 roll of 21 would be a critical.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
get isInhuman() {
|
||||
/*
|
||||
Changing this to only consider the base x5 stat target for whether something is 'inhuman'
|
||||
because I do not think the intent was an Agent with a high strength getting a +40% bonus to be considered 'inhuman'
|
||||
and therefore benefit from the increased crit threshold, although could be wrong about this.
|
||||
*/
|
||||
if (this.target > 99 && this.type === "stat") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a roll result is critical.
|
||||
* If roll has not been evaluated, return null.
|
||||
*
|
||||
* @returns {null|Boolean}
|
||||
*/
|
||||
get isCritical() {
|
||||
// If roll isn't evaluated, return null.
|
||||
if (!this.total) {
|
||||
return null;
|
||||
}
|
||||
let isCritical = false;
|
||||
|
||||
// 1, 100, or any matching dice are a crit, i.e. 11, 22, 33...99.
|
||||
if (this.total === 1 || this.total === 100 || this.total % 11 === 0) {
|
||||
// really good, or reeaaaally bad
|
||||
isCritical = true;
|
||||
}
|
||||
|
||||
// If inhuman and the roll is below the regular (non-x5) value of the stat, it is a critical.
|
||||
// E.g. a CON of 25, a d100 roll of 21 would be a critical.
|
||||
if (this.isInhuman && this.total <= this.target / 5) {
|
||||
isCritical = true;
|
||||
}
|
||||
|
||||
return isCritical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a roll succeeded.
|
||||
* If roll has not been evaluated, return null.
|
||||
*
|
||||
* @returns {null|Boolean}
|
||||
*/
|
||||
get isSuccess() {
|
||||
// If roll isn't evaluated, return null.
|
||||
if (!this.total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A roll of 100 always (critically) fails, even for inhuman rolls.
|
||||
if (this.total === 100) return false;
|
||||
return this.total <= this.effectiveTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual target for the roll accounting for modifier if present.
|
||||
* Floored to 1 if a negative modifier would bring it below 1.
|
||||
* Capped at 99 unless it is an inhuman stat test.
|
||||
* Also worth noting, per page 47 of the Agent's Handbook, Exhaustion penalties
|
||||
* affect not only skill and stat tests, but SAN tests as well...
|
||||
*
|
||||
* @returns {null|integer}
|
||||
*/
|
||||
get effectiveTarget() {
|
||||
let target = 1;
|
||||
|
||||
const { rollTargetModifier } = this;
|
||||
|
||||
if (!this.target || Number.isNaN(this.target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
target = parseInt(this.target);
|
||||
|
||||
if (rollTargetModifier) {
|
||||
target = clampPercentileRollTarget(target, rollTargetModifier, {
|
||||
allowOver99: this.target > 99 && this.type === "stat",
|
||||
});
|
||||
}
|
||||
|
||||
if (this.modifier && !Number.isNaN(this.modifier)) {
|
||||
const modifier = parseInt(this.modifier);
|
||||
|
||||
target += modifier;
|
||||
|
||||
// per agent's handbook (pg.43), a negative modifier can't lower a target below 1%
|
||||
target = Math.max(target, 1);
|
||||
}
|
||||
|
||||
// an 'inhuman' stat test can exceed 99% as a target, but skill tests otherwise cannot (agents handbook pg.43)
|
||||
if (!this.isInhuman) {
|
||||
target = Math.min(target, 99);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage|object>} 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();
|
||||
}
|
||||
}
|
||||
@@ -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<ChatMessage>} - 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 = `<b>${DGUtils.localizeWithFallback(
|
||||
"DG.Generic.SanDamage",
|
||||
"SAN DAMAGE",
|
||||
)}</b> For <b>${lowDie.formula} / ${highDie.formula}</b>`;
|
||||
|
||||
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<Number>} - Array of result numbers
|
||||
*/
|
||||
get damageResults() {
|
||||
if (!this.total) return null;
|
||||
|
||||
const [lowResult, highResult] = this.terms[0].results;
|
||||
return [lowResult?.result, highResult?.result];
|
||||
}
|
||||
}
|
||||
@@ -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 default 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}`;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { showDgDialog } from "../applications/dg-dialog.js";
|
||||
import DGUtils from "../utils/utility-functions.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
const PERCENTILE_ROLL_DIALOG_TEMPLATE =
|
||||
"systems/deltagreen/templates/dialog/modify-percentile-roll.html";
|
||||
const DAMAGE_ROLL_DIALOG_TEMPLATE =
|
||||
"systems/deltagreen/templates/dialog/modify-damage-roll.html";
|
||||
|
||||
const QUICK_MODIFIER_PRESETS = [
|
||||
{
|
||||
action: "modMinus40",
|
||||
mod: -40,
|
||||
label: "−40",
|
||||
explanationKey: "DG.ModifySkillRollDialogue.ModifierMuchHarder",
|
||||
},
|
||||
{
|
||||
action: "modMinus20",
|
||||
mod: -20,
|
||||
label: "−20",
|
||||
explanationKey: "DG.ModifySkillRollDialogue.ModifierHarder",
|
||||
},
|
||||
{
|
||||
action: "modPlus20",
|
||||
mod: 20,
|
||||
label: "+20",
|
||||
explanationKey: "DG.ModifySkillRollDialogue.ModifierEasier",
|
||||
},
|
||||
{
|
||||
action: "modPlus40",
|
||||
mod: 40,
|
||||
label: "+40",
|
||||
explanationKey: "DG.ModifySkillRollDialogue.ModifierMuchEasier",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function readMessageMode(dialog) {
|
||||
return dialog.element.querySelector("[name='messageMode']")?.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.label
|
||||
* @param {number} options.target
|
||||
* @param {boolean} [options.hideTarget]
|
||||
* @param {number} [options.defaultModifier]
|
||||
* @param {Actor|null} [options.actor]
|
||||
* @returns {Promise<{ targetModifier: number, messageMode?: string }|void>}
|
||||
*/
|
||||
export async function showPercentileRollModifyDialog({
|
||||
label,
|
||||
target,
|
||||
targetDisplayClass = "",
|
||||
hideTarget = false,
|
||||
defaultModifier = 20,
|
||||
actor = null,
|
||||
}) {
|
||||
const quickModifiers = QUICK_MODIFIER_PRESETS.map((preset) => ({
|
||||
...preset,
|
||||
explanation: game.i18n.localize(preset.explanationKey),
|
||||
}));
|
||||
|
||||
const content = await renderTemplate(PERCENTILE_ROLL_DIALOG_TEMPLATE, {
|
||||
data: {
|
||||
label,
|
||||
originalTarget: target,
|
||||
targetDisplayClass,
|
||||
targetModifier: defaultModifier,
|
||||
hideTarget,
|
||||
},
|
||||
quickModifiers,
|
||||
});
|
||||
|
||||
/** @type {{ targetModifier: number, messageMode?: string } | null} */
|
||||
let dialogResult = null;
|
||||
|
||||
/**
|
||||
* @param {DialogV2} dialog
|
||||
* @param {{ targetModifier: number, messageMode?: string }} result
|
||||
*/
|
||||
const resolveAndClose = async (dialog, result) => {
|
||||
dialogResult = result;
|
||||
await dialog.close();
|
||||
return result;
|
||||
};
|
||||
|
||||
// Footer buttons are callback-only; visible controls live in modify-percentile-roll.html.
|
||||
const hiddenFooterButton = { class: "hidden" };
|
||||
|
||||
const presetButtons = QUICK_MODIFIER_PRESETS.map(({ action, mod }) => ({
|
||||
action,
|
||||
label: "",
|
||||
...hiddenFooterButton,
|
||||
callback: async (_event, _button, dialog) =>
|
||||
resolveAndClose(dialog, {
|
||||
targetModifier: mod,
|
||||
messageMode: readMessageMode(dialog),
|
||||
}),
|
||||
}));
|
||||
|
||||
return showDgDialog({
|
||||
modifier: "modify-roll",
|
||||
content,
|
||||
form: { closeOnSubmit: false },
|
||||
position: { width: 420 },
|
||||
window: {
|
||||
title: DGUtils.localizeWithFallback(
|
||||
"DG.ModifySkillRollDialogue.Title",
|
||||
"Modify Roll",
|
||||
),
|
||||
},
|
||||
close: () => dialogResult,
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
action: "roll",
|
||||
label: "",
|
||||
...hiddenFooterButton,
|
||||
callback: async (_event, _button, dialog) => {
|
||||
const raw =
|
||||
dialog.element
|
||||
.querySelector("[name='targetModifier']")
|
||||
?.value?.trim() ?? "";
|
||||
const targetModifier = Number(raw);
|
||||
|
||||
if (raw === "" || Number.isNaN(targetModifier)) {
|
||||
ui.notifications.warn(
|
||||
DGUtils.localizeWithFallback(
|
||||
"DG.ModifySkillRollDialogue.InvalidModifier",
|
||||
"Enter a valid modifier to roll.",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actor != null) {
|
||||
await actor.update({
|
||||
"system.settings.rolling.defaultPercentileModifier":
|
||||
targetModifier,
|
||||
});
|
||||
}
|
||||
|
||||
return resolveAndClose(dialog, {
|
||||
targetModifier,
|
||||
messageMode: readMessageMode(dialog),
|
||||
});
|
||||
},
|
||||
},
|
||||
...presetButtons,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} [options.itemName]
|
||||
* @param {string} options.formula
|
||||
* @returns {Promise<{ newFormula: string, messageMode?: string }|void>}
|
||||
*/
|
||||
export async function showDamageRollModifyDialog({ itemName, formula }) {
|
||||
const content = await renderTemplate(DAMAGE_ROLL_DIALOG_TEMPLATE, {
|
||||
data: {
|
||||
label: itemName,
|
||||
originalFormula: formula,
|
||||
outerModifier: "2 * ",
|
||||
innerModifier: "+ 0",
|
||||
},
|
||||
});
|
||||
|
||||
return showDgDialog({
|
||||
modifier: "modify-damage-roll",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"),
|
||||
},
|
||||
close: () => null,
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
label: game.i18n.translations.DG.Roll.Roll,
|
||||
action: "roll",
|
||||
callback: (_event, _button, dialog) => {
|
||||
const outerModifier = dialog.element.querySelector(
|
||||
"[name='outerModifier']",
|
||||
)?.value;
|
||||
let innerModifier = dialog.element.querySelector(
|
||||
"[name='innerModifier']",
|
||||
)?.value;
|
||||
const modifiedBaseRoll = dialog.element.querySelector(
|
||||
"[name='originalFormula']",
|
||||
)?.value;
|
||||
const messageMode = dialog.element.querySelector(
|
||||
"[name='targetRollMode']",
|
||||
)?.value;
|
||||
|
||||
if (innerModifier.replace(" ", "") === "+0") {
|
||||
innerModifier = "";
|
||||
}
|
||||
|
||||
let newFormula = "";
|
||||
if (outerModifier.trim() !== "") {
|
||||
newFormula += `${outerModifier}(${modifiedBaseRoll}${innerModifier.trim()})`;
|
||||
} else {
|
||||
newFormula += modifiedBaseRoll + innerModifier.trim();
|
||||
}
|
||||
|
||||
return { newFormula, messageMode };
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
+104
-833
@@ -1,847 +1,118 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import DGUtils from "../other/utility-functions.js";
|
||||
import DG from "../config.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";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { DialogV2 } = foundry.applications.api;
|
||||
export {
|
||||
DGRoll,
|
||||
DGPercentileRoll,
|
||||
DGLethalityRoll,
|
||||
DGDamageRoll,
|
||||
DGSanityDamageRoll,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Build a Delta Green roll from a click target's dataset.
|
||||
*
|
||||
* @param {DOMStringMap} dataset
|
||||
* @param {object} params
|
||||
* @param {Actor|null} [params.actor]
|
||||
* @param {Item|null} [params.item]
|
||||
* @param {HTMLElement} [params.element]
|
||||
* @param {"actor"|"item"} [params.sanityDamageSource]
|
||||
* @returns {Roll}
|
||||
*/
|
||||
export function createDGRollFromDataset(
|
||||
dataset,
|
||||
{
|
||||
actor = null,
|
||||
item = null,
|
||||
element = null,
|
||||
sanityDamageSource = "actor",
|
||||
token = null,
|
||||
} = {},
|
||||
) {
|
||||
const rollOptions = {
|
||||
rollType: dataset.rolltype,
|
||||
key: dataset.key,
|
||||
actor,
|
||||
specialTrainingName: dataset?.name || null,
|
||||
item,
|
||||
token,
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple function that actually creates the message and sends it to chat.
|
||||
* We override this to have a little more control over certain aspects of the message,
|
||||
* right now, its `speaker` and `rollMode`.
|
||||
*
|
||||
* @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.rollMode] The template roll mode to use for the message from CONFIG.Dice.rollModes
|
||||
* @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the
|
||||
* prepared chatData object.
|
||||
* @returns {Promise<ChatMessage|object>} A promise which resolves to the created ChatMessage document if create is
|
||||
* true, or the Object of prepared chatData otherwise.
|
||||
*/
|
||||
async toMessage(messageData = {}, { rollMode, create = true } = {}) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
messageData.speaker = ChatMessage.getSpeaker({ actor: this.actor });
|
||||
return super.toMessage(messageData, {
|
||||
rollMode: this.options.rollMode || rollMode,
|
||||
create,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DGPercentileRoll extends DGRoll {
|
||||
/**
|
||||
* Creates D100 rolls, the base die of the system.
|
||||
*
|
||||
* This constructor embeds the following info into the roll:
|
||||
* 1. Target number that the roll needs to beat.
|
||||
* 2. Localized name for the roll.
|
||||
*
|
||||
* Note: In order for all of our custom data to persist, our constructor must use the same parameters as its parent class.
|
||||
* So, even though percentile rolls will always have a formula of "1d100" and we don't use the `data` object,
|
||||
* we still have to keep them as parameters.
|
||||
*
|
||||
* @param {string} formula Unused - The string formula to parse (from Foundry) - Always "1d100" for percentile rolls.
|
||||
* @param {Object} data Unused - The data object against which to parse attributes within the formula
|
||||
* @param {Object} [options] Additional data which is preserved in the database
|
||||
* @param {Number} [options.rollType] The type of roll (stat, skill, sanity, etc).
|
||||
* @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll.
|
||||
* @param {DeltaGreenActor} [options.actor] The actor that this roll originates from.
|
||||
* @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates.
|
||||
* @param {DeltaGreenItem} [options.specialTrainingName] Optional - Special training rolls have names that are different from the roll key.
|
||||
*/
|
||||
// eslint-disable-next-line default-param-last, no-unused-vars
|
||||
constructor(formula = "1D100", data = {}, options) {
|
||||
super("1D100", {}, options);
|
||||
|
||||
// Set roll info for Skill, Stat, Typed Skill, and non-custom Weapon Percentile rolls.
|
||||
const { target, localizedKey, skillPath } = this.getRollInfoFromKey(
|
||||
this.key,
|
||||
this.actor.system,
|
||||
);
|
||||
this.target = target;
|
||||
this.localizedKey = localizedKey;
|
||||
this.skillPath = skillPath;
|
||||
|
||||
// Set roll info for other Percentile rolls
|
||||
switch (this.type) {
|
||||
case "special-training":
|
||||
this.specialTrainingName = options.specialTrainingName;
|
||||
this.localizedKey = `${this.specialTrainingName} - (${this.localizedKey})`;
|
||||
break;
|
||||
case "weapon":
|
||||
// If this weapon uses a custom target for rolls, we set that explicitly.
|
||||
if (this.key === "custom") {
|
||||
this.target = this.item.system.customSkillTarget;
|
||||
this.localizedKey = game.i18n.localize("DG.ItemWindow.Custom");
|
||||
}
|
||||
// Add a the weapon's internal modifier.
|
||||
this.modifier += this.item.system.skillModifier;
|
||||
break;
|
||||
case "sanity":
|
||||
this.target = this.actor.system.sanity.value;
|
||||
this.localizedKey = game.i18n.localize("DG.Attributes.SAN");
|
||||
break;
|
||||
case "luck":
|
||||
this.target = 50;
|
||||
this.localizedKey = game.i18n.localize("DG.Luck");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
let roll = new Roll("1d100", {});
|
||||
switch (dataset.rolltype) {
|
||||
case "stat":
|
||||
case "skill":
|
||||
case "sanity":
|
||||
case "special-training":
|
||||
case "weapon":
|
||||
case "luck":
|
||||
roll = new DGPercentileRoll("1D100", {}, rollOptions);
|
||||
break;
|
||||
case "lethality":
|
||||
roll = new DGLethalityRoll("1D100", {}, rollOptions);
|
||||
break;
|
||||
case "damage": {
|
||||
const diceFormula = appendMeleeDamageBonus(
|
||||
item.system.damage,
|
||||
actor,
|
||||
item.system.skill,
|
||||
);
|
||||
roll = new DGDamageRoll(diceFormula, {}, rollOptions);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a dialog that can modify the roll.
|
||||
*
|
||||
* @returns {Promise<Object|void>} - the results of the dialog.
|
||||
*/
|
||||
async showDialog() {
|
||||
const privateSanSetting = game.settings.get(
|
||||
"deltagreen",
|
||||
"keepSanityPrivate",
|
||||
);
|
||||
|
||||
let hideSanTarget = false;
|
||||
if (
|
||||
privateSanSetting &&
|
||||
(this.type === "sanity" || this.key === "ritual") &&
|
||||
!game.user.isGM
|
||||
) {
|
||||
hideSanTarget = true;
|
||||
}
|
||||
|
||||
let customModifierTarget = 20;
|
||||
|
||||
if (this.actor != null) {
|
||||
try {
|
||||
customModifierTarget = parseInt(
|
||||
this.actor.system.settings.rolling.defaultPercentileModifier,
|
||||
);
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
const backingData = {
|
||||
data: {
|
||||
label: this.localizedKey,
|
||||
originalTarget: this.target,
|
||||
targetModifier: customModifierTarget,
|
||||
hideTarget: hideSanTarget,
|
||||
},
|
||||
};
|
||||
|
||||
const template =
|
||||
"systems/deltagreen/templates/dialog/modify-percentile-roll.html";
|
||||
const content = await renderTemplate(template, backingData);
|
||||
return new Promise((resolve, reject) => {
|
||||
const modButtons = [-40, -20, 20, 40].map((mod) => {
|
||||
const sign = mod > 0 ? "Positive" : "Negative";
|
||||
return {
|
||||
action: `roll${Math.abs(mod)}${sign}`,
|
||||
label: String(mod),
|
||||
callback: (event, button, dialog) => {
|
||||
try {
|
||||
const rollMode =
|
||||
dialog.element.querySelector("[name='rollMode']")?.value;
|
||||
resolve({ targetModifier: mod, rollMode });
|
||||
} catch (ex) {
|
||||
reject(console.log(ex));
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
new DialogV2({
|
||||
content,
|
||||
window: {
|
||||
title: DGUtils.localizeWithFallback(
|
||||
"DG.ModifySkillRollDialogue.Title",
|
||||
"Modify Roll",
|
||||
),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
action: "roll",
|
||||
label: DGUtils.localizeWithFallback("DG.Roll.Roll", "Roll"),
|
||||
callback: (event, button, dialog) => {
|
||||
try {
|
||||
let targetModifier = dialog.element.querySelector(
|
||||
"[name='targetModifier']",
|
||||
)?.value; // this is text as a heads up
|
||||
|
||||
const rollMode =
|
||||
dialog.element.querySelector("[name='rollMode']")?.value;
|
||||
|
||||
const plusMinus = dialog.element.querySelector(
|
||||
"[name='plusOrMinus']",
|
||||
)?.value;
|
||||
|
||||
if (
|
||||
targetModifier.trim() !== "" &&
|
||||
!Number.isNaN(targetModifier)
|
||||
) {
|
||||
targetModifier = Math.abs(parseInt(targetModifier));
|
||||
|
||||
if (plusMinus === "-") {
|
||||
targetModifier *= -1;
|
||||
}
|
||||
|
||||
// update the custom target modifier so it 'persists' between dialogs
|
||||
if (this.actor != null) {
|
||||
this.actor.update({
|
||||
"system.settings.rolling.defaultPercentileModifier":
|
||||
targetModifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
resolve({ targetModifier, rollMode });
|
||||
} catch (ex) {
|
||||
reject(console.log(ex));
|
||||
}
|
||||
},
|
||||
},
|
||||
...modButtons,
|
||||
],
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares data for a chat message and then passes that data
|
||||
* to a method that actually creates a ChatMessage.
|
||||
*
|
||||
* Lays out and styles message based on outcome of the roll.
|
||||
*
|
||||
* @returns {Promise<ChatMessage>} - the created chat message.
|
||||
*/
|
||||
async toChat() {
|
||||
// if using private san rolls, must hide any SAN roll unless user is a GM
|
||||
const privateSanSetting = game.settings.get(
|
||||
"deltagreen",
|
||||
"keepSanityPrivate",
|
||||
);
|
||||
if (
|
||||
privateSanSetting &&
|
||||
(this.type === "sanity" || this.key === "ritual") &&
|
||||
!game.user.isGM
|
||||
) {
|
||||
this.options.rollMode = "blindroll";
|
||||
}
|
||||
|
||||
const diceSoNice =
|
||||
game.modules.has("dice-so-nice") &&
|
||||
game.modules.get("dice-so-nice").active;
|
||||
|
||||
const label = this.createLabel();
|
||||
|
||||
let resultString = "";
|
||||
let styleOverride = "";
|
||||
|
||||
if (this.isSuccess) {
|
||||
if (this.isCritical) {
|
||||
resultString = `${game.i18n.localize("DG.Roll.CriticalSuccess")}`;
|
||||
resultString = `${resultString.toUpperCase()}`;
|
||||
styleOverride = "color: green";
|
||||
case "sanity-damage": {
|
||||
let successLoss;
|
||||
let failedLoss;
|
||||
if (sanityDamageSource === "item" && item) {
|
||||
const isLearntDamage = element?.hasAttribute("data-san-on-learn");
|
||||
const sanityData = isLearntDamage
|
||||
? item.system.learnedSanity
|
||||
: item.system.sanity;
|
||||
({ successLoss, failedLoss } = sanityData);
|
||||
} else if (actor) {
|
||||
({ successLoss, failedLoss } = actor.system.sanity);
|
||||
} else {
|
||||
resultString = `${game.i18n.localize("DG.Roll.Success")}`;
|
||||
break;
|
||||
}
|
||||
} else if (this.isCritical) {
|
||||
resultString = `${game.i18n.localize("DG.Roll.CriticalFailure")}`;
|
||||
resultString = `${resultString.toUpperCase()}`;
|
||||
styleOverride = "color: red";
|
||||
} else {
|
||||
resultString = `${game.i18n.localize("DG.Roll.Failure")}`;
|
||||
const combinedFormula = `{${successLoss}, ${failedLoss}}`;
|
||||
roll = new DGSanityDamageRoll(combinedFormula, {}, rollOptions);
|
||||
break;
|
||||
}
|
||||
|
||||
const failureMark =
|
||||
this.actor?.type === "agent" &&
|
||||
!this.isSuccess &&
|
||||
this.skillPath &&
|
||||
this.key !== "unnatural" &&
|
||||
!foundry.utils.getProperty(this.actor, `${this.skillPath}.failure`) &&
|
||||
game.settings.get(DG.ID, "skillFailure");
|
||||
|
||||
const html = await renderTemplate(
|
||||
"systems/deltagreen/templates/roll/percentile-roll.hbs",
|
||||
{
|
||||
styleOverride,
|
||||
resultString,
|
||||
formula: this.formula,
|
||||
total: this.total,
|
||||
failureMark,
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: add setting for it?
|
||||
if (failureMark) {
|
||||
const keyForUpdate = `${this.skillPath}.failure`;
|
||||
|
||||
const message = await this.toMessage({
|
||||
flags: {
|
||||
deltagreen: {
|
||||
rollbacks: {
|
||||
[keyForUpdate]: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
content: html,
|
||||
flavor: label,
|
||||
});
|
||||
|
||||
if (diceSoNice) {
|
||||
await game.dice3d.waitFor3DAnimationByMessageID(message.id);
|
||||
}
|
||||
|
||||
// TODO: auto-update actor or post icon with manual apply
|
||||
await this.actor.update({
|
||||
[keyForUpdate]: true,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return this.toMessage({ content: html, flavor: label });
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function, called in the DGPercentileRoll constructor.
|
||||
* If this roll key corresponds to a stat, skill,
|
||||
* or typedSkill, get pertinent info.
|
||||
*
|
||||
* This is used for Stat, Skill, Typed Skill, Weapon, and Special Training Rolls.
|
||||
*
|
||||
* @returns {Object} - Contains the roll target and localized version of the key.
|
||||
*/
|
||||
getRollInfoFromKey() {
|
||||
const actorData = this.actor.system;
|
||||
const skillKeys = Object.keys(actorData.skills);
|
||||
const typedSkillKeys = Object.keys(actorData.typedSkills);
|
||||
const statKeys = Object.keys(actorData.statistics);
|
||||
|
||||
let target = null;
|
||||
let localizedKey = null;
|
||||
let skillPath = null; // For optimization of failure checks
|
||||
if (statKeys.includes(this.key)) {
|
||||
target = actorData.statistics[this.key].x5;
|
||||
localizedKey = game.i18n.localize(`DG.Attributes.${this.key}`);
|
||||
}
|
||||
if (skillKeys.includes(this.key)) {
|
||||
// use calculated target proficiency (effects and etc like aim + 20%)
|
||||
target =
|
||||
actorData.skills[this.key].targetProficiency ||
|
||||
actorData.skills[this.key].proficiency;
|
||||
localizedKey = game.i18n.localize(`DG.Skills.${this.key}`);
|
||||
skillPath = `system.skills.${this.key}`;
|
||||
}
|
||||
if (typedSkillKeys.includes(this.key)) {
|
||||
const skill = actorData.typedSkills[this.key];
|
||||
target = skill.proficiency;
|
||||
localizedKey = `${skill.group} (${skill.label})`;
|
||||
skillPath = `system.typedSkills.${this.key}`;
|
||||
}
|
||||
if (this.key === "ritual") {
|
||||
target = actorData.sanity.ritual;
|
||||
localizedKey = game.i18n.localize(`DG.Skills.ritual`);
|
||||
}
|
||||
return { target, localizedKey, skillPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create label based on result of roll
|
||||
*
|
||||
* todo: do we want make isInhuman more similar to base label?
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
createLabel() {
|
||||
const startOfLabel = `${game.i18n.localize("DG.Roll.Rolling")} <b>${
|
||||
this.localizedKey
|
||||
}`;
|
||||
const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${
|
||||
this.effectiveTarget
|
||||
}`;
|
||||
|
||||
let label = this.isInhuman
|
||||
? // "Inhuman" stat being rolled. See function for details.
|
||||
`${startOfLabel} [${game.i18n
|
||||
.localize("DG.Roll.Inhuman")
|
||||
.toUpperCase()}]</b> ${endOfLabel}`
|
||||
: `${startOfLabel}</b><br> ${endOfLabel}%`;
|
||||
|
||||
const { isExhausted, exhaustedCheckPenalty } = this.exhausted;
|
||||
|
||||
if (this.modifier || isExhausted) {
|
||||
label += ` (${this.target}%`;
|
||||
|
||||
if (this.modifier) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(this.modifier)}%`;
|
||||
}
|
||||
|
||||
if (isExhausted) {
|
||||
label += `${DGUtils.formatStringWithLeadingPlus(
|
||||
exhaustedCheckPenalty,
|
||||
)}%`;
|
||||
}
|
||||
|
||||
label += `)`;
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
|
||||
get exhausted() {
|
||||
let isExhausted = false;
|
||||
let exhaustedCheckPenalty = -20;
|
||||
|
||||
try {
|
||||
// I suspect (but am not entirely certain) that being tired doesn't make you less lucky)
|
||||
if (this.type !== "luck") {
|
||||
isExhausted = this.actor.system.physical.exhausted;
|
||||
exhaustedCheckPenalty = this.actor.system.physical.exhaustedPenalty;
|
||||
exhaustedCheckPenalty = -1 * Math.abs(exhaustedCheckPenalty);
|
||||
}
|
||||
} catch {
|
||||
isExhausted = false;
|
||||
exhaustedCheckPenalty = -20;
|
||||
}
|
||||
|
||||
return { isExhausted, exhaustedCheckPenalty };
|
||||
}
|
||||
|
||||
/**
|
||||
* "Inhuman" stat being rolled, logic is different per page 188 of the Handler's Guide.
|
||||
* Note - originally implemented by Uriele, but my attempt at merging conficts went poorly, so re-implementing.
|
||||
* For an inhuman check, the roll succeeds except on a roll of 100 which fails AND fumbles.
|
||||
* If the roll is a matching digit roll, it is a critical as normal.
|
||||
* Also, if the roll is below the regular (non-x5) value of the stat, it is a critical. E.g. a CON of 25, a d100 roll of 21 would be a critical.
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
get isInhuman() {
|
||||
/*
|
||||
Changing this to only consider the base x5 stat target for whether something is 'inhuman'
|
||||
because I do not think the intent was an Agent with a high strength getting a +40% bonus to be considered 'inhuman'
|
||||
and therefore benefit from the increased crit threshold, although could be wrong about this.
|
||||
*/
|
||||
if (this.target > 99 && this.type === "stat") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a roll result is critical.
|
||||
* If roll has not been evaluated, return null.
|
||||
*
|
||||
* @returns {null|Boolean}
|
||||
*/
|
||||
get isCritical() {
|
||||
// If roll isn't evaluated, return null.
|
||||
if (!this.total) {
|
||||
return null;
|
||||
}
|
||||
let isCritical = false;
|
||||
|
||||
// 1, 100, or any matching dice are a crit, i.e. 11, 22, 33...99.
|
||||
if (this.total === 1 || this.total === 100 || this.total % 11 === 0) {
|
||||
// really good, or reeaaaally bad
|
||||
isCritical = true;
|
||||
}
|
||||
|
||||
// If inhuman and the roll is below the regular (non-x5) value of the stat, it is a critical.
|
||||
// E.g. a CON of 25, a d100 roll of 21 would be a critical.
|
||||
if (this.isInhuman && this.total <= this.target / 5) {
|
||||
isCritical = true;
|
||||
}
|
||||
|
||||
return isCritical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a roll succeeded.
|
||||
* If roll has not been evaluated, return null.
|
||||
*
|
||||
* @returns {null|Boolean}
|
||||
*/
|
||||
get isSuccess() {
|
||||
// If roll isn't evaluated, return null.
|
||||
if (!this.total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A roll of 100 always (critically) fails, even for inhuman rolls.
|
||||
if (this.total === 100) return false;
|
||||
return this.total <= this.effectiveTarget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actual target for the roll accounting for modifier if present.
|
||||
* Floored to 1 if a negative modifier would bring it below 1.
|
||||
* Capped at 99 unless it is an inhuman stat test.
|
||||
* Also worth noting, per page 47 of the Agent's Handbook, Exhaustion penalties
|
||||
* affect not only skill and stat tests, but SAN tests as well...
|
||||
*
|
||||
* @returns {null|integer}
|
||||
*/
|
||||
get effectiveTarget() {
|
||||
let target = 1;
|
||||
|
||||
const { isExhausted, exhaustedCheckPenalty } = this.exhausted;
|
||||
|
||||
if (!this.target || Number.isNaN(this.target)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
target = parseInt(this.target);
|
||||
|
||||
if (isExhausted) {
|
||||
target += exhaustedCheckPenalty;
|
||||
}
|
||||
|
||||
if (this.modifier && !Number.isNaN(this.modifier)) {
|
||||
const modifier = parseInt(this.modifier);
|
||||
|
||||
target += modifier;
|
||||
|
||||
// per agent's handbook (pg.43), a negative modifier can't lower a target below 1%
|
||||
target = Math.max(target, 1);
|
||||
}
|
||||
|
||||
// an 'inhuman' stat test can exceed 99% as a target, but skill tests otherwise cannot (agents handbook pg.43)
|
||||
if (!this.isInhuman) {
|
||||
target = Math.min(target, 99);
|
||||
}
|
||||
|
||||
return target;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return roll;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
/**
|
||||
* Show an optional modifier dialog, evaluate, and send a roll to chat.
|
||||
*
|
||||
* @param {Event|object} event
|
||||
* @param {Roll} roll
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function processDGRoll(event, roll) {
|
||||
const shiftKey = event?.shiftKey ?? false;
|
||||
const which = event?.which ?? 0;
|
||||
|
||||
/**
|
||||
* 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<ChatMessage>} - 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")}`;
|
||||
if (shiftKey || which === 3) {
|
||||
if (!(roll instanceof DGSanityDamageRoll)) {
|
||||
const dialogData = await roll.showDialog();
|
||||
if (!dialogData) return;
|
||||
if (dialogData.newFormula) {
|
||||
roll = new DGDamageRoll(dialogData.newFormula, {}, roll.options);
|
||||
}
|
||||
roll.modifier += dialogData.targetModifier;
|
||||
if (dialogData.messageMode) {
|
||||
roll.options.messageMode = dialogData.messageMode;
|
||||
}
|
||||
}
|
||||
|
||||
const { nonLethalDamage } = this;
|
||||
let label = `${game.i18n.localize("DG.Roll.Rolling")} <b>${game.i18n
|
||||
.localize("DG.Roll.Lethality")
|
||||
.toUpperCase()}</b> ${game.i18n.localize(
|
||||
"DG.Roll.For",
|
||||
)} <b>${this.item.name.toUpperCase()}</b> ${game.i18n.localize(
|
||||
"DG.Roll.Target",
|
||||
)} ${this.target + this.modifier}`;
|
||||
if (this.modifier) {
|
||||
label += ` (${DGUtils.formatStringWithLeadingPlus(this.modifier)}%)`;
|
||||
}
|
||||
|
||||
let html = "";
|
||||
html += `<div class="dice-roll" data-action="expandRoll">`;
|
||||
html += ` <div class="dice-result">`;
|
||||
html += ` <div style="${styleOverride}" class="dice-formula">${resultString}</div>`;
|
||||
html += ` <div class="dice-tooltip">`;
|
||||
html += ` <div class="wrapper">`;
|
||||
html += ` <section class="tooltip-part">`;
|
||||
html += ` <div class="dice">`;
|
||||
html += ` <header class="part-header flexrow">`;
|
||||
html += ` <span class="part-formula">`;
|
||||
html += ` d100`;
|
||||
html += ` </span>`;
|
||||
html += ` <span class="part-total">`;
|
||||
html += ` ${this.total}`;
|
||||
html += ` </span>`;
|
||||
html += ` </header>`;
|
||||
html += ` <ol class="dice-rolls">`;
|
||||
html += ` <li class="roll die d100">${this.total}`;
|
||||
html += ` </ol>`;
|
||||
html += ` <hr>`;
|
||||
html += ` <header class="part-header flexrow">`;
|
||||
html += ` <span class="part-formula">`;
|
||||
html += ` 2d10 (d10 + d10)`;
|
||||
html += ` </span>`;
|
||||
html += ` <span class="part-total">`;
|
||||
html += ` ${nonLethalDamage.total}`;
|
||||
html += ` </span>`;
|
||||
html += ` </header>`;
|
||||
html += ` <ol class="dice-rolls">`;
|
||||
html += ` <li class="roll die d10">${nonLethalDamage.die1}</li>`;
|
||||
html += ` <li class="roll die d10">${nonLethalDamage.die2}</li>`;
|
||||
html += ` </ol>`;
|
||||
html += ` </div>`;
|
||||
html += ` </section>`;
|
||||
html += ` </div>`;
|
||||
html += ` </div>`;
|
||||
html += ` <h4 class="dice-total">${this.total} (${
|
||||
nonLethalDamage.total
|
||||
} ${game.i18n.localize("DG.Roll.Damage")})</h4>`;
|
||||
html += ` </div>`;
|
||||
html += `</div>`;
|
||||
|
||||
return this.toMessage({ content: html, flavor: label });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the damage for when a lethality roll fails.
|
||||
* If roll has not been evaluated, return null.
|
||||
*
|
||||
* See full rules on page 57 of agent's handbook.
|
||||
*
|
||||
* Note, this getter does not actually care if the roll has failed.
|
||||
*
|
||||
* @returns {null|Object} - return data about the non-lethal damage.
|
||||
*/
|
||||
get nonLethalDamage() {
|
||||
if (!this.total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to determine what the d100 result would be as if it was two d10's being rolled.
|
||||
const totalString = this.total.toString();
|
||||
const digits = totalString.length;
|
||||
let die1;
|
||||
let die2;
|
||||
switch (digits) {
|
||||
case 1:
|
||||
// If one digit in the result, one die is a 10, and the other is the result.
|
||||
[die1, die2] = [10, this.total];
|
||||
break;
|
||||
case 2:
|
||||
// If two digits in the result, each die is the value of one of the digits. If one of those digits is 0, make it 10.
|
||||
[die1, die2] = totalString
|
||||
.split("")
|
||||
.map((digit) => parseInt(digit))
|
||||
.map((digit) => digit || 10);
|
||||
break;
|
||||
case 3:
|
||||
// If three digits in the result (aka result === 100), each die is a 10.
|
||||
[die1, die2] = [10, 10];
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const total = die1 + die2;
|
||||
return { die1, die2, total };
|
||||
}
|
||||
}
|
||||
|
||||
export class DGDamageRoll extends DGRoll {
|
||||
/**
|
||||
* Prepares data for a chat message and then passes that data
|
||||
* to a method that actually creates a ChatMessage.
|
||||
*
|
||||
* @returns {Promise<ChatMessage>} - the created chat message.
|
||||
* @override
|
||||
*/
|
||||
async toChat() {
|
||||
let label = this.formula;
|
||||
try {
|
||||
label = `${game.i18n.localize("DG.Roll.Rolling")} <b>${game.i18n
|
||||
.localize("DG.Roll.Damage")
|
||||
.toUpperCase()}</b> ${game.i18n.localize("DG.Roll.For")} ${
|
||||
this.item.name
|
||||
} (<b>${
|
||||
this.item.system.armorPiercing
|
||||
} </b><img class="armor-piercing-chat-card-img" src="systems/deltagreen/assets/icons/supersonic-bullet.svg" alt="armor penetration"/>)`;
|
||||
} catch (ex) {
|
||||
// console.log(ex);
|
||||
label = `Rolling <b>DAMAGE</b> for <b>${label.toUpperCase()}</b>`;
|
||||
}
|
||||
return this.toMessage({ content: this.total, flavor: label });
|
||||
}
|
||||
|
||||
async showDialog() {
|
||||
const template =
|
||||
"systems/deltagreen/templates/dialog/modify-damage-roll.html";
|
||||
const backingData = {
|
||||
data: {
|
||||
label: this.item?.name,
|
||||
originalFormula: this.formula,
|
||||
outerModifier: "2 * ",
|
||||
innerModifier: "+ 0",
|
||||
},
|
||||
};
|
||||
|
||||
const content = await renderTemplate(template, backingData);
|
||||
return new Promise((resolve, reject) => {
|
||||
new DialogV2({
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ModifySkillRollDialogue.Title"),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
label: game.i18n.translations.DG.Roll.Roll,
|
||||
action: "roll",
|
||||
callback: (event, button, dialog) => {
|
||||
try {
|
||||
const outerModifier = dialog.element.querySelector(
|
||||
"[name='outerModifier']",
|
||||
)?.value; // this is text as a heads up
|
||||
let innerModifier = dialog.element.querySelector(
|
||||
"[name='innerModifier']",
|
||||
)?.value; // this is text as a heads up
|
||||
const modifiedBaseRoll = dialog.element.querySelector(
|
||||
"[name='originalFormula']",
|
||||
)?.value; // this is text as a heads up
|
||||
const rollMode = dialog.element.querySelector(
|
||||
"[name='targetRollMode']",
|
||||
)?.value;
|
||||
|
||||
if (innerModifier.replace(" ", "") === "+0") {
|
||||
innerModifier = "";
|
||||
}
|
||||
|
||||
let newFormula = "";
|
||||
if (outerModifier.trim() !== "") {
|
||||
newFormula += `${outerModifier}(${modifiedBaseRoll}${innerModifier.trim()})`;
|
||||
} else {
|
||||
newFormula += modifiedBaseRoll + innerModifier.trim();
|
||||
}
|
||||
|
||||
resolve({ newFormula, rollMode });
|
||||
} catch (ex) {
|
||||
reject(console.log(ex));
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
}).render(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DGSanityDamageRoll extends DGRoll {
|
||||
/**
|
||||
* Prepares data for a chat message and then passes that data
|
||||
* to a method that actually creates a ChatMessage.
|
||||
*
|
||||
* @returns {Promise<ChatMessage>} - the created chat message.
|
||||
* @override
|
||||
*/
|
||||
async toChat() {
|
||||
const [lowDie, highDie] = this.terms[0].terms.map((formula) => {
|
||||
return Roll.parse(formula)[0] || { faces: parseInt(formula), number: 1 };
|
||||
});
|
||||
|
||||
const [lowResult, highResult] = this.damageResults;
|
||||
|
||||
const flavor = `Rolling <b>${DGUtils.localizeWithFallback(
|
||||
"DG.Generic.SanDamage",
|
||||
"SAN DAMAGE",
|
||||
)}</b> For <b>${lowDie.formula} / ${highDie.formula}</b>`;
|
||||
|
||||
let html = "";
|
||||
html += `<div class="dice-roll" data-action="expandRoll">`;
|
||||
html += ` <div class="dice-result">`;
|
||||
html += ` <div class="dice-formula">${lowDie.formula} / ${highDie.formula}</div>`;
|
||||
html += ` <div class="dice-tooltip">`;
|
||||
html += ` <div class="wrapper">`;
|
||||
html += ` <section class="tooltip-part">`;
|
||||
html += ` <div class="dice">`;
|
||||
html += ` <header class="part-header flexrow">`;
|
||||
html += ` <span class="part-formula">`;
|
||||
html += ` ${lowDie.formula}`;
|
||||
html += ` </span>`;
|
||||
html += ` <span class="part-total">`;
|
||||
html += ` ${lowResult}`;
|
||||
html += ` </span>`;
|
||||
html += ` </header>`;
|
||||
html += ` <ol class="dice-rolls">`;
|
||||
html += ` <li class="roll die d${lowDie.faces}">${lowResult}`;
|
||||
html += ` </ol>`;
|
||||
html += ` <hr>`;
|
||||
html += ` <header class="part-header flexrow">`;
|
||||
html += ` <span class="part-formula">`;
|
||||
html += ` ${highDie.formula}`;
|
||||
html += ` </span>`;
|
||||
html += ` <span class="part-total">`;
|
||||
html += ` ${highResult}`;
|
||||
html += ` </span>`;
|
||||
html += ` </header>`;
|
||||
html += ` <ol class="dice-rolls">`;
|
||||
html += ` <li class="roll die d${highDie.faces}">${highResult}</li>`;
|
||||
html += ` </ol>`;
|
||||
html += ` </div>`;
|
||||
html += ` </section>`;
|
||||
html += ` </div>`;
|
||||
html += ` </div>`;
|
||||
html += ` <h4 class="dice-total">${lowResult} / ${highResult}</h4>`;
|
||||
html += ` </div>`;
|
||||
html += `</div>`;
|
||||
return this.toMessage({ content: html, flavor });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the two results for a sanity damage roll.
|
||||
*
|
||||
* Returns null if the roll has not been evaluated.
|
||||
*
|
||||
* @returns {null|Array<Number>} - Array of result numbers
|
||||
*/
|
||||
get damageResults() {
|
||||
if (!this.total) return null;
|
||||
|
||||
const [lowResult, highResult] = this.terms[0].results;
|
||||
return [lowResult?.result, highResult?.result];
|
||||
}
|
||||
await roll.evaluate();
|
||||
await roll.toChat();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createDGChatMessage } from "../chat/dg-chat-card.js";
|
||||
import DG from "../config/index.js";
|
||||
import { formatProfessionSkillLabel } from "../profession/index.js";
|
||||
|
||||
/**
|
||||
* @param {"1"|"d3"|"d4"|"d4-1"} baseFormula
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSkillImprovementFormulaAsPercent(baseFormula) {
|
||||
const flat = baseFormula === "1";
|
||||
const formula = flat ? "1" : `1${baseFormula}`;
|
||||
return `${formula}%`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and evaluates skill improvement rolls for failed skills.
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @param {string} baseFormula
|
||||
* @param {object[]} failedSkills
|
||||
* @param {object[]} failedTypedSkills
|
||||
* @returns {Promise<Record<string, number>>}
|
||||
*/
|
||||
export async function evaluateSkillImprovementRolls(
|
||||
actor,
|
||||
baseFormula,
|
||||
failedSkills,
|
||||
failedTypedSkills,
|
||||
) {
|
||||
const totalFailures = failedSkills.length + failedTypedSkills.length;
|
||||
|
||||
if (!Object.keys(DG.skillImprovementFormulas).includes(baseFormula)) {
|
||||
throw new Error(`Unknown roll formula: ${baseFormula}`);
|
||||
}
|
||||
|
||||
const resultObj = {};
|
||||
if (baseFormula === "1") {
|
||||
return resultObj;
|
||||
}
|
||||
|
||||
const rollPromises = [];
|
||||
for (let i = 0; i < totalFailures; i++) {
|
||||
rollPromises.push(new Roll(baseFormula, actor.system).evaluate());
|
||||
}
|
||||
const rolls = await Promise.all(rollPromises);
|
||||
|
||||
[...failedSkills, ...failedTypedSkills].forEach((skill, index) => {
|
||||
resultObj[skill.key] = rolls[index].total;
|
||||
});
|
||||
|
||||
return resultObj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} params
|
||||
* @param {Actor} params.actor
|
||||
* @param {TokenDocument|null} [params.token]
|
||||
* @param {string} params.baseFormula
|
||||
* @param {object[]} params.failedSkills
|
||||
* @param {object[]} params.failedTypedSkills
|
||||
* @param {Record<string, number>} params.resultObj
|
||||
* @returns {Promise<ChatMessage>}
|
||||
*/
|
||||
export async function createSkillImprovementChatMessage({
|
||||
actor,
|
||||
token = null,
|
||||
baseFormula,
|
||||
failedSkills,
|
||||
failedTypedSkills,
|
||||
resultObj,
|
||||
}) {
|
||||
const localizeFailedSkills = (skillsArray, kind) => {
|
||||
return skillsArray.map((skill) => {
|
||||
const increment = resultObj[skill.key] ?? 1;
|
||||
const label =
|
||||
kind === "typed"
|
||||
? formatProfessionSkillLabel({
|
||||
kind: "typed",
|
||||
group: skill.group,
|
||||
label: skill.label,
|
||||
})
|
||||
: formatProfessionSkillLabel({ kind: "fixed", key: skill.key });
|
||||
|
||||
return `${label}: <b>+${increment}%</b>`;
|
||||
});
|
||||
};
|
||||
|
||||
const failedSkillNames = localizeFailedSkills(failedSkills, "fixed");
|
||||
const failedTypedSkillNames = localizeFailedSkills(
|
||||
failedTypedSkills,
|
||||
"typed",
|
||||
);
|
||||
|
||||
const content = [...failedSkillNames, ...failedTypedSkillNames].join(", ");
|
||||
const label = game.i18n.format(
|
||||
"DG.Skills.ApplySkillImprovements.ChatFlavor",
|
||||
{
|
||||
formula: getSkillImprovementFormulaAsPercent(baseFormula),
|
||||
},
|
||||
);
|
||||
|
||||
return createDGChatMessage({
|
||||
actor,
|
||||
token,
|
||||
label,
|
||||
content,
|
||||
messageMode: game.settings.get("core", "messageMode"),
|
||||
});
|
||||
}
|
||||
+11
-46
@@ -1,6 +1,7 @@
|
||||
/* 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";
|
||||
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
|
||||
@@ -87,7 +88,7 @@ const SettingForm = class extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
}
|
||||
|
||||
// Once all promises resolve, show a notification.
|
||||
Promise.allSettled(settingsPromises).then((values) => {
|
||||
Promise.allSettled(settingsPromises).then(() => {
|
||||
ui.notifications.info(game.i18n.localize("DG.Settings.Saved"));
|
||||
});
|
||||
}
|
||||
@@ -297,7 +298,7 @@ export default function registerSystemSettings() {
|
||||
program: game.i18n.localize("DG.Settings.charactersheet.program"),
|
||||
},
|
||||
default: "program", // The default value for the setting
|
||||
onChange: (value) => {
|
||||
onChange: () => {
|
||||
// A callback function which triggers when the setting is changed
|
||||
// console.log(value)
|
||||
},
|
||||
@@ -329,54 +330,18 @@ export default function registerSystemSettings() {
|
||||
default: "hover",
|
||||
onChange: () => {
|
||||
foundry.applications.instances.forEach((app) => {
|
||||
if (app instanceof DGActorSheet) {
|
||||
if (app instanceof DGActorSheet || app instanceof DGItemSheet) {
|
||||
app.render();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// obsolete - will be removed at some point
|
||||
game.settings.register("deltagreen", "characterSheetFont", {
|
||||
name: "World Font Choice",
|
||||
hint: "Choose font style for use throughout this world.",
|
||||
scope: "world", // This specifies a world-stored setting
|
||||
config: false, // This specifies that the setting appears in the configuration view
|
||||
type: String,
|
||||
choices: {
|
||||
// If choices are defined, the resulting setting will be a select menu
|
||||
SpecialElite: "Special Elite (Classic Typewriters Font)",
|
||||
Martel: "Martel (Clean Modern Font)",
|
||||
Signika: "Signika (Foundry Default Font)",
|
||||
TypeWriterCondensed:
|
||||
"Condensed Typewriter (Modern, Small Typewriter Font)",
|
||||
PublicSans: "Public Sans (US Government-style sans serif font)",
|
||||
// "atwriter": "Another Typewriter (Alternate Old-style Typewriter Font)"
|
||||
},
|
||||
default: "SpecialElite", // The default value for the setting
|
||||
onChange: (value) => {
|
||||
// A callback function which triggers when the setting is changed
|
||||
// console.log(value)
|
||||
},
|
||||
});
|
||||
|
||||
// obsolete - will be removed at some point
|
||||
game.settings.register("deltagreen", "characterSheetBackgroundImageSetting", {
|
||||
name: "World Sheet Background Image",
|
||||
hint: "Choose background image for use throughout this world. (Refresh page to see change.)",
|
||||
scope: "world", // This specifies a world-stored setting
|
||||
config: false, // This specifies that the setting appears in the configuration view
|
||||
type: String,
|
||||
choices: {
|
||||
// If choices are defined, the resulting setting will be a select menu
|
||||
OldPaper1: "Old Dirty Paper (Good with Special Elite Font)",
|
||||
IvoryPaper: "Ivory White Paper (Good with Martel Font)",
|
||||
DefaultParchment: "Default Parchment (Good with Signika Font)",
|
||||
},
|
||||
default: "OldPaper1", // The default value for the setting
|
||||
onChange: (value) => {
|
||||
// A callback function which triggers when the setting is changed
|
||||
// console.log(value)
|
||||
},
|
||||
game.settings.register(DG.ID, "schemaMigrationVersion", {
|
||||
name: "Schema Migration Version",
|
||||
scope: "world",
|
||||
config: false,
|
||||
type: Number,
|
||||
default: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import DGAgentSheet from "./agent-sheet.js";
|
||||
import ActorEditStatForm from "./edit-stats.js";
|
||||
|
||||
/** @extends {DGAgentSheet} */
|
||||
export default class DGAgentSheetV2 extends DGAgentSheet {
|
||||
static DEFAULT_OPTIONS = /** @type {const} */ ({
|
||||
classes: ["actor-sheet-v2"],
|
||||
position: { width: 950, height: 665 },
|
||||
actions: {
|
||||
openStatsEdit: this._openStatsEdit,
|
||||
},
|
||||
});
|
||||
|
||||
/** @override */
|
||||
static PARTS = /** @type {const} */ ({
|
||||
leftBar: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/left-bar.html`,
|
||||
templates: [`${this.TEMPLATE_PATH}/partials/sanity-partial.html`],
|
||||
},
|
||||
rightBar: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/right-bar.html`,
|
||||
templates: [
|
||||
`${this.TEMPLATE_PATH}/parts/skills-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/physical-tab-v2.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/motivations-tab-v2.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/gear-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/bio-tab-v2.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/bonds-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/about-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/custom-skills-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/notes-partial.html`,
|
||||
],
|
||||
},
|
||||
tabs: this.BASE_PARTS.tabs,
|
||||
});
|
||||
|
||||
static _openStatsEdit() {
|
||||
const form = new ActorEditStatForm({
|
||||
actor: this.actor,
|
||||
});
|
||||
form.render(true);
|
||||
}
|
||||
}
|
||||
+554
-146
@@ -1,21 +1,67 @@
|
||||
import DG, { BASE_TEMPLATE_PATH } from "../config.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,
|
||||
getSkillImprovementFormulaAsPercent,
|
||||
} from "../roll/skill-improvement-roll.js";
|
||||
import DGActorSheet from "./base-actor-sheet.js";
|
||||
import ActorEditStatForm from "../applications/edit-stats.js";
|
||||
import EffectsTabMixin from "./mixins/effects-tab-mixin.js";
|
||||
import {
|
||||
applyStimulantEffect,
|
||||
clearStimulantEffects,
|
||||
getEffectiveSuppressExhaustion,
|
||||
hasActiveStimulantEffect,
|
||||
} from "../active-effect/runtime/stimulant-effect.js";
|
||||
import assignProfessionToAgent from "../applications/profession-setup-flow.js";
|
||||
import { showDgDialog } from "../applications/dg-dialog.js";
|
||||
import showRenameProfessionDialog from "../applications/rename-profession-dialog.js";
|
||||
import { PROFESSION_OPTION_PICKS_KEY } from "../data/item/profession.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
|
||||
/** @extends {DGActorSheet} */
|
||||
export default class DGAgentSheet extends DGActorSheet {
|
||||
const AgentSheetBase = EffectsTabMixin(DGActorSheet);
|
||||
|
||||
/** @extends {AgentSheetBase} */
|
||||
export default class DGAgentSheet extends AgentSheetBase {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = /** @type {const} */ ({
|
||||
classes: ["agent-sheet"],
|
||||
position: { width: 978, height: 720 },
|
||||
actions: {
|
||||
// Resets.
|
||||
clearBondDamage: DGAgentSheet._clearBondDamage,
|
||||
resetBreakingPoint: DGAgentSheet._resetBreakingPoint,
|
||||
// Other actions.
|
||||
applySkillImprovements: DGAgentSheet._processSkillImprovements,
|
||||
openStatsEdit: DGAgentSheet._openStatsEdit,
|
||||
toggleCustomSkillsEdit: function toggleCustomSkillsEdit(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._customSkillsEditMode = !this._customSkillsEditMode;
|
||||
this._syncCustomSkillsEditModeUi();
|
||||
return this.render();
|
||||
},
|
||||
createEffect: AgentSheetBase.createEffect,
|
||||
openEffect: AgentSheetBase.openEffect,
|
||||
deleteEffect: AgentSheetBase.deleteEffect,
|
||||
toggleEffect: AgentSheetBase.toggleEffect,
|
||||
openEffectOrigin: AgentSheetBase.openEffectOrigin,
|
||||
toggleAcuteEpisode: DGAgentSheet._toggleAcuteEpisode,
|
||||
exhaustAgent: DGAgentSheet._exhaustAgent,
|
||||
restAgent: DGAgentSheet._restAgent,
|
||||
takeStimulants: DGAgentSheet._takeStimulants,
|
||||
openProfessionItem: DGAgentSheet._openProfessionItem,
|
||||
},
|
||||
});
|
||||
|
||||
/** @type {boolean} */
|
||||
_customSkillsEditMode = false;
|
||||
|
||||
/** @override */
|
||||
static TABS = /** @type {const} */ ({
|
||||
primary: {
|
||||
@@ -23,45 +69,246 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
labelPrefix: "DG.Navigation.Agent",
|
||||
tabs: [
|
||||
{ id: "skills" },
|
||||
{ id: "physical" },
|
||||
{ id: "combat" },
|
||||
{ id: "motivations" },
|
||||
{ id: "gear" },
|
||||
{ id: "bio" },
|
||||
{ id: "bonds" },
|
||||
{ id: "personal" },
|
||||
{ id: "effects" },
|
||||
{ id: "about", icon: "fas fa-question-circle", label: "" },
|
||||
],
|
||||
},
|
||||
leftBarRestSanity: {
|
||||
initial: "rest",
|
||||
labelPrefix: "DG.Navigation.Agent.LeftBar",
|
||||
tabs: [{ id: "rest" }, { id: "sanity" }],
|
||||
},
|
||||
});
|
||||
|
||||
/** @override */
|
||||
static PARTS = /** @type {const} */ ({
|
||||
header: this.BASE_PARTS.header,
|
||||
tabs: this.BASE_PARTS.tabs,
|
||||
skills: this.BASE_PARTS.skills,
|
||||
physical: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/physical-tab.html`,
|
||||
leftBar: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/left-bar.html`,
|
||||
templates: [
|
||||
`${this.TEMPLATE_PATH}/partials/attributes-grid-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/sanity-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/left-bar-exhaustion-section.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/left-bar-rest-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/left-bar-sanity-tab.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/profession-subname.html`,
|
||||
],
|
||||
scrollable: [""],
|
||||
},
|
||||
motivations: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/motivations-tab.html`,
|
||||
tabs: this.BASE_PARTS.tabs,
|
||||
rightBar: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/right-bar.html`,
|
||||
templates: [
|
||||
`${this.TEMPLATE_PATH}/parts/skills-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/combat-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/motivations-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/gear-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/personal-tab-agent.html`,
|
||||
`${this.TEMPLATE_PATH}/parts/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`,
|
||||
`${this.TEMPLATE_PATH}/partials/armor-section-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/injuries-section-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/other-gear-section-partial.html`,
|
||||
`${this.TEMPLATE_PATH}/partials/notes-partial.html`,
|
||||
],
|
||||
},
|
||||
gear: this.BASE_PARTS.gear,
|
||||
bio: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/bio-tab.html`,
|
||||
templates: [`${this.TEMPLATE_PATH}/partials/notes-partial.html`],
|
||||
scrollable: [""],
|
||||
},
|
||||
bonds: {
|
||||
template: `${this.TEMPLATE_PATH}/parts/bonds-tab.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
about: this.BASE_PARTS.about,
|
||||
});
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/** @override */
|
||||
_configureRenderOptions(options) {
|
||||
super._configureRenderOptions(options);
|
||||
|
||||
if (!this.actor.limited) return;
|
||||
|
||||
options.parts = ["leftBar", "tabs", "rightBar"];
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_prepareTabs(group) {
|
||||
const tabs = super._prepareTabs(group);
|
||||
|
||||
if (
|
||||
!this.actor.limited ||
|
||||
this.actor.type !== "agent" ||
|
||||
group !== "primary"
|
||||
) {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
return {
|
||||
personal: { ...tabs.personal, active: true, cssClass: "active" },
|
||||
};
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
|
||||
context.tabs = this._prepareTabs("primary");
|
||||
context.leftBarRestSanityTabs = this._prepareTabs("leftBarRestSanity");
|
||||
|
||||
if (this.actor.type !== "agent") return context;
|
||||
|
||||
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(
|
||||
(i) => i.type === "profession",
|
||||
);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @returns {object} */
|
||||
_preparePhysicalUi() {
|
||||
const { physical } = this.actor.system;
|
||||
const isExhausted = DGAgentSheet._isActorExhausted(this.actor);
|
||||
const stimulantActive = hasActiveStimulantEffect(this.actor);
|
||||
const suppressExhaustion = getEffectiveSuppressExhaustion(this.actor);
|
||||
return {
|
||||
isExhausted,
|
||||
displayPenalty: isExhausted ? physical.exhaustedPenalty : 0,
|
||||
suppressExhaustionInactive: !isExhausted,
|
||||
suppressExhaustionInputDisabled: !isExhausted || stimulantActive,
|
||||
suppressExhaustionChecked: isExhausted && suppressExhaustion,
|
||||
};
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_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 */
|
||||
async _onRender(context, options) {
|
||||
await super._onRender(context, options);
|
||||
this._syncCustomSkillsEditModeUi();
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
_syncCustomSkillsEditModeUi() {
|
||||
this.element?.classList.toggle(
|
||||
"custom-skills-edit-mode",
|
||||
Boolean(this._customSkillsEditMode),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async _toggleAcuteEpisode(event, target) {
|
||||
event.preventDefault();
|
||||
const itemId =
|
||||
target.dataset.itemId ??
|
||||
target.closest("[data-item-id]")?.dataset?.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
if (!item || item.type !== "motivation") return;
|
||||
await item.update({
|
||||
"system.acuteEpisode": !item.system.acuteEpisode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async _openProfessionItem(_event, target) {
|
||||
const itemId =
|
||||
target.dataset.itemId ??
|
||||
target.closest("[data-item-id]")?.dataset?.itemId;
|
||||
const item = this.actor.items.get(itemId);
|
||||
if (item?.type !== "profession") return;
|
||||
await showRenameProfessionDialog(item);
|
||||
}
|
||||
|
||||
static _clearBondDamage() {
|
||||
for (const bond of this.actor.itemTypes.bond) {
|
||||
if (bond.system.hasBeenDamagedSinceLastHomeScene) {
|
||||
bond.update({ "system.hasBeenDamagedSinceLastHomeScene": false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the actor's current breaking point by recalculating it as the difference
|
||||
@@ -71,8 +318,11 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
* @returns {void}
|
||||
*/
|
||||
static _resetBreakingPoint() {
|
||||
const pow =
|
||||
this.actor.system.statistics.pow.effectiveValue ??
|
||||
this.actor.system.statistics.pow.value;
|
||||
const currentBreakingPoint = Math.max(
|
||||
this.actor.system.sanity.value - this.actor.system.statistics.pow.value,
|
||||
this.actor.system.sanity.value - pow,
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -93,6 +343,226 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
this.actor.update(dataToUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorted special-training blocks for the skills tab.
|
||||
*
|
||||
* @returns {object[]}
|
||||
*/
|
||||
_prepareSpecialTrainingBlocks() {
|
||||
const trainings = this._prepareSpecialTrainings().map((training) => ({
|
||||
...training,
|
||||
sortLabel: training.name.toUpperCase(),
|
||||
actorType: this.actor.type,
|
||||
}));
|
||||
|
||||
trainings.sort((a, b) =>
|
||||
a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang),
|
||||
);
|
||||
|
||||
return trainings;
|
||||
}
|
||||
|
||||
static _openStatsEdit() {
|
||||
const form = new ActorEditStatForm({
|
||||
actor: this.actor,
|
||||
});
|
||||
form.render(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} raw
|
||||
* @returns {number}
|
||||
*/
|
||||
static _normalizeExhaustionPenalty(raw) {
|
||||
const value = Number(raw);
|
||||
if (Number.isNaN(value) || value === 0) return -20;
|
||||
return value > 0 ? -1 * Math.abs(value) : value;
|
||||
}
|
||||
|
||||
static async _exhaustAgent() {
|
||||
const { actor } = this;
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/exhaust-agent.html`,
|
||||
{
|
||||
penalty: actor.system.physical.exhaustedPenalty ?? -20,
|
||||
},
|
||||
);
|
||||
|
||||
let penalty = null;
|
||||
const confirmed = await showDgDialog({
|
||||
modifier: "exhaust-agent",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Physical.ExhaustDialogTitle"),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
action: "rollWpLoss",
|
||||
label: game.i18n.localize("DG.Physical.RollWillpowerLoss"),
|
||||
default: true,
|
||||
icon: "fas fa-dice",
|
||||
callback: (_event, _button, dialog) => {
|
||||
const input = dialog.element.querySelector(
|
||||
'[name="exhaustionPenalty"]',
|
||||
);
|
||||
penalty = DGAgentSheet._normalizeExhaustionPenalty(input?.value);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!confirmed || penalty === null) return;
|
||||
|
||||
const wpRoll = await new Roll("1d6").evaluate();
|
||||
const loss = wpRoll.total;
|
||||
const currentWp = Number(actor.system.wp.value) || 0;
|
||||
const maxWp = Number(actor.system.wp.max) || 0;
|
||||
const newWp = Math.max(0, currentWp - loss);
|
||||
|
||||
await actor.update({
|
||||
"system.physical.exhausted": true,
|
||||
"system.physical.exhaustedPenalty": penalty,
|
||||
"system.wp.value": newWp,
|
||||
});
|
||||
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
roll: wpRoll,
|
||||
contentKey: "DG.Physical.Chat.Exhausted",
|
||||
labelKey: "DG.Physical.Chat.ExhaustedLabel",
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
loss,
|
||||
current: newWp,
|
||||
max: maxWp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async _restAgent() {
|
||||
const { actor } = this;
|
||||
|
||||
const wasExhausted = DGAgentSheet._isActorExhausted(actor);
|
||||
const wpRoll = await new Roll("1d6").evaluate();
|
||||
const gain = wpRoll.total;
|
||||
const currentWp = Number(actor.system.wp.value) || 0;
|
||||
const maxWp = Number(actor.system.wp.max) || 0;
|
||||
const newWp = Math.min(maxWp, currentWp + gain);
|
||||
|
||||
await clearStimulantEffects(actor);
|
||||
|
||||
await actor.update({
|
||||
"system.physical.exhausted": false,
|
||||
"system.physical.suppressExhaustion": false,
|
||||
"system.wp.value": newWp,
|
||||
});
|
||||
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
roll: wpRoll,
|
||||
contentKey: "DG.Physical.Chat.Rested",
|
||||
labelKey: "DG.Physical.Chat.RestedLabel",
|
||||
extraContentKey: wasExhausted
|
||||
? "DG.Physical.Chat.RestedNoLongerExhausted"
|
||||
: null,
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
gain,
|
||||
current: newWp,
|
||||
max: maxWp,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static _isActorExhausted(actor) {
|
||||
return Boolean(actor._source?.system?.physical?.exhausted);
|
||||
}
|
||||
|
||||
static async _takeStimulants() {
|
||||
const { actor } = this;
|
||||
|
||||
if (!DGAgentSheet._isActorExhausted(actor)) {
|
||||
ui.notifications.info(
|
||||
game.i18n.format("DG.Physical.StimulantsNotExhaustedWarning", {
|
||||
name: actor.name,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/take-stimulants.html`,
|
||||
{},
|
||||
);
|
||||
|
||||
const choice = await showDgDialog({
|
||||
modifier: "stimulants",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Physical.StimulantsDialogTitle"),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
action: "regular",
|
||||
label: game.i18n.localize("DG.Physical.StimulantsRegular"),
|
||||
default: true,
|
||||
callback: () => "regular",
|
||||
},
|
||||
{
|
||||
action: "hard",
|
||||
label: game.i18n.localize("DG.Physical.StimulantsHard"),
|
||||
callback: () => "hard",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!choice) return;
|
||||
|
||||
const formula = choice === "hard" ? "2d6" : "1d6";
|
||||
const hoursRoll = await new Roll(formula).evaluate();
|
||||
const hours = hoursRoll.total;
|
||||
const contentKey =
|
||||
choice === "hard"
|
||||
? "DG.Physical.Chat.StimulantsHard"
|
||||
: "DG.Physical.Chat.StimulantsRegular";
|
||||
|
||||
const appliedHours = await applyStimulantEffect(actor, hours);
|
||||
|
||||
const labelKey =
|
||||
choice === "hard"
|
||||
? "DG.Physical.Chat.StimulantsHardLabel"
|
||||
: "DG.Physical.Chat.StimulantsRegularLabel";
|
||||
|
||||
await createAgentResourceChatMessage({
|
||||
actor,
|
||||
token: this.token,
|
||||
roll: hoursRoll,
|
||||
contentKey,
|
||||
labelKey,
|
||||
i18nData: {
|
||||
name: actor.name,
|
||||
hours: appliedHours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, object>} skillMap
|
||||
* @returns {object[]}
|
||||
*/
|
||||
static _collectFailedSkills(skillMap) {
|
||||
return Object.entries(skillMap)
|
||||
.filter(([, skill]) => skill.failure && !skill.cannotBeImprovedByFailure)
|
||||
.map(([key, skill]) => ({ ...skill, key }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs through the whole process of improving skills,
|
||||
* i.e., prompting the user, rolling, and creating the chat card.
|
||||
@@ -102,12 +572,8 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
static async _processSkillImprovements() {
|
||||
const { skills, typedSkills } = this.actor.system;
|
||||
|
||||
const failedSkills = Object.values(skills).filter(
|
||||
(skill) => skill.failure && !skill.cannotBeImprovedByFailure,
|
||||
);
|
||||
const failedTypedSkills = Object.values(typedSkills).filter(
|
||||
(skill) => skill.failure && !skill.cannotBeImprovedByFailure,
|
||||
);
|
||||
const failedSkills = DGAgentSheet._collectFailedSkills(skills);
|
||||
const failedTypedSkills = DGAgentSheet._collectFailedSkills(typedSkills);
|
||||
|
||||
if (failedSkills.length + failedTypedSkills.length === 0) {
|
||||
ui.notifications.warn("DG.Skills.ApplySkillImprovements.Warning", {
|
||||
@@ -126,18 +592,21 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
|
||||
if (!prompt) return null;
|
||||
|
||||
const resultObj = await this._createSkillImprovementRolls(
|
||||
const resultObj = await evaluateSkillImprovementRolls(
|
||||
this.actor,
|
||||
baseRollFormula,
|
||||
failedSkills,
|
||||
failedTypedSkills,
|
||||
);
|
||||
|
||||
await this._createSkillImprovementChatCard(
|
||||
baseRollFormula,
|
||||
await createSkillImprovementChatMessage({
|
||||
actor: this.actor,
|
||||
token: this.token,
|
||||
baseFormula: baseRollFormula,
|
||||
failedSkills,
|
||||
failedTypedSkills,
|
||||
resultObj,
|
||||
);
|
||||
});
|
||||
|
||||
return this._applySkillImprovements(
|
||||
failedSkills,
|
||||
@@ -180,14 +649,16 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
failedTypedSkills,
|
||||
) {
|
||||
const localizedFailedSkills = failedSkills.map((skill) =>
|
||||
game.i18n.localize(`DG.Skills.${skill.key}`),
|
||||
formatProfessionSkillLabel({ kind: "fixed", key: skill.key }),
|
||||
);
|
||||
|
||||
const localizedFailedTypedSkills = failedTypedSkills.map((skill) => {
|
||||
const groupKey = `DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`;
|
||||
const groupLabel = game.i18n.localize(groupKey);
|
||||
return `${groupLabel} (${skill.label})`;
|
||||
});
|
||||
const localizedFailedTypedSkills = failedTypedSkills.map((skill) =>
|
||||
formatProfessionSkillLabel({
|
||||
kind: "typed",
|
||||
group: skill.group,
|
||||
label: skill.label,
|
||||
}),
|
||||
);
|
||||
|
||||
const failedSkillNames = [
|
||||
...localizedFailedSkills,
|
||||
@@ -198,12 +669,12 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
`${BASE_TEMPLATE_PATH}/dialog/apply-skill-improvements.html`,
|
||||
{
|
||||
failedSkillNames,
|
||||
baseFormula:
|
||||
DGAgentSheet._getSkillImprovementFormulaAsPercent(baseFormula),
|
||||
baseFormula: getSkillImprovementFormulaAsPercent(baseFormula),
|
||||
},
|
||||
);
|
||||
|
||||
return foundry.applications.api.DialogV2.wait({
|
||||
return showDgDialog({
|
||||
modifier: "apply-skill-improvements",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Skills.ApplySkillImprovements.Title"),
|
||||
@@ -220,109 +691,59 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and evaluates the rolls for skill improvements based on failed skills.
|
||||
*
|
||||
* @param {SkillImprovementFormula} baseFormula - The formula used to calculate skill improvements.
|
||||
* @param {FailedSkill[]} failedSkills - An array of failed skills.
|
||||
* @param {FailedTypedSkill[]} failedTypedSkills - An array of failed typed skills.
|
||||
*
|
||||
* @returns {Promise<{roll: Roll|undefined, resultObj: ResultObj}>} - An object containing the roll result and a map of skill keys to improvement values.
|
||||
*
|
||||
* @throws {Error} - Throws an error if the baseFormula is unknown.
|
||||
* @param {DragEvent} event
|
||||
* @param {Item} item
|
||||
* @returns {Promise<Item|null>}
|
||||
*/
|
||||
async _createSkillImprovementRolls(
|
||||
baseFormula,
|
||||
failedSkills,
|
||||
failedTypedSkills,
|
||||
) {
|
||||
const totalFailures = failedSkills.length + failedTypedSkills.length;
|
||||
|
||||
if (!Object.keys(DG.skillImprovementFormulas).includes(baseFormula)) {
|
||||
throw new Error(`Unknown roll formula: ${baseFormula}`);
|
||||
async _onDropItem(event, item) {
|
||||
const dropType = item.type ?? item.system?.type;
|
||||
if (dropType !== "profession") {
|
||||
return super._onDropItem(event, item);
|
||||
}
|
||||
|
||||
const rollPromises = [];
|
||||
const resultObj = {};
|
||||
if (baseFormula !== "1") {
|
||||
for (let i = 0; i < totalFailures; i++) {
|
||||
rollPromises.push(new Roll(baseFormula, this.actor.system).evaluate());
|
||||
}
|
||||
const rolls = await Promise.all(rollPromises);
|
||||
const itemData =
|
||||
typeof item.toObject === "function"
|
||||
? item.toObject()
|
||||
: foundry.utils.duplicate(item);
|
||||
|
||||
[...failedSkills, ...failedTypedSkills].forEach((skill, index) => {
|
||||
resultObj[skill.key] = rolls[index].total;
|
||||
});
|
||||
}
|
||||
|
||||
return resultObj;
|
||||
return assignProfessionToAgent(this.actor, itemData, {
|
||||
token: this.token,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a chat card to record skill improvements.
|
||||
*
|
||||
* @param {FailedSkill[]} failedSkills - array of failed skills
|
||||
* @param {FailedTypedSkill[]} failedTypedSkills - array of failed typed skills
|
||||
* @param {Roll|undefined} roll - the improvement roll
|
||||
* @param {ResultObj} resultObj
|
||||
*
|
||||
* @returns {Promise<ChatMessage>}
|
||||
* @param {string} type
|
||||
* @returns {Promise<Item|Item[]>}
|
||||
*/
|
||||
_createSkillImprovementChatCard(
|
||||
baseFormula,
|
||||
failedSkills,
|
||||
failedTypedSkills,
|
||||
resultObj,
|
||||
) {
|
||||
const localizeFailedSkills = (skillsArray) => {
|
||||
return skillsArray.map((skill) => {
|
||||
const increment = resultObj[skill.key] ?? 1;
|
||||
const label =
|
||||
skill.label ?? game.i18n.localize(`DG.Skills.${skill.key}`); // fallback for regular skills
|
||||
const groupLabel = skill.group
|
||||
? `${game.i18n.localize(
|
||||
`DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`,
|
||||
)} (${label})`
|
||||
: label;
|
||||
_onItemCreate(type) {
|
||||
if (type !== "profession") {
|
||||
return super._onItemCreate(type);
|
||||
}
|
||||
|
||||
return `${groupLabel}: <b>+${increment}%</b>`;
|
||||
});
|
||||
};
|
||||
|
||||
const failedSkillNames = localizeFailedSkills(failedSkills);
|
||||
const failedTypedSkillNames = localizeFailedSkills(failedTypedSkills);
|
||||
|
||||
// Prepare chat data
|
||||
const content = [...failedSkillNames, ...failedTypedSkillNames].join(", ");
|
||||
const flavor = game.i18n.format(
|
||||
"DG.Skills.ApplySkillImprovements.ChatFlavor",
|
||||
const name = game.i18n.format(
|
||||
game.i18n.translations.DOCUMENT?.New || "DG.FallbackText.newItem",
|
||||
{
|
||||
formula: DGAgentSheet._getSkillImprovementFormulaAsPercent(baseFormula),
|
||||
type: game.i18n.localize("TYPES.Item.profession"),
|
||||
},
|
||||
);
|
||||
|
||||
const chatData = {
|
||||
speaker: ChatMessage.getSpeaker({
|
||||
actor: this.actor,
|
||||
token: this.token,
|
||||
alias: this.actor.name,
|
||||
}),
|
||||
content,
|
||||
flavor,
|
||||
rollMode: game.settings.get("core", "rollMode"),
|
||||
const itemData = {
|
||||
name,
|
||||
type: "profession",
|
||||
system: {
|
||||
bonds: 0,
|
||||
automaticSkills: {},
|
||||
automaticSkillMeta: {},
|
||||
optionSkills: { [PROFESSION_OPTION_PICKS_KEY]: 0 },
|
||||
optionSkillMeta: {},
|
||||
},
|
||||
};
|
||||
|
||||
return ChatMessage.create(chatData);
|
||||
return assignProfessionToAgent(this.actor, itemData, {
|
||||
token: this.token,
|
||||
}).then((created) => (created ? [created] : []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the actor's skills / typed skills with the improvements,
|
||||
* persisting the changes to the database.
|
||||
*
|
||||
* @param {FailedSkill[]} failedSkills - array of failed skills that need to be updated
|
||||
* @param {FailedTypedSkill[]} failedTypedSkills - array of failed skills that need to be updated
|
||||
* @param {ResultObj} resultObj
|
||||
* @returns {Promise<DeltaGreenActor>} - the update promise
|
||||
*/
|
||||
_applySkillImprovements(failedSkills, failedTypedSkills, resultObj) {
|
||||
const updateSkills = (skillsArray, updatedTarget) => {
|
||||
skillsArray.forEach((skill) => {
|
||||
@@ -344,17 +765,4 @@ export default class DGAgentSheet extends DGActorSheet {
|
||||
system: { skills: updatedSkills, typedSkills: updatedTypedSkills },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a base formula and returns a string representing the skill improvement formula as a percentage.
|
||||
* TODO: Move to the roll class itself, probably even a "SkillImprovementRoll".
|
||||
*
|
||||
* @param {SkillImprovementFormula} baseFormula - the base formula to convert
|
||||
* @returns {string} the skill improvement formula as a percentage (e.g. "1D3%" or "1%")
|
||||
*/
|
||||
static _getSkillImprovementFormulaAsPercent(baseFormula) {
|
||||
const flat = baseFormula === "1";
|
||||
const formula = flat ? "1" : `1${baseFormula}`;
|
||||
return `${formula}%`.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
+28
-1341
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,300 @@
|
||||
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";
|
||||
import { getDGRollToken } from "../chat/dg-chat-card.js";
|
||||
import { createDGRollFromDataset, processDGRoll } from "../roll/roll.js";
|
||||
|
||||
const { ItemSheetV2 } = foundry.applications.sheets;
|
||||
const ITEM_PARTS_PATH = `${BASE_TEMPLATE_PATH}/item/parts`;
|
||||
const TAB_NAV_TEMPLATE = "templates/generic/tab-navigation.hbs";
|
||||
|
||||
/** Tab group id used by item sheet templates (`data-group="primary"`). */
|
||||
const ITEM_TAB_GROUP = "primary";
|
||||
|
||||
const EFFECTS_TAB = /** @type {const} */ ({
|
||||
id: "effects",
|
||||
label: "DG.Navigation.Item.effects",
|
||||
});
|
||||
|
||||
const EFFECTS_PART = /** @type {const} */ ({
|
||||
effects: {
|
||||
template: `${ITEM_PARTS_PATH}/effects-tab.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
});
|
||||
|
||||
const TABS_PART = /** @type {const} */ ({
|
||||
tabs: { template: TAB_NAV_TEMPLATE },
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {object} config
|
||||
* @param {string} config.header
|
||||
* @param {string} config.description
|
||||
* @param {string} config.descriptionLabel
|
||||
* @returns {object}
|
||||
*/
|
||||
function activeEffectItemLayout({ header, description, descriptionLabel }) {
|
||||
return {
|
||||
tabs: {
|
||||
initial: "description",
|
||||
tabs: [{ id: "description", label: descriptionLabel }, EFFECTS_TAB],
|
||||
},
|
||||
parts: {
|
||||
header: { template: header },
|
||||
...TABS_PART,
|
||||
description: { template: description, scrollable: [""] },
|
||||
...EFFECTS_PART,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Tab and part layout per item type. */
|
||||
const ITEM_SHEET_LAYOUT = /** @type {const} */ ({
|
||||
weapon: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/weapon-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/weapon-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Weapons.Description",
|
||||
}),
|
||||
gear: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/gear-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/gear-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Gear.Description",
|
||||
}),
|
||||
armor: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/armor-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/armor-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Armor.Description",
|
||||
}),
|
||||
bond: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/bond-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/bond-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Bonds.Description",
|
||||
}),
|
||||
motivation: activeEffectItemLayout({
|
||||
header: `${ITEM_PARTS_PATH}/motivation-header.html`,
|
||||
description: `${ITEM_PARTS_PATH}/motivation-description.html`,
|
||||
descriptionLabel: "DG.ItemWindow.Motivations.Description",
|
||||
}),
|
||||
tome: {
|
||||
tabs: {
|
||||
initial: "description",
|
||||
tabs: [
|
||||
{ id: "description", label: "DG.ItemWindow.Tome.Description" },
|
||||
{
|
||||
id: "handler",
|
||||
label: "DG.ItemWindow.Tome.HandlerOnlyTabHeader",
|
||||
gmOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/tome-header.html` },
|
||||
...TABS_PART,
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/tome-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
handler: {
|
||||
template: `${ITEM_PARTS_PATH}/tome-handler.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
ritual: {
|
||||
tabs: {
|
||||
initial: "description",
|
||||
tabs: [
|
||||
{ id: "description", label: "DG.ItemWindow.Tome.Description" },
|
||||
{
|
||||
id: "handler",
|
||||
label: "DG.ItemWindow.Tome.HandlerOnlyTabHeader",
|
||||
gmOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/ritual-header.html` },
|
||||
...TABS_PART,
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/ritual-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
handler: {
|
||||
template: `${ITEM_PARTS_PATH}/ritual-handler.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
},
|
||||
},
|
||||
profession: {
|
||||
tabs: {
|
||||
initial: "description",
|
||||
tabs: [
|
||||
{ id: "description", label: "DG.ItemWindow.Profession.Description" },
|
||||
{ id: "skills", label: "DG.ItemWindow.Profession.SkillsTab" },
|
||||
],
|
||||
},
|
||||
parts: {
|
||||
header: { template: `${ITEM_PARTS_PATH}/profession-header.html` },
|
||||
...TABS_PART,
|
||||
description: {
|
||||
template: `${ITEM_PARTS_PATH}/profession-description.html`,
|
||||
scrollable: [""],
|
||||
},
|
||||
skills: {
|
||||
template: `${ITEM_PARTS_PATH}/profession-skills-tab.html`,
|
||||
scrollable: [".profession-skills-scroll"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ItemSheetBase = ProfessionItemMixin(
|
||||
EffectsTabMixin(DGSheetMixin(ItemSheetV2)),
|
||||
);
|
||||
|
||||
/**
|
||||
* @extends {ItemSheetBase}
|
||||
*/
|
||||
export default class DGItemSheet extends ItemSheetBase {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = /** @type {const} */ ({
|
||||
classes: ["item"],
|
||||
position: { width: 520, height: 600 },
|
||||
actions: {
|
||||
roll: DGItemSheet._onRoll,
|
||||
toggleTomeRevealed: DGItemSheet._toggleTomeRevealed,
|
||||
addAutomaticSkill: DGItemSheet.addAutomaticSkill,
|
||||
addOptionSkill: DGItemSheet.addOptionSkill,
|
||||
removeAutomaticSkill: DGItemSheet.removeAutomaticSkill,
|
||||
removeOptionSkill: DGItemSheet.removeOptionSkill,
|
||||
createEffect: ItemSheetBase.createEffect,
|
||||
openEffect: ItemSheetBase.openEffect,
|
||||
deleteEffect: ItemSheetBase.deleteEffect,
|
||||
toggleEffect: ItemSheetBase.toggleEffect,
|
||||
},
|
||||
});
|
||||
|
||||
/** @inheritdoc */
|
||||
get item() {
|
||||
return this.document;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
_configureRenderParts(options) {
|
||||
const layout = ITEM_SHEET_LAYOUT[this.item.type];
|
||||
if (!layout) return super._configureRenderParts(options);
|
||||
|
||||
const parts = foundry.utils.deepClone(layout.parts);
|
||||
if (layout.tabs) {
|
||||
const tabIds = layout.tabs.tabs
|
||||
.filter((tab) => !tab.gmOnly || game.user.isGM)
|
||||
.map((tab) => tab.id);
|
||||
for (const partId of Object.keys(parts)) {
|
||||
if (
|
||||
partId !== "tabs" &&
|
||||
partId !== "header" &&
|
||||
!tabIds.includes(partId)
|
||||
) {
|
||||
delete parts[partId];
|
||||
}
|
||||
}
|
||||
}
|
||||
Object.values(parts).forEach((p) => {
|
||||
p.templates ??= [];
|
||||
});
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
_getTabsConfig(group) {
|
||||
const layout = ITEM_SHEET_LAYOUT[this.item.type];
|
||||
if (!layout?.tabs || group !== ITEM_TAB_GROUP) {
|
||||
return super._getTabsConfig(group);
|
||||
}
|
||||
|
||||
const tabs = layout.tabs.tabs.filter(
|
||||
(tab) => !tab.gmOnly || game.user.isGM,
|
||||
);
|
||||
return { ...layout.tabs, tabs };
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preparePartContext(partId, context, options) {
|
||||
const partContext = await super._preparePartContext(
|
||||
partId,
|
||||
context,
|
||||
options,
|
||||
);
|
||||
if (partId === "tabs") partContext.tabClasses = "sheet-tabs";
|
||||
return partContext;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
|
||||
const layout = ITEM_SHEET_LAYOUT[this.item.type];
|
||||
if (layout?.tabs) {
|
||||
const { initial } = layout.tabs;
|
||||
const tabIds = layout.tabs.tabs
|
||||
.filter((tab) => !tab.gmOnly || game.user.isGM)
|
||||
.map((tab) => tab.id);
|
||||
if (!tabIds.includes(this.tabGroups[ITEM_TAB_GROUP])) {
|
||||
this.tabGroups[ITEM_TAB_GROUP] = initial;
|
||||
}
|
||||
context.tabs = this._prepareTabs(ITEM_TAB_GROUP);
|
||||
}
|
||||
|
||||
context.owner = this.document.isOwner;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async _onFirstRender(context, options) {
|
||||
await super._onFirstRender(context, options);
|
||||
this._setRightClickListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-click on roll controls opens the modifier dialog.
|
||||
* @returns {void}
|
||||
*/
|
||||
_setRightClickListeners() {
|
||||
this.element.addEventListener("contextmenu", (event) => {
|
||||
const target = event.target.closest("[data-action='roll']");
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
DGItemSheet._onRoll.call(this, event, target);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async _onRoll(event, target) {
|
||||
if (event.which === 2) return;
|
||||
|
||||
const roll = createDGRollFromDataset(target.dataset, {
|
||||
actor: this.actor,
|
||||
item: this.item,
|
||||
element: target,
|
||||
sanityDamageSource: "item",
|
||||
token: getDGRollToken(this.actor, this.token),
|
||||
});
|
||||
await processDGRoll(event, roll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether tome/ritual details are visible to players.
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
static _toggleTomeRevealed(event) {
|
||||
event.preventDefault();
|
||||
const revealed = !this.item.system.revealed;
|
||||
this.item.update({ "system.revealed": revealed });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
|
||||
const HbsAppMixin = foundry.applications.api.HandlebarsApplicationMixin;
|
||||
|
||||
@@ -29,8 +31,32 @@ const DGSheetMixin = (Base) => {
|
||||
// Add the document to the context under a descriptive name (i.e. "actor", "item")
|
||||
context[docName] = this.document;
|
||||
|
||||
context.richText = {};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async _preparePartContext(partId, context, options) {
|
||||
context = await super._preparePartContext(partId, context, options);
|
||||
|
||||
const { documentName } = this.document;
|
||||
const fieldSpecs = getRichTextFieldsForPart(documentName, partId, {
|
||||
actorType: this.document.type,
|
||||
itemType: this.document.type,
|
||||
showNotesInSkills: context.showNotesInSkills,
|
||||
});
|
||||
|
||||
if (fieldSpecs.length) {
|
||||
Object.assign(
|
||||
context.richText,
|
||||
await prepareRichTextContext(this.document, fieldSpecs),
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
|
||||
async _onFirstRender(context, options) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Copy typographic layout from the real input so the overlay matches except color. */
|
||||
function syncAeDisplayTypography(input, display) {
|
||||
const cs = getComputedStyle(input);
|
||||
display.style.inset = "auto";
|
||||
display.style.top = cs.borderTopWidth;
|
||||
display.style.left = cs.borderLeftWidth;
|
||||
display.style.right = cs.borderRightWidth;
|
||||
display.style.bottom = cs.borderBottomWidth;
|
||||
display.style.font = cs.font;
|
||||
display.style.lineHeight = cs.lineHeight;
|
||||
display.style.padding = cs.padding;
|
||||
display.style.textAlign = cs.textAlign;
|
||||
display.style.letterSpacing = cs.letterSpacing;
|
||||
display.style.boxSizing = cs.boxSizing;
|
||||
}
|
||||
|
||||
/** @type {WeakMap<HTMLElement, ResizeObserver>} */
|
||||
const hostObservers = new WeakMap();
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function AeInputMixin(Base) {
|
||||
return class AeInputHost extends Base {
|
||||
/** @param {HTMLElement} root */
|
||||
_bindAeBackedInputs(root) {
|
||||
for (const host of root.querySelectorAll(".ae-input-host")) {
|
||||
hostObservers.get(host)?.disconnect();
|
||||
|
||||
const input = host.querySelector("input");
|
||||
const display = host.querySelector(".ae-input-display");
|
||||
if (input && display) {
|
||||
const modifier = Number(display.dataset.aeModifier) || 0;
|
||||
|
||||
const syncTypography = () => syncAeDisplayTypography(input, display);
|
||||
|
||||
const syncDisplay = () => {
|
||||
if (host.matches(":focus-within")) return;
|
||||
const base = Number(input.value);
|
||||
if (Number.isFinite(base)) {
|
||||
const effective = Math.max(0, Math.round(base + modifier));
|
||||
display.textContent = String(effective);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("focus", () => input.select());
|
||||
input.addEventListener("blur", () => {
|
||||
syncTypography();
|
||||
syncDisplay();
|
||||
});
|
||||
|
||||
syncTypography();
|
||||
requestAnimationFrame(syncTypography);
|
||||
syncDisplay();
|
||||
|
||||
const observer = new ResizeObserver(syncTypography);
|
||||
observer.observe(input);
|
||||
hostObservers.set(host, observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @param {...Function} mixins
|
||||
* @returns {Function}
|
||||
*/
|
||||
export default function composeMixins(...mixins) {
|
||||
return (Base) => mixins.reduceRight((acc, mixin) => mixin(acc), Base);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { SUPPORTED_ITEM_TYPES } from "../../active-effect/effect-fields.js";
|
||||
import { getTransferSuppressionReasonKey } from "../../active-effect/documents/dg-active-effect.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function EffectsTabMixin(Base) {
|
||||
return class EffectsTabHost extends Base {
|
||||
/** @returns {Actor|Item} */
|
||||
get _effectsParent() {
|
||||
return this.document;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} target
|
||||
* @returns {ActiveEffect|undefined}
|
||||
*/
|
||||
_getEffectDocument(target) {
|
||||
const effectId = target.closest("[data-effect-id]")?.dataset?.effectId;
|
||||
if (!effectId) return undefined;
|
||||
|
||||
const parent = this._effectsParent;
|
||||
if (parent.documentName === "Actor") {
|
||||
if (target.closest("[data-parent-id]")?.dataset?.parentId) {
|
||||
const itemId = target.closest("[data-parent-id]").dataset.parentId;
|
||||
return parent.items.get(itemId)?.effects.get(effectId);
|
||||
}
|
||||
return parent.effects.get(effectId);
|
||||
}
|
||||
|
||||
return parent.effects.get(effectId);
|
||||
}
|
||||
|
||||
/** @returns {Promise<{ temporary: object[], permanent: object[] }>} */
|
||||
async _prepareSheetEffects() {
|
||||
const temporary = [];
|
||||
const permanent = [];
|
||||
const parent = this._effectsParent;
|
||||
|
||||
const effects =
|
||||
parent.documentName === "Actor"
|
||||
? parent.allApplicableEffects()
|
||||
: parent.effects;
|
||||
|
||||
for (const effect of effects) {
|
||||
const entry = {
|
||||
id: effect.id,
|
||||
name: effect.name,
|
||||
img: effect.img,
|
||||
disabled: effect.disabled,
|
||||
active: effect.active,
|
||||
transfer: effect.transfer,
|
||||
suppressionReasonKey: getTransferSuppressionReasonKey(effect),
|
||||
duration: null,
|
||||
source: null,
|
||||
};
|
||||
|
||||
if (effect.parent !== parent && parent.documentName === "Actor") {
|
||||
entry.source = {
|
||||
id: effect.parent.id,
|
||||
name: effect.parent.name,
|
||||
};
|
||||
}
|
||||
|
||||
if (effect.isTemporary) {
|
||||
entry.duration = {
|
||||
label: effect.duration?.label ?? "",
|
||||
};
|
||||
temporary.push(entry);
|
||||
} else {
|
||||
permanent.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return { temporary, permanent };
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
if (this._shouldPrepareSheetEffects()) {
|
||||
context.sheetEffects = await this._prepareSheetEffects();
|
||||
context.canManageEffects = this.isEditable;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
_shouldPrepareSheetEffects() {
|
||||
const parent = this._effectsParent;
|
||||
if (parent.documentName === "Actor") {
|
||||
return parent.type === "agent";
|
||||
}
|
||||
return SUPPORTED_ITEM_TYPES.has(parent.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async createEffect() {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
const parent = sheet._effectsParent;
|
||||
const documentClass = foundry.utils.getDocumentClass("ActiveEffect");
|
||||
|
||||
const docData = {
|
||||
name: game.i18n.localize("DG.ActiveEffects.DefaultName"),
|
||||
img: "systems/deltagreen/assets/icons/magic-shield.svg",
|
||||
transfer: SUPPORTED_ITEM_TYPES.has(parent.type),
|
||||
};
|
||||
|
||||
await documentClass.create(docData, {
|
||||
parent,
|
||||
renderSheet: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static openEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
sheet._getEffectDocument(target)?.sheet?.render({ force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async deleteEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
await sheet._getEffectDocument(target)?.deleteDialog();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static async toggleEffect(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
const effect = sheet._getEffectDocument(target);
|
||||
if (!effect) return;
|
||||
await effect.update({ disabled: !effect.disabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} _event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static openEffectOrigin(_event, target) {
|
||||
const sheet = /** @type {EffectsTabHost} */ (this);
|
||||
const itemId = target.closest("[data-parent-id]")?.dataset?.parentId;
|
||||
if (!itemId || sheet._effectsParent.documentName !== "Actor") return;
|
||||
const item = sheet._effectsParent.items.get(itemId);
|
||||
item?.sheet?.render({ force: true });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { showDgDialog } from "../../applications/dg-dialog.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function ItemListMixin(Base) {
|
||||
return class extends Base {
|
||||
/** @param {object} [context] */
|
||||
_prepareCharacterItems() {
|
||||
const { actor } = this;
|
||||
|
||||
const armor = [];
|
||||
const weapons = [];
|
||||
const gear = [];
|
||||
const tomes = [];
|
||||
const rituals = [];
|
||||
|
||||
for (const i of actor.items) {
|
||||
if (i.type === "armor") {
|
||||
armor.push(i);
|
||||
} else if (i.type === "weapon") {
|
||||
weapons.push(i);
|
||||
} else if (i.type === "gear") {
|
||||
gear.push(i);
|
||||
} else if (i.type === "tome") {
|
||||
tomes.push(i);
|
||||
} else if (i.type === "ritual") {
|
||||
rituals.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const nameSort = (a, b) => {
|
||||
const x = a.name.toLowerCase();
|
||||
const y = b.name.toLowerCase();
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortSort = (a, b) => a.sort - b.sort;
|
||||
|
||||
if (actor.system.settings.sorting.armorSortAlphabetical) {
|
||||
armor.sort(nameSort);
|
||||
} else {
|
||||
armor.sort(sortSort);
|
||||
}
|
||||
|
||||
if (actor.system.settings.sorting.weaponSortAlphabetical) {
|
||||
weapons.sort(nameSort);
|
||||
} else {
|
||||
weapons.sort(sortSort);
|
||||
}
|
||||
|
||||
if (actor.system.settings.sorting.gearSortAlphabetical) {
|
||||
gear.sort(nameSort);
|
||||
} else {
|
||||
gear.sort(sortSort);
|
||||
}
|
||||
|
||||
if (actor.system.settings.sorting.tomeSortAlphabetical) {
|
||||
tomes.sort(nameSort);
|
||||
} else {
|
||||
tomes.sort(sortSort);
|
||||
}
|
||||
|
||||
if (actor.system.settings.sorting.ritualSortAlphabetical) {
|
||||
rituals.sort(nameSort);
|
||||
} else {
|
||||
rituals.sort(sortSort);
|
||||
}
|
||||
|
||||
actor.armor = armor;
|
||||
actor.weapons = weapons;
|
||||
actor.gear = gear;
|
||||
actor.rituals = rituals;
|
||||
actor.tomes = tomes;
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
_setupItemDragListeners() {
|
||||
const { element } = this;
|
||||
const handler = (ev) => this._onDragStart(ev);
|
||||
element.querySelectorAll("li.item").forEach((li) => {
|
||||
if (li.classList.contains("inventory-header")) return;
|
||||
li.setAttribute("draggable", true);
|
||||
li.addEventListener("dragstart", handler, false);
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_onDragStart(event) {
|
||||
const li = event.currentTarget;
|
||||
if (event.target.classList.contains("content-link")) return;
|
||||
|
||||
let dragData;
|
||||
|
||||
if (li.dataset.itemId) {
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
dragData = item.toDragData();
|
||||
}
|
||||
|
||||
if (li.dataset.effectId) {
|
||||
const effect = this.actor.effects.get(li.dataset.effectId);
|
||||
dragData = effect.toDragData();
|
||||
}
|
||||
|
||||
if (!dragData) return;
|
||||
|
||||
if (li.dataset.itemId) {
|
||||
const item = this.actor.items.get(li.dataset.itemId);
|
||||
dragData.itemData = item;
|
||||
}
|
||||
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
}
|
||||
|
||||
/** @override */
|
||||
async _onDrop(event) {
|
||||
super._onDrop(event);
|
||||
if (event.altKey) {
|
||||
const TextEditor = foundry.applications.ux.TextEditor.implementation;
|
||||
const dragData = TextEditor.getDragEventData(event);
|
||||
if (dragData.type === "Item") {
|
||||
const item = fromUuidSync(dragData.uuid);
|
||||
await item.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static _onItemAction(event, target) {
|
||||
const li = target.closest(".item");
|
||||
const { itemId } = li.dataset;
|
||||
const { actionType, itemType } = target.dataset;
|
||||
|
||||
switch (actionType) {
|
||||
case "create":
|
||||
this._onItemCreate(itemType);
|
||||
break;
|
||||
case "edit": {
|
||||
const item = this.actor.items.get(itemId);
|
||||
item.sheet.render(true);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
this.actor.deleteEmbeddedDocuments("Item", [itemId]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {String} type */
|
||||
_onItemCreate(type) {
|
||||
const name = game.i18n.format(
|
||||
game.i18n.translations.DOCUMENT?.New || "DG.FallbackText.newItem",
|
||||
{
|
||||
type: game.i18n.localize(`TYPES.Item.${type}`),
|
||||
},
|
||||
);
|
||||
|
||||
const itemData = {
|
||||
name,
|
||||
type,
|
||||
system: {},
|
||||
};
|
||||
|
||||
if (type === "bond") {
|
||||
itemData.system.score =
|
||||
this.actor.system.statistics.cha.effectiveValue ??
|
||||
this.actor.system.statistics.cha.value;
|
||||
}
|
||||
|
||||
return this.actor.createEmbeddedDocuments("Item", [itemData]);
|
||||
}
|
||||
|
||||
/** Handler for generic toggle events */
|
||||
static _toggleGeneric(event, target) {
|
||||
const { prop } = target.dataset;
|
||||
const itemId = target.closest("[data-item-id]")?.dataset.itemId;
|
||||
this.toggle(prop, itemId);
|
||||
}
|
||||
|
||||
/** Handler for item-sort-mode toggle events */
|
||||
static _toggleItemSortMode(event, target) {
|
||||
const itemType = target.dataset.gearType;
|
||||
const propString = `${itemType}SortAlphabetical`;
|
||||
const targetProp = `system.settings.sorting.${propString}`;
|
||||
this.toggle(targetProp);
|
||||
}
|
||||
|
||||
static _browsePack(event, target) {
|
||||
const { packType } = target.dataset;
|
||||
if (packType === "weapon") {
|
||||
this._browseWeaponPack().catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
game.packs
|
||||
.find((k) => k.collection === `deltagreen.${packType}`)
|
||||
.render(true);
|
||||
}
|
||||
|
||||
async _browseWeaponPack() {
|
||||
await showDgDialog({
|
||||
modifier: "browse-weapon-pack",
|
||||
window: {
|
||||
title: game.i18n.localize("DG.Gear.SelectCompendium"),
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
action: "firearms",
|
||||
label: game.i18n.localize("DG.Gear.WeaponTypes.Firearms"),
|
||||
icon: '<i class="fas fa-crosshairs"></i>',
|
||||
callback: () =>
|
||||
game.packs
|
||||
.find((k) => k.collection === "deltagreen.firearms")
|
||||
.render(true),
|
||||
},
|
||||
{
|
||||
action: "melee",
|
||||
label: game.i18n.localize("DG.Gear.WeaponTypes.Melee"),
|
||||
icon: '<i class="far fa-hand-rock"></i>',
|
||||
callback: () =>
|
||||
game.packs
|
||||
.find((k) => k.collection === "deltagreen.hand-to-hand-weapons")
|
||||
.render(true),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} prop
|
||||
* @param {string} [itemId]
|
||||
*/
|
||||
toggle(prop, itemId) {
|
||||
const item = this.actor.items.get(itemId);
|
||||
const targetDoc = item ?? this.actor;
|
||||
|
||||
const currentVal = foundry.utils.getProperty(targetDoc, prop);
|
||||
targetDoc.update({ [prop]: !currentVal });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { BASE_TEMPLATE_PATH } from "../../config/index.js";
|
||||
import { PROFESSION_OPTION_PICKS_KEY } from "../../data/item/profession.js";
|
||||
import {
|
||||
allocateProfessionSkillStorageKey,
|
||||
buildSkillCatalog,
|
||||
catalogIdToSkillRef,
|
||||
findTypedSkillNameConflict,
|
||||
prepareProfessionSkillRows,
|
||||
splitProfessionSkillMap,
|
||||
} from "../../profession/index.js";
|
||||
import {
|
||||
getDialogContentRoot,
|
||||
showDgDialog,
|
||||
} from "../../applications/dg-dialog.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { ForcedDeletion } = foundry.data.operators;
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function ProfessionItemMixin(Base) {
|
||||
return class extends Base {
|
||||
/** @inheritdoc */
|
||||
async _preparePartContext(partId, context, options) {
|
||||
const partContext = await super._preparePartContext(
|
||||
partId,
|
||||
context,
|
||||
options,
|
||||
);
|
||||
if (this.item.type !== "profession" || partId !== "skills") {
|
||||
return partContext;
|
||||
}
|
||||
|
||||
const automatic = this.item.system.automaticSkills ?? {};
|
||||
const automaticMeta = this.item.system.automaticSkillMeta ?? {};
|
||||
const { optionPicks, skills: optionSkillMap } = splitProfessionSkillMap(
|
||||
this.item.system.optionSkills ?? {},
|
||||
);
|
||||
const optionMeta = this.item.system.optionSkillMeta ?? {};
|
||||
|
||||
partContext.automaticSkillRows = prepareProfessionSkillRows(
|
||||
automatic,
|
||||
automaticMeta,
|
||||
).map((row) => ({
|
||||
...row,
|
||||
isChooseOne: Boolean(automaticMeta[row.key]?.chooseOne),
|
||||
}));
|
||||
partContext.optionSkillRows = prepareProfessionSkillRows(
|
||||
optionSkillMap,
|
||||
optionMeta,
|
||||
).map((row) => ({
|
||||
...row,
|
||||
isChooseOne: Boolean(optionMeta[row.key]?.chooseOne),
|
||||
}));
|
||||
partContext.optionPicks = optionPicks;
|
||||
|
||||
return partContext;
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} _event */
|
||||
static addAutomaticSkill(_event) {
|
||||
return this._showAddProfessionSkillDialog("automatic");
|
||||
}
|
||||
|
||||
/** @param {PointerEvent} _event */
|
||||
static addOptionSkill(_event) {
|
||||
return this._showAddProfessionSkillDialog("option");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"automatic" | "option"} section
|
||||
*/
|
||||
async _showAddProfessionSkillDialog(section) {
|
||||
const catalog = buildSkillCatalog();
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/add-profession-skill.html`,
|
||||
{ catalog, isTyped: false },
|
||||
);
|
||||
|
||||
const confirmed = await showDgDialog({
|
||||
modifier: "add-profession-skill",
|
||||
content,
|
||||
position: { width: 300 },
|
||||
window: {
|
||||
title: game.i18n.localize("DG.ItemWindow.Profession.AddSkillTitle"),
|
||||
},
|
||||
form: { closeOnSubmit: false },
|
||||
onRender: (dialog) => {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
const select = root?.querySelector('[name="skillCatalogId"]');
|
||||
const typedGroup = root?.querySelector(
|
||||
".profession-typed-label-group",
|
||||
);
|
||||
const typeInput = root?.querySelector('[name="typedSkillLabel"]');
|
||||
const chooseOneInput = root?.querySelector('[name="chooseOne"]');
|
||||
|
||||
const syncTypedVisibility = () => {
|
||||
const isTyped = select?.value.startsWith("typed:") ?? false;
|
||||
typedGroup?.classList.toggle("hidden", !isTyped);
|
||||
if (!isTyped) {
|
||||
if (chooseOneInput) chooseOneInput.checked = false;
|
||||
if (typeInput) {
|
||||
typeInput.value = "";
|
||||
typeInput.disabled = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const syncChooseOne = () => {
|
||||
const chooseOne = Boolean(chooseOneInput?.checked);
|
||||
if (typeInput) {
|
||||
typeInput.disabled = chooseOne;
|
||||
if (chooseOne) typeInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
select?.addEventListener("change", syncTypedVisibility);
|
||||
chooseOneInput?.addEventListener("change", syncChooseOne);
|
||||
syncTypedVisibility();
|
||||
syncChooseOne();
|
||||
},
|
||||
close: () => false,
|
||||
buttons: [
|
||||
{
|
||||
action: "ok",
|
||||
label: game.i18n.localize("DG.Profession.Dialog.Submit"),
|
||||
default: true,
|
||||
callback: async (_event, _button, dialog) => {
|
||||
const root = getDialogContentRoot(dialog);
|
||||
const select = root?.querySelector('[name="skillCatalogId"]');
|
||||
const ratingInput = root?.querySelector('[name="skillRating"]');
|
||||
const typeInput = root?.querySelector('[name="typedSkillLabel"]');
|
||||
const chooseOneInput = root?.querySelector('[name="chooseOne"]');
|
||||
|
||||
const catalogId = select?.value ?? "";
|
||||
const rating = Number(ratingInput?.value);
|
||||
const typedLabel = typeInput?.value?.trim() ?? "";
|
||||
const chooseOne = Boolean(chooseOneInput?.checked);
|
||||
|
||||
if (!catalogId || Number.isNaN(rating)) return false;
|
||||
|
||||
const isTyped = catalogId.startsWith("typed:");
|
||||
if (isTyped && !chooseOne && !typedLabel) {
|
||||
ui.notifications.warn(
|
||||
game.i18n.localize(
|
||||
"DG.ItemWindow.Profession.TypedSkillRequired",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const ref = catalogIdToSkillRef(
|
||||
catalogId,
|
||||
chooseOne ? "" : typedLabel,
|
||||
{
|
||||
allowEmptyTypedLabel: chooseOne,
|
||||
},
|
||||
);
|
||||
if (!ref) return false;
|
||||
|
||||
const skillKey = allocateProfessionSkillStorageKey(ref, {
|
||||
chooseOne,
|
||||
});
|
||||
|
||||
const automaticSkills = {
|
||||
...(this.item.system.automaticSkills ?? {}),
|
||||
};
|
||||
const optionSkills = { ...(this.item.system.optionSkills ?? {}) };
|
||||
const automaticMeta = {
|
||||
...(this.item.system.automaticSkillMeta ?? {}),
|
||||
};
|
||||
const optionMeta = {
|
||||
...(this.item.system.optionSkillMeta ?? {}),
|
||||
};
|
||||
|
||||
const conflict =
|
||||
ref.kind === "typed"
|
||||
? findTypedSkillNameConflict({
|
||||
group: ref.group,
|
||||
label: chooseOne ? "" : typedLabel,
|
||||
chooseOne,
|
||||
automaticSkills,
|
||||
optionSkills,
|
||||
automaticMeta,
|
||||
optionMeta,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (conflict) {
|
||||
ui.notifications.warn(
|
||||
game.i18n.format(
|
||||
"DG.ItemWindow.Profession.TypedSkillNameConflict",
|
||||
{ name: typedLabel },
|
||||
),
|
||||
{ localize: true },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const skillField =
|
||||
section === "automatic" ? "automaticSkills" : "optionSkills";
|
||||
const metaField =
|
||||
section === "automatic"
|
||||
? "automaticSkillMeta"
|
||||
: "optionSkillMeta";
|
||||
|
||||
const currentSkills =
|
||||
section === "automatic" ? automaticSkills : optionSkills;
|
||||
const currentMeta =
|
||||
section === "automatic" ? automaticMeta : optionMeta;
|
||||
|
||||
if (
|
||||
section === "option" &&
|
||||
currentSkills[PROFESSION_OPTION_PICKS_KEY] === undefined
|
||||
) {
|
||||
currentSkills[PROFESSION_OPTION_PICKS_KEY] = 0;
|
||||
}
|
||||
|
||||
currentSkills[skillKey] = rating;
|
||||
if (chooseOne) currentMeta[skillKey] = { chooseOne: true };
|
||||
else delete currentMeta[skillKey];
|
||||
|
||||
await this.item.update({
|
||||
[`system.${skillField}`]: currentSkills,
|
||||
[`system.${metaField}`]: currentMeta,
|
||||
});
|
||||
await dialog.close();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: game.i18n.localize("Cancel"),
|
||||
callback: async (_event, _button, dialog) => {
|
||||
await dialog.close();
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (confirmed) this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} target
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
#getProfessionSkillKeyFromTarget(target) {
|
||||
return target.closest("[data-skill-key]")?.dataset.skillKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {"automaticSkills" | "optionSkills"} field
|
||||
* @param {string} skillKey
|
||||
*/
|
||||
async #removeProfessionSkill(field, skillKey) {
|
||||
const metaField =
|
||||
field === "automaticSkills" ? "automaticSkillMeta" : "optionSkillMeta";
|
||||
|
||||
await this.item.update({
|
||||
system: {
|
||||
[field]: {
|
||||
[skillKey]: new ForcedDeletion(),
|
||||
},
|
||||
[metaField]: {
|
||||
[skillKey]: new ForcedDeletion(),
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static removeAutomaticSkill(event, target) {
|
||||
const skillKey = this.#getProfessionSkillKeyFromTarget(target);
|
||||
if (skillKey) {
|
||||
this.#removeProfessionSkill("automaticSkills", skillKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
* @param {HTMLElement} target
|
||||
*/
|
||||
static removeOptionSkill(event, target) {
|
||||
const skillKey = this.#getProfessionSkillKeyFromTarget(target);
|
||||
if (skillKey && skillKey !== PROFESSION_OPTION_PICKS_KEY) {
|
||||
this.#removeProfessionSkill("optionSkills", skillKey);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getDGRollToken } from "../../chat/dg-chat-card.js";
|
||||
import { createDGRollFromDataset, processDGRoll } from "../../roll/roll.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function RollSheetMixin(Base) {
|
||||
return class extends Base {
|
||||
/**
|
||||
* Listens for right click events on the actor sheet and executes a regular roll,
|
||||
* or luck roll, depending on the action.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
_setRightClickListeners() {
|
||||
const { element } = this;
|
||||
element.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const target = event.target.closest(
|
||||
"[data-action='roll'],[data-action='rollLuck']",
|
||||
);
|
||||
if (!target) return;
|
||||
|
||||
if (target.dataset.action === "rollLuck") {
|
||||
this.constructor._onRollLuck.call(this, event, target);
|
||||
return;
|
||||
}
|
||||
|
||||
this.constructor._onRoll.call(this, event, target);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clickable rolls.
|
||||
*
|
||||
* @param {Event} event The originating click event
|
||||
* @async
|
||||
* @private
|
||||
*/
|
||||
static async _onRoll(event, target) {
|
||||
if (target.classList.contains("not-rollable") || event.which === 2)
|
||||
return;
|
||||
|
||||
const item = this.actor.items.get(target.dataset.iid);
|
||||
const roll = createDGRollFromDataset(target.dataset, {
|
||||
actor: this.actor,
|
||||
item,
|
||||
element: target,
|
||||
sanityDamageSource: "actor",
|
||||
token: getDGRollToken(this.actor, this.token),
|
||||
});
|
||||
await this.processRoll(event, roll);
|
||||
}
|
||||
|
||||
static async _onRollLuck(event) {
|
||||
if (event.which === 2) return;
|
||||
|
||||
const roll = createDGRollFromDataset(
|
||||
{ rolltype: "luck", key: "luck" },
|
||||
{
|
||||
actor: this.actor,
|
||||
token: getDGRollToken(this.actor, this.token),
|
||||
},
|
||||
);
|
||||
await this.processRoll(event, roll);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a dialog for the roll and then send to chat.
|
||||
*
|
||||
* @param {Event} event The originating click event
|
||||
* @param {Roll} roll The roll to show a dialog for and then send to chat.
|
||||
* @async
|
||||
*/
|
||||
async processRoll(event, roll) {
|
||||
return processDGRoll(event, roll);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import DG from "../../config/index.js";
|
||||
import { reorderForColumnSorting } from "../../utils/skill-layout.js";
|
||||
import { getRollTargetDisplayClassFromModifier } from "../../active-effect/runtime/derived.js";
|
||||
import {
|
||||
applySkillTooltipDisplayMode,
|
||||
buildAgentSpecialTrainingTooltip,
|
||||
buildSkillTooltip,
|
||||
} from "../../utils/skill-tooltip.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function SkillPrepMixin(Base) {
|
||||
return class extends Base {
|
||||
/**
|
||||
* Prepares the subname info placeholder based on the actor type.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
_prepareSubnameInfoPlaceholder() {
|
||||
let subnameInfoPlaceholder = "";
|
||||
switch (this.actor.type) {
|
||||
case "unnatural":
|
||||
subnameInfoPlaceholder =
|
||||
"DG.UnnaturalSheet.ShortDescriptionPlaceholder";
|
||||
break;
|
||||
case "vehicle":
|
||||
subnameInfoPlaceholder = "DG.VehicleSheet.DescriptionPlaceHolder";
|
||||
break;
|
||||
default:
|
||||
subnameInfoPlaceholder = "DG.AgentSheet.ProfessionPlaceholder";
|
||||
break;
|
||||
}
|
||||
return game.i18n.localize(subnameInfoPlaceholder);
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
_sortSkills() {
|
||||
const sortedSkills = [];
|
||||
for (const [key, skill] of Object.entries(this.actor.system.skills)) {
|
||||
skill.key = key;
|
||||
|
||||
if (game.i18n.lang === "ja") {
|
||||
skill.sortLabel = game.i18n.localize(`DG.Skills.ruby.${key}`);
|
||||
} else {
|
||||
skill.sortLabel = game.i18n.localize(`DG.Skills.${key}`);
|
||||
}
|
||||
|
||||
if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) {
|
||||
skill.sortLabel = skill.key;
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
(this.actor.type === "npc" || this.actor.type === "unnatural") &&
|
||||
this.actor.system.showUntrainedSkills &&
|
||||
skill.proficiency < 1
|
||||
)
|
||||
) {
|
||||
sortedSkills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
sortedSkills.sort((a, b) => {
|
||||
return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang);
|
||||
});
|
||||
|
||||
if (game.settings.get("deltagreen", "sortSkills")) {
|
||||
const columnSortedSkills = reorderForColumnSorting(sortedSkills, 3);
|
||||
this.actor.system.sortedSkills = columnSortedSkills;
|
||||
} else {
|
||||
this.actor.system.sortedSkills = sortedSkills;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
_sortCustomSkills() {
|
||||
const specialTrainings = this._prepareSpecialTrainings();
|
||||
const sortedCustomSkills = [];
|
||||
|
||||
for (const [key, skill] of Object.entries(
|
||||
this.actor.system.typedSkills,
|
||||
)) {
|
||||
skill.type = "typeSkill";
|
||||
skill.key = key;
|
||||
skill.sortLabel = `${skill.group}.${skill.label}`;
|
||||
skill.sortLabel = skill.sortLabel.toUpperCase();
|
||||
skill.actorType = this.actor.type;
|
||||
|
||||
if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) {
|
||||
skill.sortLabel = skill.key;
|
||||
}
|
||||
|
||||
skill.tooltip = buildSkillTooltip(
|
||||
"sheet",
|
||||
this.actor,
|
||||
{ kind: "typed", group: skill.group, label: skill.label },
|
||||
{ proficiency: Number(skill.proficiency) || 0 },
|
||||
);
|
||||
|
||||
sortedCustomSkills.push(skill);
|
||||
}
|
||||
|
||||
for (let i = 0; i < specialTrainings.length; i++) {
|
||||
const training = specialTrainings[i];
|
||||
|
||||
training.type = "training";
|
||||
training.sortLabel = training.name.toUpperCase();
|
||||
training.actorType = this.actor.type;
|
||||
|
||||
sortedCustomSkills.push(training);
|
||||
}
|
||||
|
||||
sortedCustomSkills.sort((a, b) => {
|
||||
return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang);
|
||||
});
|
||||
|
||||
if (game.settings.get("deltagreen", "sortSkills")) {
|
||||
const columnSortedSkills = reorderForColumnSorting(
|
||||
sortedCustomSkills,
|
||||
2,
|
||||
);
|
||||
|
||||
this.actor.system.sortedCustomSkills = columnSortedSkills;
|
||||
} else {
|
||||
this.actor.system.sortedCustomSkills = sortedCustomSkills;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {void} */
|
||||
_prepareSkillTooltips() {
|
||||
for (const skill of Object.values(this.actor.system.sortedSkills)) {
|
||||
skill.tooltip = buildSkillTooltip(
|
||||
"sheet",
|
||||
this.actor,
|
||||
{ kind: "fixed", key: skill.key },
|
||||
{ proficiency: Number(skill.proficiency) || 0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {object[]} */
|
||||
_prepareSpecialTrainings() {
|
||||
return this.actor.system.specialTraining.map((training) => {
|
||||
const simplifiedTraining = {
|
||||
name: training.name,
|
||||
id: training.id,
|
||||
key: training.attribute,
|
||||
};
|
||||
let attributeLabel;
|
||||
let attributeTooltip = null;
|
||||
|
||||
switch (true) {
|
||||
case DG.statistics.includes(training.attribute):
|
||||
attributeLabel = `${training.attribute.toUpperCase()}x5`;
|
||||
attributeTooltip = `DG.Attributes.Tooltip.${training.attribute}`;
|
||||
simplifiedTraining.attribute = attributeLabel;
|
||||
simplifiedTraining.targetNumber =
|
||||
this.actor.system.statistics[training.attribute].x5;
|
||||
break;
|
||||
case DG.skills.includes(training.attribute):
|
||||
attributeLabel = game.i18n.localize(
|
||||
`DG.Skills.${training.attribute}`,
|
||||
);
|
||||
if (attributeLabel === `DG.Skills.${training.attribute}`) {
|
||||
attributeLabel =
|
||||
this.actor.system.skills[training.attribute].label;
|
||||
}
|
||||
attributeTooltip = `DG.Skills.Tooltip.${training.attribute}`;
|
||||
simplifiedTraining.attribute = attributeLabel;
|
||||
simplifiedTraining.targetNumber =
|
||||
this.actor.system.skills[training.attribute].proficiency;
|
||||
break;
|
||||
default:
|
||||
attributeLabel =
|
||||
this.actor.system.typedSkills[training.attribute].label;
|
||||
simplifiedTraining.attribute = attributeLabel;
|
||||
simplifiedTraining.targetNumber =
|
||||
this.actor.system.typedSkills[training.attribute].proficiency;
|
||||
break;
|
||||
}
|
||||
|
||||
simplifiedTraining.attributeLabel = attributeLabel;
|
||||
simplifiedTraining.attributeTooltip = attributeTooltip;
|
||||
|
||||
if (this.actor.type === "agent") {
|
||||
const { rollTarget } = this.actor.system;
|
||||
let modifier = 0;
|
||||
|
||||
if (rollTarget) {
|
||||
if (DG.statistics.includes(training.attribute)) {
|
||||
modifier = Number(rollTarget.statistics) || 0;
|
||||
} else {
|
||||
modifier = Number(rollTarget.allSkills) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
simplifiedTraining.rollTargetDisplayClass =
|
||||
getRollTargetDisplayClassFromModifier(modifier);
|
||||
}
|
||||
|
||||
simplifiedTraining.displayLabel = `${simplifiedTraining.name} (${attributeLabel}, ${simplifiedTraining.targetNumber}%)`;
|
||||
|
||||
if (this.actor.type === "agent" && attributeTooltip) {
|
||||
const rollTargetKey = DG.statistics.includes(training.attribute)
|
||||
? "system.rollTarget.statistics"
|
||||
: "system.rollTarget.allSkills";
|
||||
simplifiedTraining.tooltip = buildAgentSpecialTrainingTooltip(
|
||||
this.actor,
|
||||
attributeTooltip,
|
||||
rollTargetKey,
|
||||
simplifiedTraining.targetNumber,
|
||||
);
|
||||
}
|
||||
|
||||
return simplifiedTraining;
|
||||
});
|
||||
}
|
||||
|
||||
shouldShowHyperGeometrySection(actor) {
|
||||
if (game.user.isGM) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
game.settings.get(
|
||||
"deltagreen",
|
||||
"alwaysShowHypergeometrySectionForPlayers",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const i of actor.items) {
|
||||
if (i.type === "tome" || i.type === "ritual") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require Shift while hovering to show tooltips.
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
*/
|
||||
_tooltipsSettings(root) {
|
||||
applySkillTooltipDisplayMode(root);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { showDgDialog } from "../../applications/dg-dialog.js";
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function SpecialTrainingMixin(Base) {
|
||||
return class extends Base {
|
||||
static _onSpecialTrainingAction(event, target) {
|
||||
const { actionType, id } = target.dataset;
|
||||
switch (actionType) {
|
||||
case "delete":
|
||||
{
|
||||
const specialTrainingArray = foundry.utils.duplicate(
|
||||
this.actor.system.specialTraining,
|
||||
);
|
||||
|
||||
const index = specialTrainingArray.findIndex(
|
||||
(training) => training.id === id,
|
||||
);
|
||||
|
||||
specialTrainingArray.splice(index, 1);
|
||||
this.actor.update({
|
||||
"system.specialTraining": specialTrainingArray,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this._showSpecialTrainingDialog(actionType, id).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async _showSpecialTrainingDialog(action, targetID) {
|
||||
const specialTraining = this.actor.system.specialTraining.find(
|
||||
(training) => training.id === targetID,
|
||||
);
|
||||
|
||||
const optionGroups = {
|
||||
stats: game.i18n.localize(
|
||||
"DG.SpecialTraining.Dialog.DropDown.Statistics",
|
||||
),
|
||||
skills: game.i18n.localize("DG.SpecialTraining.Dialog.DropDown.Skills"),
|
||||
typedSkills: game.i18n.localize(
|
||||
"DG.SpecialTraining.Dialog.DropDown.CustomSkills",
|
||||
),
|
||||
};
|
||||
|
||||
const statList = Object.entries(this.actor.system.statistics).map(
|
||||
([key, stat]) => ({
|
||||
value: key,
|
||||
group: optionGroups.stats,
|
||||
label: game.i18n.localize(`DG.Attributes.${key}`),
|
||||
targetNumber: stat.value * 5,
|
||||
}),
|
||||
);
|
||||
|
||||
const skillList = Object.entries(this.actor.system.skills).map(
|
||||
([key, skill]) => ({
|
||||
value: key,
|
||||
group: optionGroups.skills,
|
||||
label: game.i18n.localize(`DG.Skills.${key}`),
|
||||
targetNumber: skill.proficiency,
|
||||
}),
|
||||
);
|
||||
|
||||
const typedSkillList = Object.entries(this.actor.system.typedSkills).map(
|
||||
([key, skill]) => ({
|
||||
value: key,
|
||||
group: optionGroups.typedSkills,
|
||||
label: `${game.i18n.localize(`DG.TypeSkills.${skill.group}`)} (${
|
||||
skill.label
|
||||
})`,
|
||||
targetNumber: skill.proficiency,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectElement = foundry.applications.fields.createSelectInput({
|
||||
name: "special-training-skill",
|
||||
options: [...statList, ...skillList, ...typedSkillList],
|
||||
groups: Object.values(optionGroups),
|
||||
}).outerHTML;
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const content = await renderTemplate(
|
||||
"systems/deltagreen/templates/dialog/special-training.html",
|
||||
{
|
||||
name: specialTraining?.name || "",
|
||||
selectElement,
|
||||
currentAttribute: specialTraining?.attribute || "",
|
||||
statList,
|
||||
skillList,
|
||||
typedSkillList,
|
||||
},
|
||||
);
|
||||
|
||||
const buttonLabel = game.i18n.localize(
|
||||
`DG.SpecialTraining.Dialog.${action.capitalize()}SpecialTraining`,
|
||||
);
|
||||
|
||||
await showDgDialog({
|
||||
modifier: "special-training",
|
||||
content,
|
||||
window: {
|
||||
title: game.i18n.localize("DG.SpecialTraining.Dialog.Title"),
|
||||
},
|
||||
default: "confirm",
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
action: "confirm",
|
||||
label: buttonLabel,
|
||||
callback: (_event, _button, dialog) => {
|
||||
const specialTrainingLabel = dialog.element.querySelector(
|
||||
"[name='special-training-label']",
|
||||
)?.value;
|
||||
const specialTrainingAttribute = dialog.element.querySelector(
|
||||
"[name='special-training-skill']",
|
||||
)?.value;
|
||||
if (action === "create")
|
||||
this._createSpecialTraining(
|
||||
specialTrainingLabel,
|
||||
specialTrainingAttribute,
|
||||
);
|
||||
if (action === "edit")
|
||||
this._editSpecialTraining(
|
||||
specialTrainingLabel,
|
||||
specialTrainingAttribute,
|
||||
targetID,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
_createSpecialTraining(label, attribute) {
|
||||
const specialTrainingArray = foundry.utils.duplicate(
|
||||
this.actor.system.specialTraining,
|
||||
);
|
||||
specialTrainingArray.push({
|
||||
name: label,
|
||||
attribute,
|
||||
id: foundry.utils.randomID(),
|
||||
});
|
||||
this.actor.update({ "system.specialTraining": specialTrainingArray });
|
||||
}
|
||||
|
||||
_editSpecialTraining(label, attribute, id) {
|
||||
const specialTrainingArray = foundry.utils.duplicate(
|
||||
this.actor.system.specialTraining,
|
||||
);
|
||||
const specialTraining = specialTrainingArray.find(
|
||||
(training) => training.id === id,
|
||||
);
|
||||
specialTraining.name = label;
|
||||
specialTraining.attribute = attribute;
|
||||
this.actor.update({ "system.specialTraining": specialTrainingArray });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { showDgDialog } from "../../applications/dg-dialog.js";
|
||||
import { BASE_TEMPLATE_PATH } from "../../config/index.js";
|
||||
import {
|
||||
TYPED_GROUP_I18N,
|
||||
TYPED_SKILL_TEMPLATE_GROUPS,
|
||||
} from "../../profession/constants.js";
|
||||
|
||||
const { renderTemplate } = foundry.applications.handlebars;
|
||||
const { ForcedDeletion } = foundry.data.operators;
|
||||
|
||||
/**
|
||||
* @param {string} [selectedGroup]
|
||||
* @returns {{ value: string, label: string, selected: boolean }[]}
|
||||
*/
|
||||
function buildTypedSkillGroupOptions(selectedGroup) {
|
||||
return TYPED_SKILL_TEMPLATE_GROUPS.map((value) => ({
|
||||
value,
|
||||
label: game.i18n.localize(TYPED_GROUP_I18N[value]),
|
||||
selected: value === selectedGroup,
|
||||
}));
|
||||
}
|
||||
|
||||
/** @param {typeof foundry.applications.api.ApplicationV2} Base */
|
||||
export default function TypedSkillMixin(Base) {
|
||||
return class extends Base {
|
||||
static _onTypedSkillAction(event, target) {
|
||||
const { actionType, typedskill } = target.dataset;
|
||||
switch (actionType) {
|
||||
case "create":
|
||||
this._showTypedSkillDialog({ mode: "create" }).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
break;
|
||||
case "edit":
|
||||
this._showTypedSkillDialog({
|
||||
mode: "edit",
|
||||
targetSkill: typedskill,
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
break;
|
||||
case "delete":
|
||||
this.actor.update({
|
||||
system: {
|
||||
typedSkills: {
|
||||
[typedskill]: new ForcedDeletion(),
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ mode: "create" | "edit", targetSkill?: string }} options
|
||||
*/
|
||||
async _showTypedSkillDialog({ mode, targetSkill }) {
|
||||
const isEdit = mode === "edit";
|
||||
const { typedSkills } = this.actor.system;
|
||||
const currentLabel = isEdit ? typedSkills[targetSkill].label : "";
|
||||
const currentGroup = isEdit
|
||||
? typedSkills[targetSkill].group
|
||||
: TYPED_SKILL_TEMPLATE_GROUPS[0];
|
||||
|
||||
const content = await renderTemplate(
|
||||
`${BASE_TEMPLATE_PATH}/dialog/typed-skill.html`,
|
||||
{
|
||||
groups: buildTypedSkillGroupOptions(currentGroup),
|
||||
label: currentLabel,
|
||||
},
|
||||
);
|
||||
|
||||
const titleKey = isEdit
|
||||
? "DG.Skills.EditTypedOrCustomSkill"
|
||||
: "DG.Skills.AddTypedOrCustomSkill";
|
||||
const buttonKey = isEdit ? "DG.Skills.EditSkill" : "DG.Skills.AddSkill";
|
||||
|
||||
await showDgDialog({
|
||||
modifier: "typed-skill",
|
||||
content,
|
||||
position: { width: 320 },
|
||||
window: { title: game.i18n.localize(titleKey) },
|
||||
default: "submit",
|
||||
buttons: [
|
||||
{
|
||||
default: true,
|
||||
action: "submit",
|
||||
label: game.i18n.localize(buttonKey),
|
||||
callback: (_event, _button, dialog) => {
|
||||
const newTypeSkillLabel = dialog.element.querySelector(
|
||||
"[name='new-type-skill-label']",
|
||||
)?.value;
|
||||
const newTypeSkillGroup = dialog.element.querySelector(
|
||||
"[name='new-type-skill-group']",
|
||||
)?.value;
|
||||
if (isEdit) {
|
||||
this._updateTypedSkill(
|
||||
targetSkill,
|
||||
newTypeSkillLabel,
|
||||
newTypeSkillGroup,
|
||||
);
|
||||
} else {
|
||||
this._addNewTypedSkill(newTypeSkillLabel, newTypeSkillGroup);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
_addNewTypedSkill(newSkillLabel, newSkillGroup) {
|
||||
const updatedData = foundry.utils.duplicate(this.actor.system);
|
||||
const { typedSkills } = updatedData;
|
||||
|
||||
const d = new Date();
|
||||
|
||||
const newSkillPropertyName =
|
||||
d.getFullYear().toString() +
|
||||
(d.getMonth() + 1).toString() +
|
||||
d.getDate().toString() +
|
||||
d.getHours().toString() +
|
||||
d.getMinutes().toString() +
|
||||
d.getSeconds().toString();
|
||||
|
||||
typedSkills[newSkillPropertyName] = {
|
||||
label: newSkillLabel,
|
||||
group: newSkillGroup,
|
||||
proficiency: 10,
|
||||
failure: false,
|
||||
};
|
||||
|
||||
updatedData.typedSkills = typedSkills;
|
||||
|
||||
this.actor.update({ system: updatedData });
|
||||
}
|
||||
|
||||
_updateTypedSkill(targetSkill, newSkillLabel, newSkillGroup) {
|
||||
if (
|
||||
newSkillLabel !== null &&
|
||||
newSkillLabel !== "" &&
|
||||
newSkillGroup !== null &&
|
||||
newSkillGroup !== ""
|
||||
) {
|
||||
const updatedData = foundry.utils.duplicate(this.actor.system);
|
||||
|
||||
updatedData.typedSkills[targetSkill].label = newSkillLabel;
|
||||
updatedData.typedSkills[targetSkill].group = newSkillGroup;
|
||||
|
||||
this.actor.update({ system: updatedData });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import flagUnnaturalResourceMaxUpdates from "../utils/actor-submit.js";
|
||||
import DGNPCSheet from "./npc-sheet.js";
|
||||
|
||||
/** @extends {DGNPCSheet} */
|
||||
@@ -5,19 +6,7 @@ export default class DGUnnaturalSheet extends DGNPCSheet {
|
||||
/** @override */
|
||||
async _processSubmitData(event, form, submitData, options) {
|
||||
const submittedData = foundry.utils.expandObject(submitData);
|
||||
|
||||
// we need to update max WP if POW has changed (only for unnatural, but doesn't hurt others)
|
||||
submittedData.system.wp.maxNeedsUpdate =
|
||||
submittedData.system?.statistics.pow.value !==
|
||||
this.actor.system.statistics.pow.value;
|
||||
|
||||
// we need to update max HP if STR or CON have changed (only for unnatural, but doesn't hurt others)
|
||||
submittedData.system.health.maxNeedsUpdate =
|
||||
submittedData.system?.statistics.str.value !==
|
||||
this.actor.system.statistics.str.value ||
|
||||
submittedData.system?.statistics.con.value !==
|
||||
this.actor.system.statistics.con.value;
|
||||
|
||||
flagUnnaturalResourceMaxUpdates(submittedData, this.actor);
|
||||
return super._processSubmitData(event, form, submittedData, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ export default class DGVehicleSheet extends DGActorSheet {
|
||||
initial: "gear",
|
||||
labelPrefix: "DG.Navigation.Vehicle",
|
||||
tabs: [
|
||||
{ id: "notes" },
|
||||
{ id: "gear" },
|
||||
{ id: "notes" },
|
||||
{ id: "about", icon: "fas fa-question-circle", label: "" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
export default async function preloadHandlebarsTemplates() {
|
||||
return foundry.applications.handlebars.loadTemplates([
|
||||
"systems/deltagreen/templates/dialog/modify-percentile-roll.html",
|
||||
"systems/deltagreen/templates/partials/prose-mirror-host.html",
|
||||
"systems/deltagreen/templates/actor/parts/effects-tab.html",
|
||||
"systems/deltagreen/templates/item/parts/effects-tab.html",
|
||||
"systems/deltagreen/templates/active-effect/change-row.html",
|
||||
"systems/deltagreen/templates/actor/partials/ae-backed-input.html",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @param {object} submittedData
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export default function flagUnnaturalResourceMaxUpdates(submittedData, actor) {
|
||||
const sourceStats = actor._source?.system?.statistics;
|
||||
|
||||
submittedData.system.wp.maxNeedsUpdate =
|
||||
submittedData.system?.statistics?.pow?.value !== sourceStats?.pow?.value;
|
||||
|
||||
submittedData.system.health.maxNeedsUpdate =
|
||||
submittedData.system?.statistics?.str?.value !== sourceStats?.str?.value ||
|
||||
submittedData.system?.statistics?.con?.value !== sourceStats?.con?.value;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import DGUtils from "./utility-functions.js";
|
||||
|
||||
export default function registerHandlebarsHelpers() {
|
||||
// Add Handlebars helpers
|
||||
Handlebars.registerHelper("localizeWithFallback", (value, fallbackValue) => {
|
||||
return DGUtils.localizeWithFallback(value, fallbackValue);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("concat", (...args) => {
|
||||
let outStr = "";
|
||||
for (const arg in args) {
|
||||
if (typeof args[arg] !== "object") {
|
||||
outStr += args[arg];
|
||||
}
|
||||
}
|
||||
return outStr;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("toUpperCase", (str) => {
|
||||
try {
|
||||
return str.toUpperCase();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_eq", (a, b, opts) => {
|
||||
if (a === b) {
|
||||
return opts.fn(this);
|
||||
}
|
||||
return opts.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("if_gt", (a, b, trueVal, falseVal) => {
|
||||
if (a > b) {
|
||||
return trueVal;
|
||||
}
|
||||
return falseVal;
|
||||
});
|
||||
|
||||
/** Roll visibility modes for dialog selects (v14+ CONFIG.ChatMessage.modes keys). */
|
||||
const ROLL_MESSAGE_MODE_KEYS = ["public", "gm", "blind", "self"];
|
||||
|
||||
Handlebars.registerHelper("getAvailableRollModes", () => {
|
||||
try {
|
||||
return Object.fromEntries(
|
||||
ROLL_MESSAGE_MODE_KEYS.filter(
|
||||
(key) => key in CONFIG.ChatMessage.modes,
|
||||
).map((key) => [key, CONFIG.ChatMessage.modes[key]]),
|
||||
);
|
||||
} catch (error) {
|
||||
return console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("getDefaultRollMode", () => {
|
||||
try {
|
||||
return game.settings.get("core", "messageMode");
|
||||
} catch (error) {
|
||||
return console.log(error);
|
||||
}
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("localizeWeaponSkill", (skill) => {
|
||||
let label = skill;
|
||||
|
||||
try {
|
||||
if (skill === "dex") {
|
||||
label = game.i18n.localize("DG.Attributes.dex");
|
||||
}
|
||||
if (skill === "DG.Skills.custom") {
|
||||
label = game.i18n.localize("DG.ItemWindow.Custom");
|
||||
} else {
|
||||
label = game.i18n.localize(`DG.Skills.${skill}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
return label;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("keepSanityPrivate", () => {
|
||||
let setting = false;
|
||||
|
||||
try {
|
||||
setting = game.settings.get("deltagreen", "keepSanityPrivate");
|
||||
|
||||
if (game.user.isGM) {
|
||||
setting = false;
|
||||
}
|
||||
} catch (ex) {
|
||||
setting = false;
|
||||
}
|
||||
|
||||
return setting;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("playerHasGamemasterPrivileges", () => {
|
||||
return game.user.isGM;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper("showImpossibleLandscapesContent", () => {
|
||||
let result = false;
|
||||
const setting = game.settings.get(
|
||||
"deltagreen",
|
||||
"showImpossibleLandscapesContent",
|
||||
);
|
||||
|
||||
if (game.user.isGM === true && setting === true) {
|
||||
result = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
const { HTMLField } = foundry.data.fields;
|
||||
|
||||
/**
|
||||
* Build toggled ProseMirror markup for a system schema HTML field (App V2).
|
||||
* @param {Actor|Item} document
|
||||
* @param {string} fieldPath Path within `document.system` (e.g. "physical.description")
|
||||
* @param {object} [options] Additional options passed to DataField#toInput
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function prepareProseMirrorInput(
|
||||
document,
|
||||
fieldPath,
|
||||
options = {},
|
||||
) {
|
||||
const field = document.system.schema.getField(fieldPath);
|
||||
if (!(field instanceof HTMLField)) {
|
||||
throw new Error(
|
||||
`Rich text field "system.${fieldPath}" must be an HTMLField on ${document.documentName} ${document.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const value = foundry.utils.getProperty(document.system, fieldPath) ?? "";
|
||||
const enriched =
|
||||
await foundry.applications.ux.TextEditor.implementation.enrichHTML(value, {
|
||||
async: true,
|
||||
rollData: document.getRollData?.() ?? {},
|
||||
relativeTo: document,
|
||||
});
|
||||
|
||||
const input = field.toInput({
|
||||
name: `system.${fieldPath}`,
|
||||
value,
|
||||
enriched,
|
||||
toggled: true,
|
||||
documentUUID: document.uuid,
|
||||
...options,
|
||||
});
|
||||
|
||||
return input.outerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate `context.richText` from a list of field specs.
|
||||
* @param {Actor|Item} document
|
||||
* @param {{ path: string, key: string }[]} fieldSpecs
|
||||
* @returns {Promise<Record<string, string>>}
|
||||
*/
|
||||
export async function prepareRichTextContext(document, fieldSpecs) {
|
||||
const richText = {};
|
||||
for (const { path, key } of fieldSpecs) {
|
||||
richText[key] = await prepareProseMirrorInput(document, path);
|
||||
}
|
||||
return richText;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import DG from "../config/index.js";
|
||||
import { ROLL_TARGET_FIELD_KEYS } from "../active-effect/effect-fields.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 */
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRollTargetEffectActive(effect) {
|
||||
if (effect.disabled) return false;
|
||||
if (effect.duration?.expired) return false;
|
||||
if (effect.isSuppressed) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ActiveEffect} effect
|
||||
* @returns {string}
|
||||
*/
|
||||
function getRollTargetEffectLabel(effect) {
|
||||
if (effect.getFlag(DG.ID, "exhaustion")) {
|
||||
return game.i18n.localize("DG.Physical.ExhaustionEffectName");
|
||||
}
|
||||
|
||||
if (effect.name?.trim()) return effect.name;
|
||||
|
||||
const { parent } = effect;
|
||||
if (parent?.documentName === "Item") return parent.name ?? "";
|
||||
|
||||
return effect.name ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Actor} actor
|
||||
* @param {RollTargetFieldKey} rollTargetFieldKey
|
||||
* @returns {Array<{ name: string, modifier: number }>}
|
||||
*/
|
||||
export function collectRollTargetContributions(actor, rollTargetFieldKey) {
|
||||
if (
|
||||
actor.type !== "agent" ||
|
||||
!ROLL_TARGET_FIELD_KEYS.includes(rollTargetFieldKey)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const contributions = [];
|
||||
|
||||
for (const effect of actor.appliedEffects ?? []) {
|
||||
if (isRollTargetEffectActive(effect)) {
|
||||
const change = (effect.changes ?? []).find(
|
||||
(entry) => entry.key === rollTargetFieldKey && entry.type === "add",
|
||||
);
|
||||
if (change) {
|
||||
const modifier = Number(change.value);
|
||||
if (Number.isFinite(modifier) && modifier !== 0) {
|
||||
contributions.push({
|
||||
name: getRollTargetEffectLabel(effect),
|
||||
modifier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatTargetPercent(value) {
|
||||
return `${Math.round(value)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} modifier
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatModifierPercent(modifier) {
|
||||
return `${DGUtils.formatStringWithLeadingPlus(modifier)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} base
|
||||
* @param {Array<{ name: string, modifier: number }>} contributions
|
||||
* @param {{ allowOver99?: boolean }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRollTargetBreakdownTable(
|
||||
base,
|
||||
contributions,
|
||||
{ allowOver99 = false } = {},
|
||||
) {
|
||||
const rows = [
|
||||
[
|
||||
game.i18n.localize("DG.Tooltip.RollTarget.BaseTarget"),
|
||||
formatTargetPercent(base),
|
||||
],
|
||||
];
|
||||
|
||||
for (const { name, modifier } of contributions) {
|
||||
rows.push([name, formatModifierPercent(modifier)]);
|
||||
}
|
||||
|
||||
const totalModifier = contributions.reduce(
|
||||
(sum, entry) => sum + entry.modifier,
|
||||
0,
|
||||
);
|
||||
const finalTarget = clampPercentileRollTarget(base, totalModifier, {
|
||||
allowOver99,
|
||||
});
|
||||
|
||||
rows.push([
|
||||
game.i18n.localize("DG.Tooltip.RollTarget.FinalTarget"),
|
||||
formatTargetPercent(finalTarget),
|
||||
]);
|
||||
|
||||
const body = rows
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><td>${foundry.utils.escapeHTML(
|
||||
label,
|
||||
)}</td><td>${foundry.utils.escapeHTML(value)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `<table class="dg-roll-target-tooltip"><tbody>${body}</tbody></table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRollTargetTargetLine(base) {
|
||||
return game.i18n.format("DG.Tooltip.RollTarget.Target", {
|
||||
value: Math.round(base),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} existingTooltip
|
||||
* @param {Actor} actor
|
||||
* @param {RollTargetFieldKey} rollTargetFieldKey
|
||||
* @param {number} base
|
||||
* @param {{ allowOver99?: boolean, showTargetWhenUnmodified?: boolean }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function appendRollTargetTooltipSection(
|
||||
existingTooltip,
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
options = {},
|
||||
) {
|
||||
if (actor.type !== "agent") return existingTooltip;
|
||||
|
||||
const { showTargetWhenUnmodified = true, ...tableOptions } = options;
|
||||
|
||||
const contributions = collectRollTargetContributions(
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
);
|
||||
|
||||
let appendix = "";
|
||||
if (contributions.length > 0) {
|
||||
appendix = buildRollTargetBreakdownTable(base, contributions, tableOptions);
|
||||
} else if (showTargetWhenUnmodified) {
|
||||
appendix = buildRollTargetTargetLine(base);
|
||||
}
|
||||
|
||||
if (!appendix) return existingTooltip;
|
||||
if (!existingTooltip) return appendix;
|
||||
return `${existingTooltip}<br><br>${appendix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stat and sanity roll-target tooltips for the agent sheet left bar (not skills).
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @returns {void}
|
||||
*/
|
||||
export function prepareAgentStatSanityTooltips(actor) {
|
||||
if (actor.type !== "agent") return;
|
||||
|
||||
const { system } = actor;
|
||||
|
||||
for (const [key, stat] of Object.entries(system.statistics ?? {})) {
|
||||
const base = Number(stat.x5) || 0;
|
||||
const existing = game.i18n.localize(`DG.Attributes.Tooltip.${key}`);
|
||||
stat.tooltip = appendRollTargetTooltipSection(
|
||||
existing,
|
||||
actor,
|
||||
"system.rollTarget.statistics",
|
||||
base,
|
||||
{ allowOver99: base > 99 },
|
||||
);
|
||||
}
|
||||
|
||||
const { sanity } = system;
|
||||
if (sanity) {
|
||||
const existing = `${game.i18n.localize("DG.Tooltip.CurrentSanityPartOne")}${
|
||||
sanity.currentBreakingPoint
|
||||
}${game.i18n.localize("DG.Tooltip.CurrentSanityPartTwo")}`;
|
||||
const base = Number(sanity.value) || 0;
|
||||
sanity.tooltip = appendRollTargetTooltipSection(
|
||||
existing,
|
||||
actor,
|
||||
"system.rollTarget.sanity",
|
||||
base,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
const AGENT_SKILL_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* Reorders a flat array into column-major order for grid display.
|
||||
*
|
||||
* @param {object[]} arr
|
||||
* @param {number} numCols
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function reorderForColumnSorting(arr, numCols) {
|
||||
const reordered = new Array(arr.length);
|
||||
|
||||
const baseRowCount = Math.floor(arr.length / numCols);
|
||||
const extraColumns = arr.length % numCols;
|
||||
|
||||
const colHeights = new Array(numCols).fill(baseRowCount);
|
||||
for (let i = 0; i < extraColumns; i++) {
|
||||
colHeights[i] += 1;
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
|
||||
for (let col = 0; col < numCols; col++) {
|
||||
const rowCount = colHeights[col];
|
||||
|
||||
for (let row = 0; row < rowCount; row++) {
|
||||
const newIndex = numCols * row + col;
|
||||
|
||||
if (newIndex < arr.length) {
|
||||
reordered[newIndex] = arr[index];
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Column-major packing (matches legacy reorderForColumnSorting + 3-column grid).
|
||||
*
|
||||
* @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 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 columns;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/** @typedef {import("../profession/index.js").ProfessionSkillRef} ProfessionSkillRef */
|
||||
/** @typedef {"dialog" | "sheet"} SkillTooltipContext */
|
||||
|
||||
import { appendRollTargetTooltipSection } from "./roll-target-tooltip.js";
|
||||
|
||||
/**
|
||||
* Descriptive skill tooltip only (no roll target or 0% appendix).
|
||||
*
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getSkillDescriptiveTooltip(ref) {
|
||||
if (ref.kind === "fixed") {
|
||||
const key = `DG.Skills.Tooltip.${ref.key}`;
|
||||
const localized = game.i18n.localize(key);
|
||||
return localized === key ? "" : localized;
|
||||
}
|
||||
|
||||
const key = `DG.TypeSkills.Tooltip.${ref.group}`;
|
||||
const localized = game.i18n.localize(key);
|
||||
return localized === key ? "" : localized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skill tooltip for character creation or the agent sheet.
|
||||
*
|
||||
* @param {SkillTooltipContext} context
|
||||
* @param {Actor | null} actor
|
||||
* @param {ProfessionSkillRef} ref
|
||||
* @param {{ proficiency?: number }} [options]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSkillTooltip(
|
||||
context,
|
||||
actor,
|
||||
ref,
|
||||
{ proficiency = 0 } = {},
|
||||
) {
|
||||
let tooltip = getSkillDescriptiveTooltip(ref);
|
||||
|
||||
if (ref.kind === "fixed" && !tooltip) {
|
||||
tooltip = game.i18n.localize(`DG.Skills.Tooltip.${ref.key}`);
|
||||
}
|
||||
|
||||
if (context === "dialog") return tooltip;
|
||||
|
||||
if (ref.kind === "fixed" && !proficiency) {
|
||||
tooltip = tooltip.concat(
|
||||
"<br><br>",
|
||||
game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"),
|
||||
);
|
||||
}
|
||||
|
||||
if (actor?.type === "agent") {
|
||||
tooltip = appendRollTargetTooltipSection(
|
||||
tooltip,
|
||||
actor,
|
||||
"system.rollTarget.allSkills",
|
||||
Number(proficiency) || 0,
|
||||
{ showTargetWhenUnmodified: false },
|
||||
);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent sheet special-training row tooltip (attribute-based, not ProfessionSkillRef).
|
||||
*
|
||||
* @param {Actor} actor
|
||||
* @param {string} attributeTooltipKey i18n key for the underlying attribute/skill
|
||||
* @param {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"} rollTargetFieldKey
|
||||
* @param {number} base
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildAgentSpecialTrainingTooltip(
|
||||
actor,
|
||||
attributeTooltipKey,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
) {
|
||||
return appendRollTargetTooltipSection(
|
||||
game.i18n.localize(attributeTooltipKey),
|
||||
actor,
|
||||
rollTargetFieldKey,
|
||||
base,
|
||||
{
|
||||
allowOver99:
|
||||
rollTargetFieldKey === "system.rollTarget.statistics" && base > 99,
|
||||
showTargetWhenUnmodified: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client skillTooltipDisplay setting (hover / hoverShift / never).
|
||||
*
|
||||
* @param {HTMLElement} root
|
||||
* @returns {void}
|
||||
*/
|
||||
export function applySkillTooltipDisplayMode(root) {
|
||||
const mode = game.settings.get("deltagreen", "skillTooltipDisplay");
|
||||
|
||||
if (mode !== "hoverShift" && mode !== "never") return;
|
||||
|
||||
const nodes = root.querySelectorAll("[data-tooltip],[title]");
|
||||
|
||||
if (mode === "never") {
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((el) => {
|
||||
if (el.dataset.shiftTooltipInstalled === "true") return;
|
||||
|
||||
let html = el.getAttribute("data-tooltip");
|
||||
let isHtml = true;
|
||||
|
||||
if (!html) {
|
||||
const title = el.getAttribute("title");
|
||||
if (title) {
|
||||
html = foundry.utils.escapeHTML(title);
|
||||
isHtml = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!html) return;
|
||||
|
||||
el.removeAttribute("data-tooltip");
|
||||
el.removeAttribute("title");
|
||||
el.dataset.shiftTooltipInstalled = "true";
|
||||
|
||||
const opts = isHtml ? { html } : { text: html };
|
||||
|
||||
const show = () => game.tooltip.activate(el, opts);
|
||||
const hide = () => game.tooltip.deactivate();
|
||||
|
||||
const onKey = (ev) => {
|
||||
if (ev.key !== "Shift") return;
|
||||
if (!document.body.contains(el)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
return;
|
||||
}
|
||||
if (ev.type === "keydown") show();
|
||||
else hide();
|
||||
};
|
||||
|
||||
const onEnter = (ev) => {
|
||||
if (ev.shiftKey) show();
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
const onLeave = () => {
|
||||
hide();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("keyup", onKey);
|
||||
};
|
||||
|
||||
el.addEventListener("pointerenter", onEnter, { passive: true });
|
||||
el.addEventListener("pointerleave", onLeave, { passive: true });
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user