diff --git a/.github/workflows/format-lint-check.yml b/.github/workflows/format-lint-check.yml index b4f59c4..6e238d6 100644 --- a/.github/workflows/format-lint-check.yml +++ b/.github/workflows/format-lint-check.yml @@ -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 diff --git a/.github/workflows/publish-dev-release.yml b/.github/workflows/publish-dev-release.yml index e00daf7..5ba34d5 100644 --- a/.github/workflows/publish-dev-release.yml +++ b/.github/workflows/publish-dev-release.yml @@ -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: diff --git a/.github/workflows/publish-prod-release.yml b/.github/workflows/publish-prod-release.yml index a9337b7..d65922c 100644 --- a/.github/workflows/publish-prod-release.yml +++ b/.github/workflows/publish-prod-release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 8e5ea01..7f2d7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ deltagreen.lock # Ignore npm modules node_modules + +# Sass build output (source lives in scss/) +css/*.css +css/*.css.map diff --git a/.prettierignore b/.prettierignore index e7492be..b5da478 100644 --- a/.prettierignore +++ b/.prettierignore @@ -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 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 31fe31f..4eddbc4 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -5,7 +5,8 @@ "esbenp.prettier-vscode", "monosans.djlint", "DavidAnson.vscode-markdownlint", - "EditorConfig.EditorConfig" + "EditorConfig.EditorConfig", + "syler.sass-indented" ], "unwantedRecommendations": [] } diff --git a/CHANGELOG.md b/CHANGELOG.md index acbe751..ee74296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ - [#296](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/296) - Remove adaptation checks if not adapted. - [#281](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/281) - Add tooltips to checkboxes in adaptation area. - [#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 +- [#396](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/issues/396) - Fixed typed skill dialog when editing existing skill + +### **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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1bb1662..0e47acd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. @@ -454,6 +473,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. @@ -473,8 +494,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: diff --git a/README.md b/README.md index 60ee88e..581b88d 100644 --- a/README.md +++ b/README.md @@ -35,4 +35,4 @@ If there is a need to manually install the system, go to _Game Systems_ and clic ## System Documentation -You may find documentation for the system in [this folder](https://github.com/TheLastScrub/delta-green-foundry-vtt-system/blob/master/documentation/home.md). +You may find documentation for the system in [this folder](https://github.com/deltagreen-foundryvtt/delta-green-foundry-vtt-system/blob/master/documentation/home.md). diff --git a/assets/img/green_delta_icon.webp b/assets/img/green_delta_icon.webp new file mode 100644 index 0000000..6df7a71 Binary files /dev/null and b/assets/img/green_delta_icon.webp differ diff --git a/css/.gitkeep b/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/css/deltagreen.css b/css/deltagreen.css deleted file mode 100644 index c4f44a8..0000000 --- a/css/deltagreen.css +++ /dev/null @@ -1,1470 +0,0 @@ -@import url("https://fonts.googleapis.com/css2?family=Martel:wght@400;800&family=Roboto:wght@300;400;500&display=swap"); -@import url("https://fonts.googleapis.com/css2?family=Didact+Gothic"); - -@font-face { - font-family: "atwriter"; - src: url("../assets/font/another-typewriter/atwriter.ttf") format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */ -} - -@font-face { - font-family: TypeWriterCondensed; - src: url("../assets/font/typewriter-condensed/typewcond_regular.otf") - format("opentype"); -} - -@font-face { - font-family: TypeWriterCondensed; - font-weight: bold; - src: url("../assets/font/typewriter-condensed/typewcond_bold.otf") - format("opentype"); -} - -@font-face { - font-family: TypeWriterCondensed; - font-weight: 600; - src: url("../assets/font/typewriter-condensed/typewcond_demi.otf") - format("opentype"); -} - -@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 */ -} - -@font-face { - font-family: "PublicSans"; - src: url("../assets/font/public-sans/PublicSans-VariableFont_wght.ttf") - format("truetype"); /* Chrome 4+, Firefox 3.5, Opera 10+, Safari 3—5 */ -} - -/* Global styles */ - -.deltagreen section.window-content { - scrollbar-color: #215112 #111 !important; - padding: 0.5rem; - color: black; /* Override for when in Foundry's dark mode */ - - input[type="checkbox"]:checked::before { - color: black; /* Override for when in Foundry's dark mode */ - } - - input[type="text"], - input[type="number"], - input[type="password"] { - height: unset; - line-height: 1.5rem; - } -} - -/* Character Sheet Style Options */ - -.window-content.outlaw-style, -.window-content.cowboy-style { - background: url("../assets/img/StainedPlainPaper10b.webp") !important; - background-size: 100% !important; -} - -.window-content.program-style { - background: url("../assets/img/StainedPlainPaper10a.webp") !important; - background-size: 100% !important; -} - -.cowboy-style { - font-family: "SpecialElite", sans-serif !important; -} - -.outlaw-style, -.program-style { - font-family: "PublicSans", sans-serif !important; -} - -.outlaw-style .cowboy-style .tabs [data-action="tab"] { - background: rgba(173, 151, 101, 0.3); -} - -.program-style .tabs [data-action="tab"] { - background: rgba(138, 136, 133, 0.3); -} - -.old-paper-background { - background: url("../assets/img/StainedPlainPaper10.webp") !important; -} - -.default-parchment-background { - background: url("/ui/parchment.jpg") repeat !important; -} - -.typewriter-condensed-font { - font-family: "TypeWriterCondensed", sans-serif !important; -} - -.martel-font { - font-family: "Martel", sans-serif !important; -} - -.signika-font { - font-family: "Signika", sans-serif !important; -} - -.atwriter-font { - font-family: "atwriter", sans-serif !important; -} - -.special-elite-font { - font-family: "SpecialElite", sans-serif !important; -} - -.public-sans-font { - font-family: "PublicSans", sans-serif !important; -} - -.rollable:hover, -.rollable:focus { - color: #000; - text-shadow: 0 0 5px #374e13; - cursor: pointer; -} - -.not-rollable { - color: #999999; -} - -input.breaking-point-hit { - color: rgb(255, 255, 255); - background-color: rgb(117, 0, 0); - font-weight: bold; -} - -.dg-red { - color: rgb(117, 0, 0); -} - -.bottom-margin-5px { - margin-bottom: 5px !important; -} - -.grid-1col { - display: grid; - grid-column: span 1 / span 1; - gap: 3px; -} - -.grid, -.grid-2col { - display: grid; - grid-column: span 2 / span 2; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 3px; - margin: 0px 0; - padding: 0; -} - -.grid-3col { - grid-column: span 3 / span 3; - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.grid-4col { - grid-column: span 4 / span 4; - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.grid-5col { - grid-column: span 5 / span 5; - grid-template-columns: repeat(5, minmax(0, 1fr)); -} - -.grid-6col { - grid-column: span 5 / span 5; - grid-template-columns: repeat(5, minmax(0, 1fr)); -} - -.grid-7col { - grid-column: span 7 / span 7; - grid-template-columns: repeat(7, minmax(0, 1fr)); -} - -.grid-8col { - grid-column: span 8 / span 8; - grid-template-columns: repeat(8, minmax(0, 1fr)); -} - -.grid-9col { - grid-column: span 9 / span 9; - grid-template-columns: repeat(9, minmax(0, 1fr)); -} - -.grid-10col { - grid-column: span 10 / span 10; - grid-template-columns: repeat(10, minmax(0, 1fr)); -} - -.grid-11col { - grid-column: span 11 / span 11; - grid-template-columns: repeat(11, minmax(0, 1fr)); -} - -.grid-12col { - grid-column: span 12 / span 12; - grid-template-columns: repeat(12, minmax(0, 1fr)); -} - -/* If the user wishes to sort skills by column, we need to -explicitly define a number of rows, rather than columns. */ -.grid-13row { - grid-template-rows: repeat(13, minmax(0, auto)) !important; - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)) !important; - grid-auto-flow: column !important; -} - -.flex-group-center, -.flex-group-left, -.flex-group-right { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - text-align: center; - padding: 2px; -} - -.flex-thin-border { - border: 1px solid #999; - border-radius: 2px; - border-left-color: darkslategray; - border-left-width: 4px; -} - -/* Make sure Special Training boxes are the same height - as all other skill boxes */ -.flex-thin-border.special-training-box { - min-height: 27px; -} - -.flex-mid-border { - border: 1.5px solid rgb(0, 0, 0); - border-radius: 2px; -} - -.flex-group-left { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: left; - padding: 0; -} - -.flex-group-right { - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; - text-align: right; -} - -.flex-center { - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - text-align: center; -} - -.flex-between { - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.horizontal-flex-column { - display: flex; - flex-direction: column; -} - -.flex-left { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: left; -} - -.deltagreen .sheet-header { - -webkit-box-flex: 0; - -ms-flex: 0 0 400px; - flex: 0 0 auto; - overflow: hidden; - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; - -ms-flex-wrap: wrap; - flex-wrap: wrap; - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - margin-bottom: 8px; -} - -.deltagreen .sheet-header .profile-img { - height: 100px; - flex: 0 0 100px; - -webkit-box-flex: 0; - -ms-flex: 0 0 100px; - - cursor: var(--cursor-pointer); /* Variable name from Foundry */ - - border: 1px solid black; - margin-right: 5px; -} - -.deltagreen .sheet-header .header-fields { - -webkit-box-flex: 1; - -ms-flex: 1; - flex: 1; -} - -.deltagreen .sheet-header h1.charname { - height: 55px; - font-family: inherit; - font-size: 2rem; - padding: 0px; - border-bottom: 0; - margin-bottom: 0; -} - -.deltagreen .sheet-header h1.charname input { - width: 100%; - height: 100%; - margin: 0; -} - -.deltagreen .resource-label { - font-weight: bold; -} - -.sheet nav.sheet-tabs { - border-bottom: transparent !important; - border-top: transparent !important; -} - -.deltagreen .tabs { - height: 1.5rem; - line-height: 1.5rem; - text-align: center; - vertical-align: middle; - margin-bottom: 0px !important; - padding-bottom: 0px !important; -} - -.deltagreen .tabs [data-action="tab"] { - font-weight: bold; - color: inherit; - vertical-align: middle !important; - height: inherit; - line-height: inherit; - padding-inline: 5px; - border-radius: 5px 5px 0px 0px; - border-color: #000; - border: gray; - border-width: 1px 1px 0px 1px; - border-style: solid; -} - -.deltagreen .tabs [data-action="tab"].active { - text-shadow: none; - font-style: italic; - border: #000; - border-width: 2px 2px 0px 2px; - border-style: solid; -} - -.deltagreen .tab { - height: 100%; - overflow-y: auto; - - border-color: #000; - border-width: 2px; - border-style: solid; - border-radius: 3px; - - padding: 5px; -} - -.deltagreen .program-style .tabs [data-action="tab"].active { - background-color: #000000; - color: #ffffff; -} - -.deltagreen .cowboy-style .tabs [data-action="tab"].active { - background-color: rgba(156, 115, 18, 0.3); - color: #000000; -} - -.deltagreen .items-list { - list-style: none; - /*margin: 7px 0; */ - padding: 0; - overflow-y: auto; -} - -.deltagreen .items-list .item-header { - font-weight: bold; - display: flex; - justify-content: center; - align-items: center; -} - -.deltagreen .items-list .item { - min-height: 30px; - line-height: 24px; - padding: 3px 0; - border-bottom: 1px solid #bbb; -} - -.deltagreen .items-list .item .item-image { - -webkit-box-flex: 0; - -ms-flex: 0 0 24px; - flex: 0 0 24px; - margin-right: 5px; -} - -.deltagreen .items-list .item .item-bond-damage { - -webkit-box-flex: 0; - -ms-flex: 0 0 24px; - flex: 0 0 24px; - margin: 0; - align-self: center; - justify-self: center; -} - -.deltagreen .items-list .item img { - display: block; -} - -.deltagreen .items-list .item-name { - display: flex; - align-content: center; - - word-wrap: break-word; - - margin: 0; -} - -.deltagreen .items-list .item-controls { - -webkit-box-flex: 0; - -ms-flex: 0 0 86px; - flex: 0 0 86px; - text-align: right; -} - -.deltagreen .skill-and-training-controls { - flex: 0 0 35px; -} - -.deltagreen .action-pill { - padding: 2px; - border-radius: 2.5px; - font-size: 0.8rem; - font-weight: bold; - background-color: rgba(0, 0, 0, 0.185); -} - -.deltagreen[id^="dg-settings"] { - .window-content > div.flexcol { - gap: 1rem; - } - - .form-group label { - flex: unset; - } -} - -a.btn-tiny { - height: 26px; - margin-left: 5px; - margin-right: 5px; - font-size: 13px; - padding-top: 1px; - padding-bottom: 1px; - padding-right: 5px; - padding-left: 5px; - - text-decoration: none; - background-color: #eeeeee; - color: #333333; - - border-radius: 4px; - line-height: 25px; - border: 1px solid #333333; - min-width: 20px; - text-align: center; -} - -span.left-aligned-label { - justify-content: left; - margin-left: 5px; -} - -span.max-attribute-value-label { - border: 1px solid rgb(122, 121, 113); - border-radius: 3px; - padding-top: 3px; - padding-bottom: 1px; - padding-left: 3px; - padding-right: 3px; - height: 26px; -} - -.deltagreen .skill-label { - margin: 2px; - align-content: flex-start; -} - -.deltagreen .skill-type-label { - margin: 2px; -} - -input.percentile-skill-input { - flex: 0 0 40px; - margin: 0 5px; - height: 1.2rem; - align-self: center; - text-align: center; -} - -input.checkbox-skill-input { - flex: 0 0 20px; - line-height: 10px; - width: 1px; -} - -input.checkbox-skill-input:disabled { - visibility: hidden; -} - -.deltagreen .sheet-header .header-top { - margin-bottom: 5px; -} - -.deltagreen .sheet-header .charname input { - /* font-family: "SpecialElite", sans-serif; */ -} - -.deltagreen .sheet-header h2.profession { - font-family: inherit; - font-size: 1.5rem; - border-bottom: 0; - margin-top: 5px; - margin-bottom: 0; -} - -.deltagreen .sheet-header h2.profession input { - width: 100%; - height: 40px; - margin: 0; -} - -div .simple-centered-flex-div { - display: flex; - align-items: center; -} - -div input.fill-remaining-space { - flex: 1; - margin: 1px; - text-align: left; -} - -div span.bio-grid-label { - margin: 1px; - text-align: left; -} - -.bio-grid-2col { - display: grid; - grid-template-columns: 100px auto; - align-items: center; - padding-bottom: 10px; - row-gap: 10px; -} - -.bio-grid-top-border { - border-top: 0px solid #aaa; - padding-top: 10px; -} - -.bio-grid-5col { - display: grid; - grid-template-columns: 100px 100px 20px 120px auto; - align-items: center; - padding-bottom: 10px; -} - -.bonds-grid-3col { - display: grid; - grid-template-columns: auto 50px max-content; - align-items: center; - padding-bottom: 10px; -} - -.centered { - text-align: center !important; -} - -.breaking-point-grid-3col { - display: grid; - grid-template-columns: 20% 70px 65px; - border-top: 0px solid #aaa; - margin-bottom: 10px; - border-bottom: 1px solid #aaa; - align-content: center; - justify-content: left; - align-items: center; - padding-bottom: 10px; - padding-top: 10px; -} - -.adaptation-grid-5col { - display: grid; - grid-template-columns: 30% 50px 50px 50px 200px; - margin-top: 10px; - margin-bottom: 10px; - align-content: center; - justify-content: left; - align-items: center; - padding-bottom: 5px; - border-bottom: 0px solid #aaa; -} - -.adapted-label { - font-weight: bold; - font-style: italic; - color: rgb(139, 0, 0); -} - -.weapon-grid-2col { - display: grid; - grid-template-columns: 48% 48%; - margin-top: 10px; - margin-bottom: 5px; - justify-content: left; - align-items: flex-end; - padding: 0px; - column-gap: 4%; -} - -.centered-item-property { - text-align: center; -} - -.bolded-label { - font-weight: bold; -} - -.resource .labeled-resource-checkbox { - vertical-align: middle; - margin-bottom: 0; -} - -.resource .first-aid-label { - vertical-align: bottom; -} - -div .motivation-section { - border-bottom: 1px solid #aaa; - margin-bottom: 10px; -} - -div .bottom-left-justified-resource { - vertical-align: bottom; - text-align: left; -} - -div .first-aid-received-section { - display: grid; - grid-template-columns: 40% 70px; - border-bottom: 1px solid #aaa; - margin-bottom: 10px; -} - -div .exhausted-section { - display: grid; - grid-template-columns: 40% 70px; - border-bottom: 1px solid #aaa; - margin-bottom: 10px; -} - -.exhausted-section input[type="number"] { - width: 75px; - - color: rgb(139, 0, 0); - text-align: center; - - background-color: rgba(0, 0, 0, 0.05); - - border: 1px solid rgb(122, 121, 113); - border-radius: 3px; - margin-left: 10px; - margin-bottom: 5px; -} - -.resource .exhaustion-label { - vertical-align: middle; -} - -.bonds-section-header { - /* border-top: 1px solid #AAA; */ - width: 100%; - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 5px; -} - -.about-section-header { - border-top: 1px solid #aaa; - width: 100%; - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 5px; -} - -.tab.about { - .dg-about-links { - gap: 0.5rem; - margin-bottom: 1rem; - - a { - display: inline-flex; - align-items: center; - gap: 0.5rem; - width: fit-content; - max-width: 100%; - - i { - flex: 0 0 auto; - } - - span { - flex: 1 1 auto; - line-height: 1.4; - text-align: left; - } - } - } - - .dg-about-legal { - p { - margin: 0.5rem 0; - } - } -} - -textarea.wounds { - resize: none; - height: 100px; - width: 100%; -} - -.resource-inner-grid-3col { - display: grid; - grid-template-columns: 35% 30% 35%; - align-items: center; - text-align: center; -} - -.resource-outer-grid-1col { - display: grid; - grid-template-columns: 100%; - align-items: center; - justify-content: center; - border: 1.5px solid #000000; - padding: 5px; - grid-gap: 3px; - text-align: center; - border-radius: 2px; -} - -div.max-resource-value { - border: 1px solid rgb(122, 121, 113); - border-radius: 3px; - height: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.physical-stats-outer-grid-3col { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - align-items: center; - justify-content: center; - text-align: center; - padding: 5px; - grid-gap: 5px; -} - -.physical-stats-and-feature { - border-radius: 2px; - border: 1.5px solid #000000; -} - -.attributes-wrapper { - margin-block-end: 12px; -} - -div.horizontal-bar-section-divider { - display: grid; - grid-template-columns: 100%; - justify-content: center; - align-items: center; - text-align: center; - - margin-bottom: 3px; - - background: #000; - color: #fff; - height: 30px; - - border-radius: 3px 3px 3px 3px; -} - -div.horizontal-bar-section-divider.skills-header { - display: grid; - grid-template-columns: repeat(3, 1fr); - justify-content: center; - align-items: center; - text-align: center; - - margin-bottom: 3px; - - background: #000; - color: #fff; - height: 30px; - - border-radius: 3px 3px 3px 3px; -} - -.horizontal-bar-section-divider .skills-header-title { - grid-column: 2; -} - -.horizontal-bar-section-divider .toggle-untrained { - grid-column: 3; - padding-right: 0.5rem; - justify-self: end; -} - -div.horizontal-bar-section-header { - display: grid; - grid-template-columns: 100%; - justify-content: center; - align-items: center; - text-align: center; - - margin-bottom: 3px; - - background: #000; - color: #fff; - height: 50px; -} - -.physical-description-label { - font-weight: bold; -} - -.notes-wrapper { - height: 100%; - margin-block-start: 12px; -} - -div.motivation-editor-container div.editor { - height: 400px; - min-height: 100% !important; -} - -.deltagreen .editor-host { - align-items: stretch; - height: 100%; -} - -.deltagreen .editor-host .editor { - height: 100% !important; -} - -.deltagreen .editor .editor-container { - height: 100% !important; -} - -/* I think all the stuff below is now obsolete -.editor-host .tox .tox-editor-container { - background: none !important; -} - -.deltagreen .editor-host .tox { - height: 100% !important; - display: block; -} - -.deltagreen .editor-host .tox-tinymce { - height: 100% !important; - display: block; -} - -.deltagreen .editor-host .tox .tox-edit-area { - position: inherit; - min-height: 20px; - height: 300px; -} - -.deltagreen .tox-editor-header { - background: none !important; -} - -.deltagreen .tox .tox-edit-area { - padding: 0 8px; -} -*/ - -/* - Item Sheet Common CSS -*/ - -.item-img { - -webkit-box-flex: 0; - -ms-flex: 0 0 100px; - flex: 0 0 100px; - height: 100px; - border: 0px solid black; -} - -input.item-sheet-short-input { - width: 75px; - margin-right: 5px; - margin-left: 5px; - text-align: center !important; -} - -.item-grid-button-row { - vertical-align: middle !important; - line-height: inherit; - padding: 5px 0px 0px 10px; -} - -/* - Bonds Item Sheet -*/ - -.bond-sheet-grid-2col { - display: grid; - grid-template-columns: 25% 75%; - align-items: center; - justify-content: center; - text-align: center; - padding-left: 5px; - grid-gap: 5px; -} - -.bond-sheet-horizontal-grid-2col { - display: grid; - grid-template-columns: 150px auto; - align-items: center; - text-align: left; - padding-bottom: 10px; - column-gap: 10px; - row-gap: 10px; - padding-right: 5px; -} - -/* - Motivation Item Sheet -*/ - -.motivation-sheet-horizontal-grid-2col { - display: grid; - grid-template-columns: 50% 50%; - align-items: center; - justify-content: center; - text-align: left; - padding-left: 5px; - grid-gap: 5px; -} - -/* - Armor Item Sheet -*/ - -.armor-sheet-grid-2col { - display: grid; - grid-template-columns: 25% 75%; - align-items: center; - justify-content: center; - text-align: center; - grid-gap: 5px; - padding-bottom: 10px; - padding-left: 5px; -} - -.armor-sheet-horizontal-grid-2col { - display: grid; - grid-template-columns: 100px auto; - align-items: center; - text-align: left; - column-gap: 10px; - row-gap: 10px; - padding-bottom: 10px; - padding-right: 5px; -} - -.armor-sheet-secondary-grid-2col { - display: grid; - grid-template-columns: 25% 75%; - align-items: center; - justify-content: left; - text-align: left; - grid-gap: 5px; - padding-bottom: 10px; -} - -/* - Gear Item Sheet -*/ - -.gear-sheet-grid-2col { - display: grid; - grid-template-columns: 25% 75%; - align-items: center; - justify-content: center; - text-align: center; - grid-gap: 5px; - padding-left: 5px; -} - -.gear-sheet-horizontal-grid-2col { - display: grid; - grid-template-columns: 100px auto; - align-items: center; - text-align: left; - column-gap: 10px; - row-gap: 10px; - padding-bottom: 10px; - padding-right: 5px; -} - -/* - Weapon Item Sheet -*/ - -.weapon-sheet-grid-2col { - display: grid; - grid-template-columns: 25% 75%; - align-items: center; - justify-content: center; - text-align: center; - grid-gap: 5px; - padding-left: 5px; -} - -.weapon-sheet-horizontal-grid-2col { - display: grid; - grid-template-columns: 100px auto; - align-items: center; - text-align: left; - column-gap: 10px; - row-gap: 10px; - padding-bottom: 10px; - padding-right: 5px; -} - -.weapon-sheet-outer-grid-2col { - display: grid; - grid-template-columns: 60% 40%; - align-items: center; - justify-content: center; - text-align: center; - padding-left: 5px; - padding-right: 5px; - column-gap: 10px; - row-gap: 5px; -} - -.weapon-sheet-horizontal-grid-left-2col { - display: grid; - grid-template-columns: 50% 50%; - align-items: center; - text-align: left; - padding-bottom: 10px; - column-gap: 10px; - row-gap: 10px; - padding-right: 10px; -} - -.weapon-sheet-horizontal-grid-right-2col { - display: grid; - grid-template-columns: 60% 40%; - align-items: center; - text-align: left; - padding-bottom: 10px; - column-gap: 10px; - row-gap: 10px; - padding-right: 10px; -} - -.weapon-sheet-secondary-grid-2col { - display: grid; - grid-template-columns: 40% 60%; - align-items: center; - justify-content: left; - text-align: left; - grid-gap: 5px; - padding-right: 5px; -} - -/* - Tome Item Sheet -*/ - -.tome-sheet-horizontal-grid-2col { - display: grid; - grid-template-columns: 100px auto; - align-items: center; - text-align: left; - column-gap: 10px; - row-gap: 10px; - padding-bottom: 10px; - padding-right: 5px; -} - -.tome-sheet-handler-info-horizontal-grid-2col { - display: grid; - grid-template-columns: max-content auto; - align-items: center; - text-align: left; - column-gap: 10px; - row-gap: 10px; - padding-bottom: 10px; - padding-right: 5px; -} - -/* - Unnatural Character Sheet Specific Classes -*/ - -.unnatural-notes-section { - display: grid; - grid-template-columns: 100%; - align-items: left; - justify-content: left; - border: 1px solid #999; - padding: 5px; - grid-gap: 3px; - text-align: left; -} - -.skill-util-buttons { - margin: 5px; - - .typed-skill-add { - margin-right: 5px; - } -} - -a[data-action="applySkillImprovements"] { - float: right; -} - -.yellow-king { - color: #a0912f; -} - -.yellow-sign { - font-weight: bold; - text-shadow: 1px 1px #313a00; -} - -.yellow-king-rotate1 { - transform: rotate(3deg); -} - -.yellow-king-rotate4 { - transform: rotate(4deg); -} - -.yellow-king-rotate2 { - transform: rotate(-1deg); -} - -.yellow-king-rotate3 { - transform: rotate(5deg); -} - -.yellow-king-grid-9col { - display: grid; - grid-template-columns: max-content 5px max-content max-content 20px max-content max-content 10px 35px; - align-items: center; - padding-bottom: 10px; -} - -.yellow-king-grid-5col { - display: grid; - grid-template-columns: min-content 275px 20px min-content auto; - align-items: center; - padding-bottom: 10px; -} - -.yellow-king-label { - margin-right: 10px; -} - -.yellow-king input, -.yellow-king select, -.yellow-king textarea { - color: #a0912f; - font-weight: bold; -} - -.yellow-king textarea:focus, -.yellow-king input:focus { - color: #a0912f; -} - -.top-div-border { - border-top: 1px solid #aaa; - margin-bottom: 10px; - padding-bottom: 10px; - padding-top: 10px; -} - -.stat-block-input { - min-height: 300px; - resize: none; -} - -.weapon-name-flexcontainer { - display: flex; - justify-content: flex-start; - flex-direction: row; - column-gap: 10px; -} - -.weapon-name-flexitem { - flex: content; - align-self: flex-start; - text-align: left; -} - -.armor-piercing-chat-card-img { - border-radius: 10%; - height: 20px !important; - width: 20px !important; - margin: 0px; - vertical-align: text-bottom; -} - -/* - Custom UI Styling Elements -*/ -:root :not(.no-theme) { - --color-shadow-primary: #374e13; - --color-shadow-highlight: #406539; - --color-border-highlight: #406539; - --color-border-highlight-alt: #374e13; - --color-underline-header: #406539; - --color-text-dark-header: #406539; - scrollbar-color: var(--color-text-dark-header) #111; -} - -#pause img { - content: url(../assets/img/DG_Pause_Icon.webp); -} - -#interface:not(.no-theme) * { - --color-shadow-primary: #215112 !important; - --color-shadow-highlight: #215112 !important; - --color-border-highlight: #215112 !important; - --color-border-highlight-alt: #215112 !important; - --color-underline-header: #215112 !important; - --color-text-dark-header: #215112 !important; - scrollbar-color: var(--color-text-dark-header) #111 !important; -} - -#interface:not(.theme-light) * { - --control-border-color: #215112; - --control-hover-border-color: #215112; -} - -/* The logo is defunct as of v13 */ -#logo { - content: url("../assets/img/Delta_Green_Logo.webp"); - width: 90px; - height: auto; - padding-top: 10px; - padding-bottom: 10px; -} - -.target-proficiency-mod { - color: #247c14; -} - -.rollback-section { - order: 99; - display: flex; - margin-top: 5px; - align-items: center; - justify-content: center; - - > i { - margin: 5px; - } -} - -[data-action="rollback-skill-failure-state"] { - align-items: center; - display: inline-flex; - justify-content: center; - min-height: unset; - width: 20px; - height: 20px; - margin-left: 5px; - font-size: 12px; -} - -.strike { - text-decoration: line-through; -} - -.actor-sheet-v2 { - .window-content { - display: grid; - grid-template-columns: 220px 1fr; - grid-template-rows: auto; - width: 100%; - height: 100%; - } - - .left-bar { - grid-column: 1; - grid-row: 1 / -1; - padding: 10px; - } - - .window-content > :not(.left-bar) { - grid-column: 2; - grid-row: 1; - width: 100%; - box-sizing: border-box; - padding-right: 0; - margin-right: 0; - } - - .profile-img { - width: 100%; - height: auto; - } - - .physical-stats-outer-grid-3col { - display: contents; - } - - .physical-stats-outer-grid-3col, - .resources { - font-size: 12px; - } - - .resource-inner-grid-3col input { - padding: 1px; - } - - .tab.active { - padding-top: 20px; - } - - .breaking-point { - justify-content: space-between; - display: flex; - align-items: center; - - input { - width: 40px; - } - } - - .stats { - input { - margin: 1px; - } - } - - .luck { - margin-top: 5px; - } - - .horizontal-bar-section-divider.skills-header { - display: flex; - } -} - -.stats-wrapper { - position: relative; -} - -/* Edit icon */ -.stats-edit-btn { - position: absolute; - top: -10px; - right: 0; - - width: 24px; - height: 24px; - - display: flex; - align-items: center; - justify-content: center; - - border-radius: 50%; - background: var(--color-border-dark, #444); - color: #fff; - - font-size: 12px; - - cursor: pointer; - opacity: 0; - transition: - opacity 0.15s ease, - transform 0.15s ease; -} - -/* Show on hover of whole stats block */ -.stats-wrapper:hover .stats-edit-btn { - opacity: 1; - transform: scale(1.05); -} - -/* Hover feedback */ -.stats-edit-btn:hover { - background: var(--color-border-highlight, #c9a227); -} - -.edit-stat-form { - label { - color: var(--color-text-primary); - } -} - -.physical-stats-outer-grid-2col { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: center; - justify-content: center; - text-align: center; - padding: 5px; - grid-gap: 5px; -} diff --git a/css/editor.css b/css/editor.css deleted file mode 100644 index b4a32ee..0000000 --- a/css/editor.css +++ /dev/null @@ -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; -} diff --git a/lang/ca.json b/lang/ca.json index 53045db..8059236 100644 --- a/lang/ca.json +++ b/lang/ca.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "COGNOM, NOM (ALIES O NOM EN CLAU EN CAS DE SER NECESSARI)", "DG.RollLuck": "Tira sort", "DG.Luck": "Sort", - "DG.FallbackText.ProfessionAndOrRank": "Professió (Rang si aplica)", - "DG.Skills.accounting": "Comptabilitat", "DG.Skills.alertness": "Alerta", "DG.Skills.anthropology": "Antropologia", @@ -44,14 +42,12 @@ "DG.Skills.unarmed_combat": "Combat sense armes", "DG.Skills.unnatural": "Sobrenatural", "DG.Skills.flight": "Flight", - "DG.Skills.AddTypedOrCustomSkill": "Afegir tipus o habilitat personalitzada", "DG.Skills.AddSkill": "Afegir habilitat", "DG.Skills.EditTypedOrCustomSkill": "Edit Typed or Custom Skill", "DG.Skills.EditSkill": "Edit Skill", "DG.Skills.SkillGroup": "Habilitat grupal", "DG.Skills.SkillName": "Nom de l'habilitat", - "DG.TypeSkills.Art": "Art", "DG.TypeSkills.Craft": "Ofici", "DG.TypeSkills.ForeignLanguage": "Idioma", @@ -59,7 +55,6 @@ "DG.TypeSkills.Pilot": "Pilotar", "DG.TypeSkills.Science": "Ciència", "DG.TypeSkills.Other": "Altre", - "DG.Attributes.HP": "PV", "DG.Attributes.WP": "PVOL", "DG.Attributes.SAN": "SENY", @@ -69,21 +64,17 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "POD", "DG.Attributes.cha": "CAR", - "DG.Navigation.Skills": "Habilitats", "DG.Navigation.Physical": "Físic", "DG.Navigation.Mental": "Mental", "DG.Navigation.Gear": "Equip", "DG.Navigation.CV": "C.V.", "DG.Navigation.Contacts": "Contactes", - "DG.About.OnlineHelp": "Documentació del sistema oberta en (EN LÍNEA)", "DG.About.OnlineHelpTooltip": "Inicia el navegador amb ajuda en línea de Github", "DG.About.OnlineBugTracker": "Preguntes, informes d'errors o sol·licituts de funcions (EN LÍNEA)", "DG.About.OnlineBugTrackerTooltip": "Inicia el navegador el rastrejador d'errors de Github. ", - "DG.Roll.Rolling": "Tirant", - "DG.Roll.Target": "Objectiu: ", "DG.Roll.Success": "Èxit", "DG.Roll.Failure": "Fallada", "DG.Roll.Critical": "Crític", @@ -94,14 +85,11 @@ "DG.Roll.RollingDamageFor": "Tirant dany per", "DG.Roll.Or": "O", "DG.Roll.Roll": "Tirar", - "DG.Physical.Wounds": "Ferides", "DG.Physical.FirstAidLabel": "Primers auxilis ja aplicats: ", "DG.Physical.Exhausted": "Exhausted:", "DG.Physical.ExhaustionPenalty": "Penalty:", - "DG.Generic.AddButtonLabel": "Afegir", - "DG.Mental.BreakingPoint": "Punt de trencament: ", "DG.Mental.Reset": "Esborrar", "DG.Mental.SanityLossIncidentsLabel": "Incidents o pèrdua de SENY sense embogir", @@ -111,7 +99,6 @@ "DG.Mental.AdaptedToHelplessness": "Adaptat a la impotència", "DG.Mental.Motivations": "Motivacions", "DG.Mental.Disorders": "Desordres", - "DG.Gear.Weapons": "ARMES", "DG.Gear.Armor": "ARMADURA", "DG.Gear.OtherGear": "ALTRE EQUIP", @@ -120,7 +107,6 @@ "DG.Gear.ArmorPiercing": "Perforació d'armadura", "DG.Gear.ArmorRating": "Classificació d'armadura", "DG.Gear.Equipped": "Equipat", - "DG.CV.Sex": "Sexe", "DG.CV.SexPlaceholder": "Sexe", "DG.CV.AgeAndDoB": "Edat y D.D.N.", @@ -131,12 +117,10 @@ "DG.CV.NationalityPlaceholder": "Nacionalitat", "DG.CV.Education": "Educació", "DG.CV.EducationPlaceholder": "Educació o entrenament", - "DG.Bonds.Bond": "Vincle", "DG.Bonds.Name": "Nom", "DG.Bonds.Score": "Puntuació", "DG.Bonds.NewBond": "Nou vincle", - "DG.Attributes.Tooltip.HP": "Punts de vida, un personatge cau inconscient ales 2 PV i mor amb 0 PV.", "DG.Tooltip.MaximumHP": "La salut màxima és el promig de FOR + CON, arrodonint avall.", "DG.Attributes.Tooltip.WP": "Els punts de voluntat són el combustible mental. Els necessiten per tirar endavant tot i l'esgotament o le situacions mentals greus. Consulta la pàgina 47 del manual de l'agent per més detalls.", @@ -155,17 +139,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Estar adaptat a la impotència significa que l'impuls personal de l'agent s'allunya del món que el rodeja. Perd permanentement 1D6 POD. ", "DG.Tooltip.ToggleEquippedState": "Fes click per alternar si aquest item està equipat o no. ", "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", "DG.Gear.Expense.Incidental": "Accidental", "DG.Gear.Expense.Standard": "Estàndar", "DG.Gear.Expense.Unusual": "Inusual", "DG.Gear.Expense.Major": "Destacat", "DG.Gear.Expense.Extreme": "Extrem", - "DG.ItemWindow.Bonds.Description": "Descripció / Desenvolupament ", "DG.ItemWindow.Bonds.ScoreTooltip": "Una puntuació més alta representa una relació més forta entre l'agente i el vincle. ", - "DG.ItemWindow.Motivations.Attributes": "Atributs", "DG.ItemWindow.Motivations.Motivation": "Motivació", "DG.ItemWindow.Motivations.Description": "Descripció / Notes", @@ -176,19 +157,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Marca aquesta opció si afageix un trastorn o si ha perdut una motivació.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "El trastorn s'ha curat.", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Un èxito crític en una tirada de sort terapèutica pot curar un trastorn. Tot i així, pot tornar la pròxima vegada que s'arribi a un punt de ruptura. ", - "DG.ItemWindow.Gear.Attributes": "Atributs", "DG.ItemWindow.Gear.Description": "Descripció", "DG.ItemWindow.Gear.NamePlaceholder": "Nom", "DG.ItemWindow.Gear.Expense": "Despesa", "DG.ItemWindow.Gear.Equipped": "Equipat", - "DG.ItemWindow.Armor.Description": "Descripció", "DG.ItemWindow.Armor.NamePlaceholder": "Nom", "DG.ItemWindow.Armor.ArmorRating": "Nivell armadura", "DG.ItemWindow.Armor.Expense": "Despesa", "DG.ItemWindow.Armor.Equipped": "Equipat", - "DG.ItemWindow.Weapons.Description": "Descripció", "DG.ItemWindow.Weapons.NamePlaceholder": "Nom", "DG.ItemWindow.Weapons.Skill": "Habilitat", @@ -202,27 +180,21 @@ "DG.ItemWindow.Weapons.Ammo": "Munició (Quantitat/Tipus)", "DG.ItemWindow.Weapons.Range": "Abast", "DG.ItemWindow.Weapons.KillRadius": "Radi de mort", - "DG.ModifySkillRollDialogue.Title": "Modificar tirada", "DG.ModifySkillRollDialogue.Test": "Test:", "DG.ModifySkillRollDialogue.OriginalTarget": "Objectiu original:", "DG.ModifySkillRollDialogue.TargetModifier": "Modificar objectiu:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Donar un modificador positiu o negatiu aquí per sumar o restar al objectiu en la tirada d'habilitats.", - "DG.ModifyDamageRollDialogue.Weapon": "Arma:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Utilitza aquest camp per modificar la fòrmula, com duplicar el resultat.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Utilitza aquest camp per afegir un modificar com +1 o un altre terma com +1d4.", - "DG.Roll.Inhuman": "Inhumà", "DG.Tooltip.BreakPoint": "El punt de trencament actual, actualitzar quan el seny actual sigui igual a aquest valor. El punt de trencament inciial és SENY - POD.", "DG.Tooltip.BreakPointReset": "Actualitzar el punt de trencament al valor actual de SENY - PVOL.", "DG.Skills.ritual": "Ritual", - "DG.RollMode": "Modo rollo", "DG.Bonds.Relationship": "Relació", - "DG.TypeSkills.Subskills.Painting": "Pintar", - "DG.Sheet.BlockHeaders.Attributes": "ATTRIBUTES", "DG.Sheet.BlockHeaders.Statistics": "STATISTICS", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "Descripció física/notes", @@ -232,47 +204,38 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVATIONS AND DISORDERS", "DG.Sheet.BlockHeaders.PersonalInfoCV": "PERSONAL INFORMATION / CV", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "BONDS / RELATIONSHIPS / CONTACTS", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Amagar habilitats no entrenades", "DG.UnnaturalSheet.NotesLabel": "Notes", "DG.UnnaturalSheet.ShortDescriptionFallbackText": "Descripció de la criatura o tipus de criatura", "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Habilitats i notes", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Atacs i equipament", "DG.NPCSheet.BreakPointAbbreviation": "PT", - "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 All", - "DG.Generic.SanDamage": "SAN Damage", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Target", "DG.ItemWindow.Weapons.SkillModifier": "Skill Modifier", "DG.ItemWindow.Custom": "Custom", - "TYPES.Item.weapon": "Arma", "TYPES.Item.armor": "Armadura", "TYPES.Item.motivation": "Motivació", "TYPES.Item.bond": "Vincle", "TYPES.Item.gear": "Equip", - "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.Messages.Skillsmark.Marked": "Aquesta habilitat està marcada", "DG.Messages.Skillsmark.Tooltip": "Vegeu la p. 29 del Manual de l'agent" } diff --git a/lang/cn.json b/lang/cn.json index a630732..0d58cde 100644 --- a/lang/cn.json +++ b/lang/cn.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "姓氏, 名字 (如适用则包括别名)", "DG.RollLuck": "幸运检定", "DG.Luck": "幸运", - "DG.FallbackText.ProfessionAndOrRank": "职业(如适用则包括职位)", - "DG.Skills.accounting": "会计", "DG.Skills.alertness": "警觉", "DG.Skills.anthropology": "人类学", @@ -45,7 +43,6 @@ "DG.Skills.unnatural": "非自然知识", "DG.Skills.flight": "飞机", "DG.TypeSkills.Subskills.Painting": "绘画", - "DG.Skills.AddTypedOrCustomSkill": "Add Typed/Custom Skill", "DG.Skills.AddSkill": "Add Skill", "DG.Skills.Apply": "Apply", @@ -58,7 +55,6 @@ "DG.Skills.EditSkill": "编辑技能", "DG.Skills.SkillGroup": "技能组别", "DG.Skills.SkillName": "技能名称", - "DG.TypeSkills.Art": "艺术", "DG.TypeSkills.Craft": "工艺", "DG.TypeSkills.ForeignLanguage": "外语", @@ -66,7 +62,6 @@ "DG.TypeSkills.Pilot": "特种载具", "DG.TypeSkills.Science": "科学", "DG.TypeSkills.Other": "其他", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "添加特训", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Custom自定义/类别 技能", "DG.SpecialTraining.Dialog.DropDown.Skills": "技能", @@ -75,7 +70,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "名称", "DG.SpecialTraining.Dialog.SkillLabel": "技能货属性", "DG.SpecialTraining.Dialog.Title": "创建/编辑特训", - "DG.Attributes.HP": "HP", "DG.Attributes.WP": "WP", "DG.Attributes.SAN": "理智", @@ -85,21 +79,17 @@ "DG.Attributes.int": "智力", "DG.Attributes.pow": "意志", "DG.Attributes.cha": "魅力", - "DG.Navigation.Skills": "技能", "DG.Navigation.Physical": "健康", "DG.Navigation.Mental": "精神", "DG.Navigation.Gear": "装备", "DG.Navigation.CV": "个人情况", "DG.Navigation.Contacts": "重要关系", - "DG.About.OnlineHelp": "System Documentation on (ONLINE)", "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": "正在掷骰", - "DG.Roll.Target": "需求: ", "DG.Roll.Success": "成功", "DG.Roll.Failure": "失败", "DG.Roll.Critical": "大成功", @@ -110,14 +100,11 @@ "DG.Roll.RollingDamageFor": "正在为以下目标掷伤害骰", "DG.Roll.Or": "或", "DG.Roll.Roll": "掷骰", - "DG.Physical.Wounds": "伤口", "DG.Physical.FirstAidLabel": "已尝试急救: ", "DG.Physical.Exhausted": "精疲力尽:", "DG.Physical.ExhaustionPenalty": "惩罚:", - "DG.Generic.AddButtonLabel": "添加", - "DG.Mental.BreakingPoint": "崩溃点: ", "DG.Mental.Reset": "重置", "DG.Mental.SanityLossIncidentsLabel": "未陷入疯狂的理智损失事件", @@ -127,7 +114,6 @@ "DG.Mental.AdaptedToHelplessness": "适应无力感", "DG.Mental.Motivations": "人生动力", "DG.Mental.Disorders": "Disorder", - "DG.Gear.Weapons": "武器", "DG.Gear.Armor": "护甲", "DG.Gear.OtherGear": "其他装备", @@ -136,7 +122,6 @@ "DG.Gear.ArmorPiercing": "护甲开销", "DG.Gear.ArmorRating": "护甲等级", "DG.Gear.Equipped": "已装备", - "DG.CV.Sex": "性别", "DG.CV.SexPlaceholder": "性别", "DG.CV.AgeAndDoB": "年龄和出生日期.", @@ -147,12 +132,10 @@ "DG.CV.NationalityPlaceholder": "国籍", "DG.CV.Education": "教育", "DG.CV.EducationPlaceholder": "教育或培训", - "DG.Bonds.Bond": "重要关系", "DG.Bonds.Name": "名称", "DG.Bonds.Score": "关系值", "DG.Bonds.NewBond": "新关系", - "DG.Attributes.Tooltip.HP": "体力,体力降到2点时,你的特工会陷入昏迷。体力降到0点时,你的特工会死亡。", "DG.Tooltip.MaximumHP": "体力等于力量加上体质,除以二并向上取整。", "DG.Attributes.Tooltip.WP": "毅力(WP):毅力代表精神耐受度。特工需要毅力来克服疲劳或精神消耗的情况。(详见47页的毅力一节)", @@ -171,17 +154,14 @@ "DG.Tooltip.AdaptedToHelplessness": "适应无力感意味着你的特工做事的动力受到了损伤。这导致意志永久损失1D6点。", "DG.Tooltip.ToggleEquippedState": "点击切换此物品是否已装备。", "DG.Tooltip.Exhaustion": "一名特工在工作太长时间后,或是面临极端的危险或受伤却没有得到休息后,将变得精疲力尽。其适用的时机取决于掌局者,但彻夜不眠,或是在损失理智或体力后拒绝休息,应当导致精疲力尽。", - "DG.Gear.Expense.NA": "N/A or None", "DG.Gear.Expense.Incidental": "细微开销", "DG.Gear.Expense.Standard": "常规开销", "DG.Gear.Expense.Unusual": "显著开销", "DG.Gear.Expense.Major": "重大开销", "DG.Gear.Expense.Extreme": "极端开销", - "DG.ItemWindow.Bonds.Description": "描述/发展", "DG.ItemWindow.Bonds.ScoreTooltip": "A更高的关系值代表特工与重要关系之间的联系更强。", - "DG.ItemWindow.Motivations.Attributes": "属性", "DG.ItemWindow.Motivations.Motivation": "人生动机", "DG.ItemWindow.Motivations.Description": "描述/笔记", @@ -192,19 +172,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "如果添加精神疾病,或现有动机因获得精神障碍而失去,请勾选此项。", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "精神疾病已被 \"治愈\"", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "心理治疗幸运骰的大成功可以 '治愈' 精神疾病。但它可能在下次达到崩溃点时再次出现。参见《特工手册》第78-79页的心理治疗部分", - "DG.ItemWindow.Gear.Attributes": "属性", "DG.ItemWindow.Gear.Description": "描述", "DG.ItemWindow.Gear.NamePlaceholder": "名称", "DG.ItemWindow.Gear.Expense": "开销", "DG.ItemWindow.Gear.Equipped": "已装备", - "DG.ItemWindow.Armor.Description": "描述", "DG.ItemWindow.Armor.NamePlaceholder": "名称", "DG.ItemWindow.Armor.ArmorRating": "护甲等级", "DG.ItemWindow.Armor.Expense": "开销", "DG.ItemWindow.Armor.Equipped": "已装备", - "DG.ItemWindow.Weapons.Description": "描述", "DG.ItemWindow.Weapons.NamePlaceholder": "名称", "DG.ItemWindow.Weapons.Skill": "技能", @@ -218,25 +195,20 @@ "DG.ItemWindow.Weapons.Ammo": "弹药 (数量/类别)", "DG.ItemWindow.Weapons.Range": "射程", "DG.ItemWindow.Weapons.KillRadius": "击杀半径", - "DG.ModifySkillRollDialogue.Title": "修改掷骰", "DG.ModifySkillRollDialogue.Test": "测试:", "DG.ModifySkillRollDialogue.OriginalTarget": "初始目标:", "DG.ModifySkillRollDialogue.TargetModifier": "修改目标:", "DG.ModifySkillRollDialogue.ModifierTooltip": "在这里给出正面或负面修正,以增加/减少技能检定的目标值。", - "DG.ModifyDamageRollDialogue.Weapon": "武器:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "使用此字段修改公式的外部部分,例如将结果加倍。", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "使用此字段添加固定修正值,如'+1'或另一个骰子项,如'+1d4'。", - "DG.Roll.Inhuman": "非人类", "DG.Tooltip.BreakPoint": "当前崩溃点,当当前理智降至或低于此值时重置。初始崩溃点等于初始理智减去意志。", "DG.Tooltip.BreakPointReset": "将崩溃点重置为新的理智减去意志。", "DG.Skills.ritual": "仪式", - "DG.RollMode": "掷骰模式", "DG.Bonds.Relationship": "关系", - "DG.Sheet.BlockHeaders.Attributes": "属性", "DG.Sheet.BlockHeaders.Statistics": "统计", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "外貌描述 / 笔记", @@ -246,7 +218,6 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "动机和精神疾病", "DG.Sheet.BlockHeaders.PersonalInfoCV": "个人信息 / 简历", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "重要关系 / 关系 / 联系人", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "隐藏未训练技能", "DG.UnnaturalSheet.NotesLabel": "笔记", "DG.UnnaturalSheet.ShortDescriptionFallbackText": "生物或生物类型的描述", @@ -254,33 +225,26 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "技能 & 笔记", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "攻击 & 能力", "DG.NPCSheet.BreakPointAbbreviation": "BP", - "DG.ImpossibleLanscapes.IHaveSeenThe": "我看见祂了", "DG.ImpossibleLanscapes.YellowSign": "黄印", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "腐化", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "tion", "DG.ImpossibleLanscapes.Gift": "天赋", "DG.ImpossibleLanscapes.Insight": "洞察", - "DG.VehicleSheet.NotesTabName": "笔记", "DG.VehicleSheet.GearTabName": "装备", "DG.VehicleSheet.NotesLabel": "笔记", "DG.VehicleSheet.DescriptionPlaceHolder": "载具描述", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Check a damaged Bond's box until the next Home Scene ends.", "DG.Generic.ClearAll": "Clear Checks", - "DG.Generic.SanDamage": "理智损失", - "DG.ItemWindow.Weapons.CustomSkillTarget": "目标", "DG.ItemWindow.Weapons.SkillModifier": "技能修正", "DG.ItemWindow.Custom": "自定义", - "TYPES.Actor.agent": "特工", "TYPES.Actor.npc": "NPC", "TYPES.Actor.unnatural": "非自然", "TYPES.Actor.vehicle": "载具", - "TYPES.Item.armor": "护甲", "TYPES.Item.bond": "重要关系", "TYPES.Item.gear": "装备", @@ -288,13 +252,10 @@ "TYPES.Item.weapon": "武器", "TYPES.Item.tome": "典籍", "TYPES.Item.ritual": "仪式", - "DG.Gear.Tomes": "典籍", "DG.Gear.Rituals": "仪式", - "DG.Tomes.Name": "名称", "DG.Ritual.Name": "名称", - "DG.Tome.Name": "名称", "DG.ItemWindow.Tome.Description": "描述", "DG.ItemWindow.Tome.LanguagePlaceholder": "语言", @@ -304,13 +265,11 @@ "DG.ItemWindow.Tome.StudySanityDamage": "学习的理智损失", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "仅掌局者可见", "DG.ItemWindow.Tome.HandlerNotes": "掌局者笔记", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "激活造成的理智损失", "DG.ItemWindow.Ritual.ActivationTime": "激活耗时", "DG.ItemWindow.Ritual.ActivationCosts": "激活代价", "DG.ItemWindow.Ritual.StudyTime": "学习时长", "DG.ItemWindow.Ritual.LearnedSanityDamage": "学习造成的理智损失", - "DG.Messages.Skillsmark.Marked": "此技能被标记", "DG.Messages.Skillsmark.Tooltip": "参见第 29 页《代理人手册》" } diff --git a/lang/de.json b/lang/de.json index 972e302..56bb196 100644 --- a/lang/de.json +++ b/lang/de.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "NACHNAME, VORNAME (UND GGF. ALIAS)", "DG.RollLuck": "Glücksprobe ausführen", "DG.Luck": "Glück", - "DG.FallbackText.ProfessionAndOrRank": "BERUF (RANG, FALLS ZUTREFFEND)", - "DG.Skills.accounting": "Buchführung", "DG.Skills.alertness": "Wachsamkeit", "DG.Skills.anthropology": "Anthropologie", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Waffenloser Kampf", "DG.Skills.unnatural": "Mythos", "DG.Skills.flight": "Flight", - "DG.Skills.AddTypedOrCustomSkill": "Eigene Fertigkeit hinzufügen", "DG.Skills.AddSkill": "Fertigkeit hinzufügen", "DG.Skills.Apply": "Anwenden", @@ -57,7 +54,6 @@ "DG.Skills.EditSkill": "Fertigkeit bearbeiten", "DG.Skills.SkillGroup": "Fertigkeiten-Gruppe", "DG.Skills.SkillName": "Fertigkeit Name", - "DG.TypeSkills.Art": "Kunst", "DG.TypeSkills.Craft": "Handwerk", "DG.TypeSkills.ForeignLanguage": "Fremdsprache", @@ -65,7 +61,6 @@ "DG.TypeSkills.Pilot": "Pilot", "DG.TypeSkills.Science": "Wissenschaft", "DG.TypeSkills.Other": "Andere", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Besondere Ausbildung hinzufügen", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Eigene Fertigkeiten", "DG.SpecialTraining.Dialog.DropDown.Skills": "Fertigkeiten", @@ -74,7 +69,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Name", "DG.SpecialTraining.Dialog.SkillLabel": "Fertigkeit oder Attribut", "DG.SpecialTraining.Dialog.Title": "Spezialausbildung erstellen/ bearbeiten", - "DG.Attributes.HP": "Gesundheit", "DG.Attributes.WP": "Wille", "DG.Attributes.SAN": "Stabilität", @@ -84,21 +78,17 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "MAC", "DG.Attributes.cha": "CHA", - "DG.Navigation.Skills": "Fertigkeiten", "DG.Navigation.Physical": "Körperlich", "DG.Navigation.Mental": "Geistig", "DG.Navigation.Gear": "Ausrüstung", "DG.Navigation.CV": "Persönlich", "DG.Navigation.Contacts": "Bindungen", - "DG.About.OnlineHelp": "Systemdokumentation auf (ONLINE)", "DG.About.OnlineHelpTooltip": "Starten des Browsers mit der Online-Hilfe von GitHub.", "DG.About.OnlineBugTracker": "Fragen, Fehlerberichte oder Funktionsanfragen (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Browser mit GitHub-Problem-Tracker starten.", - "DG.Roll.Rolling": "Probe", - "DG.Roll.Target": "Ziel: ", "DG.Roll.Success": "Erfolg", "DG.Roll.Failure": "Fehlschlag", "DG.Roll.Critical": "Kritisch", @@ -109,14 +99,11 @@ "DG.Roll.RollingDamageFor": "SCHADEN würfeln für", "DG.Roll.Or": "Oder", "DG.Roll.Roll": "Probe", - "DG.Physical.Wounds": "Verletzungen", "DG.Physical.FirstAidLabel": "Erste Hilfe bereits geleistet: ", "DG.Physical.Exhausted": "Exhausted:", "DG.Physical.ExhaustionPenalty": "Penalty:", - "DG.Generic.AddButtonLabel": "Hinzufügen", - "DG.Mental.BreakingPoint": "Schwelle: ", "DG.Mental.Reset": "Reset", "DG.Mental.SanityLossIncidentsLabel": "Vorfälle von Stabilitäts-Verlust, ohne verrückt zu werden", @@ -126,7 +113,6 @@ "DG.Mental.AdaptedToHelplessness": "An Hilflosigkeit gewöhnt", "DG.Mental.Motivations": "Motivation", "DG.Mental.Disorders": "Störung", - "DG.Gear.Weapons": "WAFFEN", "DG.Gear.Armor": "RÜSTUNG", "DG.Gear.OtherGear": "ANDERE AUSRÜSTUNG", @@ -135,7 +121,6 @@ "DG.Gear.ArmorPiercing": "Rüstungsdurchdringung", "DG.Gear.ArmorRating": "Rüstungsschutz", "DG.Gear.Equipped": "Ausgerüstet", - "DG.CV.Sex": "Geschlecht", "DG.CV.SexPlaceholder": "Geschlecht", "DG.CV.AgeAndDoB": "Alter und Geb.", @@ -146,19 +131,15 @@ "DG.CV.NationalityPlaceholder": "Nationalität", "DG.CV.Education": "Bildung", "DG.CV.EducationPlaceholder": "Bildung oder Training", - "DG.Bonds.Bond": "Bindung", "DG.Bonds.Name": "Name", "DG.Bonds.Score": "Wert", "DG.Bonds.NewBond": "Neue Bindung", - "DG.Attributes.Tooltip.HP": "Gesundheit. Ein Charakter wird bei 2 Gesundheit bewusstlos und stirbt bei 0 Gesundheit.", "DG.Tooltip.MaximumHP": "Die maximale Gesundheit ist der Durchschnitt von STR + KON, aufgerundet.", - "DG.Attributes.Tooltip.WP": "Die Punkte in WILLE sind mentaler Treibstoff. Agenten brauchen WILLE, um trotz Erschöpfung oder geistig anstrengender Situationen weiterzumachen. Auf Seite 47 des Agentenhandbuchs findest du weitere Informationen ('Willpower').", "DG.Tooltip.MaximumWP": "Der maximale WILLE ist gleich MAC.", "DG.Attributes.Tooltip.SAN": "Klicken, um eine Stabilitäts-Probe durchzuführen...", - "DG.Tooltip.CurrentSanityPartOne": "Die aktuelle Grenze der Stabilität. Die aktuelle Belastungsschwelle ist ", "DG.Tooltip.CurrentSanityPartTwo": ", Klicke oben auf STABILITÄT, um eine Stabilitäts-Probe durchzuführen.", "DG.Tooltip.MaximumSanity": "Die maximale Stabilität ist gleich 99 abzüglich der Fähigkeit 'Mythos'.", @@ -172,17 +153,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Die 'Gewöhnung an Hilflosigkeit' ('Adapted to Helplessness') bedeutet, dass der persönliche Antrieb deines Agenten leidet, da er sich von der Welt um ihn herum zurückzieht. Sobald dein Agent an die Hilflosigkeit gewöhnt ist, verliert er dauerhaft 1D6 MAC.", "DG.Tooltip.ToggleEquippedState": "Klicke hier, um umzuschalten, ob dieser Gegenstand ausgerüstet ist oder nicht.", "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 bzw Keine", "DG.Gear.Expense.Incidental": "Zufällig", "DG.Gear.Expense.Standard": "Standard", "DG.Gear.Expense.Unusual": "Ungewöhnlich", "DG.Gear.Expense.Major": "Schwierig", "DG.Gear.Expense.Extreme": "Extrem", - "DG.ItemWindow.Bonds.Description": "Beschreibung/ Entwicklungen", "DG.ItemWindow.Bonds.ScoreTooltip": "Eine höhere Punktzahl steht für eine stärkere Beziehung zwischen dem Agenten und seiner Bindung.", - "DG.ItemWindow.Motivations.Attributes": "Attribute", "DG.ItemWindow.Motivations.Motivation": "Motivation", "DG.ItemWindow.Motivations.Description": "Beschreibung/ Notizen", @@ -193,19 +171,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Prüfe dies, wenn du eine Störung hinzufügst oder wenn eine bestehende Motivation durch den Erwerb einer Störung verloren gegangen ist.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "Störung wurde \"Geheilt\"", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Ein kritischer Erfolg bei einer Therapie-Glücksprobe kann eine Störung 'heilen'. Die Störung kann jedoch beim nächsten Mal zurückkehren, wenn eine Belastungsschwelle erreicht wird. Siehe den Abschnitt Therapie auf den Seiten 78-79 des Agentenhandbuchs. (siehe 'Therapy')", - "DG.ItemWindow.Gear.Attributes": "Attribute", "DG.ItemWindow.Gear.Description": "Beschreibung", "DG.ItemWindow.Gear.NamePlaceholder": "Name", "DG.ItemWindow.Gear.Expense": "Ausgaben", "DG.ItemWindow.Gear.Equipped": "Ausgerüstet", - "DG.ItemWindow.Armor.Description": "Beschreibung", "DG.ItemWindow.Armor.NamePlaceholder": "Name", "DG.ItemWindow.Armor.ArmorRating": "Rüstungsschutz", "DG.ItemWindow.Armor.Expense": "Ausgaben", "DG.ItemWindow.Armor.Equipped": "Ausgerüstet", - "DG.ItemWindow.Weapons.Description": "Beschreibung", "DG.ItemWindow.Weapons.NamePlaceholder": "Name", "DG.ItemWindow.Weapons.Skill": "Fertigkeit", @@ -219,27 +194,21 @@ "DG.ItemWindow.Weapons.Ammo": "Munition (Menge/Typ)", "DG.ItemWindow.Weapons.Range": "Reichweite", "DG.ItemWindow.Weapons.KillRadius": "Tötungsradius", - "DG.ModifySkillRollDialogue.Title": "Probe modifizieren", "DG.ModifySkillRollDialogue.Test": "Probe:", "DG.ModifySkillRollDialogue.OriginalTarget": "Ursprüngliches Ziel:", "DG.ModifySkillRollDialogue.TargetModifier": "Ziel modifizieren (+/-):", "DG.ModifySkillRollDialogue.ModifierTooltip": "Gib hier einen positiven oder negativen Modifikator an, der zum Ziel für die Fertigkeitsprobe addiert/ abgezogen wird.", - "DG.ModifyDamageRollDialogue.Weapon": "Waffe:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Verwende dieses Feld, um den äußeren Teil der Formel zu ändern, z. B. um das Ergebnis zu verdoppeln.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Verwende dieses Feld, um einen Modifikator wie '+1' oder einen anderen Würfelbegriff wie '+1d4' hinzuzufügen.", - "DG.Roll.Inhuman": "Nicht-Menschlich", "DG.Tooltip.BreakPoint": "Die aktuelle Belastungsschwelle, die zurückgesetzt wird, wenn die aktuelle Stabilität auf oder unter diesen Wert fällt. Die Belastungsschwelle zu Beginn ist STABILITÄT abzüglich MAC.", "DG.Tooltip.BreakPointReset": "Belastungsschwelle zurücksetzen auf aktuelle MAC abzüglich WILLE.", "DG.Skills.ritual": "Ritual", - "DG.RollMode": "Probe-Modus", "DG.Bonds.Relationship": "Beziehung", - "DG.TypeSkills.Subskills.Painting": "Malerei", - "DG.Sheet.BlockHeaders.Attributes": "ATTRIBUTE", "DG.Sheet.BlockHeaders.Statistics": "STATISTIK", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "PHYSISCHE BESCHREIBUNG / ANMERKUNGEN", @@ -249,7 +218,6 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVATIONEN UND STÖRUNGEN", "DG.Sheet.BlockHeaders.PersonalInfoCV": "PERSÖNLICHE INFORMATIONEN / LEBENSLAUF", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "BINDUNGEN / BEZIEHUNGEN / KONTAKTE", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Untrainierte ausblenden", "DG.UnnaturalSheet.NotesLabel": "Notizen", "DG.UnnaturalSheet.ShortDescriptionFallbackText": "Beschreibung der Kreatur oder Kreaturenart", @@ -257,40 +225,32 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Fertigkeiten & Notizen", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Angriffe & Fähigkeiten", "DG.NPCSheet.BreakPointAbbreviation": "BS", - "DG.ImpossibleLanscapes.IHaveSeenThe": "ICH HABE GESEHEN:", "DG.ImpossibleLanscapes.YellowSign": "DAS GELBE ZEICHEN", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Korrum", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "piert", "DG.ImpossibleLanscapes.Gift": "Gabe", "DG.ImpossibleLanscapes.Insight": "Erkenntnis", - "DG.VehicleSheet.NotesTabName": "Notizen", "DG.VehicleSheet.GearTabName": "Ausrüstung", "DG.VehicleSheet.NotesLabel": "NOTIZEN", "DG.VehicleSheet.DescriptionPlaceHolder": "Fahrzeugbeschreibung", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Prüfe das Kästchen einer beschädigten Bindung, bis die nächste Heimszene endet.", "DG.Generic.ClearAll": "Proben löschen", - "DG.Generic.SanDamage": "Stabilitäts-Verlust", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Ziel", "DG.ItemWindow.Weapons.SkillModifier": "Fertigkeitsmodifikator", "DG.ItemWindow.Custom": "Benutzerdefiniert", - "TYPES.Item.weapon": "Waffe", "TYPES.Item.armor": "Rüstung", "TYPES.Item.motivation": "Motivation", "TYPES.Item.bond": "Bindung", "TYPES.Item.gear": "Ausrüstung", - "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.Messages.Skillsmark.Marked": "Diese Fähigkeit ist gekennzeichnet.", "DG.Messages.Skillsmark.Tooltip": "Siehe S. 29 des Agentenhandbuchs" } diff --git a/lang/en.json b/lang/en.json index 94b9363..24b9346 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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 license file, excepting those elements that are components of the Delta Green intellectual property.", @@ -159,32 +157,74 @@ "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.Roll.Target": "Target: ", + "DG.About.ChangelogTooltip": "Launch browser with the online changelog.", "DG.Roll.Success": "Success", "DG.Roll.Failure": "Failure", "DG.Roll.CriticalSuccess": "Critical Success!", "DG.Roll.CriticalFailure": "Critical Failure!", "DG.Roll.Lethality": "Lethality", "DG.Roll.Lethal": "Lethal", - "DG.Roll.For": "For", + "DG.Roll.Rolling": "Rolling", + "DG.Roll.For": "for", "DG.Roll.Damage": "Damage", "DG.Roll.RollingDamageFor": "Rolling DAMAGE for", - "DG.Roll.Or": "Or", "DG.Roll.Roll": "Roll", - + "DG.Roll.CannotRollStat": "A statistic of 0 cannot be rolled.", "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.ExhaustedRollLabel": "Exhausted.
Lost {willpowerChange}.", + "DG.Physical.Chat.RestedRollLabel": "Rested.
Regained {willpowerChange}.", + "DG.Physical.Chat.RestedRollLabelExhausted": "Rested.
Regained {willpowerChange}.
No longer exhausted.", + "DG.Physical.Chat.Willpower": "Willpower", + "DG.Physical.Chat.WillpowerAmount": "{amount} {willpower}", + "DG.Physical.Chat.WillpowerTooltip": "Willpower: {current}/{max}", + "DG.Physical.Chat.StimulantsRegularRollLabel": "Regular Stimulants.
Exhaustion suppressed for {hours} hours.
Cannot sleep during this time.", + "DG.Physical.Chat.StimulantsHardRollLabel": "Hard Drugs.
Exhaustion suppressed for {hours} hours.
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,18 +235,17 @@ "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", "DG.Gear.Armor": "ARMOR", "DG.Gear.OtherGear": "OTHER GEAR", "DG.Gear.Name": "Name", - "DG.Gear.DamageOrLethality": "Damage/Lethality", + "DG.Gear.DamageOrLethality": "Damage/ Lethality", "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 +256,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 +268,23 @@ "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.RollTarget.RollModifier": "Roll Modifier", "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 +294,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 +315,76 @@ "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.DamageOrLethalityRoll.Title": "Roll Damage or Lethality", + "DG.DamageOrLethalityRoll.Intro": "This weapon has both damage and lethality. Choose which to roll.", + "DG.DamageOrLethalityRoll.RollDamage": "Roll Damage", + "DG.DamageOrLethalityRoll.RollLethality": "Roll Lethality", + "DG.EditStats.Title": "Edit Statistics", + "DG.EditStats.Button": "Edit Statistics", + "DG.EditStats.DistinguishingFeature": "Distinguishing Feature", "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 +392,104 @@ "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 Selections", + "DG.Profession.Dialog.Tab.BonusSkills": "Bonus Skills", + "DG.Profession.Dialog.Tab.Bonds": "Bonds", + "DG.Profession.Dialog.Skill": "Skill", + "DG.Profession.Dialog.Value": "Value", + "DG.Profession.Dialog.SkillTotalsHeader": "Skill Totals", + "DG.Profession.Dialog.SkillTotalsHint": "Skills in green have been increased by your profession. Hover over a skill to see what it does or consult the Agent's Handbook for the full description.", + "DG.Profession.Dialog.SkillsOptionsHeader": "Skill Selections ({picks} picks)", + "DG.Profession.Dialog.BonusSkillsIntro": "Select skills from the dropdowns to add 20% to their total. A skill may be selected multiple times. Unnatural cannot be selected, nor can any skill be increased above 80% at character creation.", + "DG.Profession.Dialog.BonusSkillWasteTooHigh": "{skill} cannot be increased above 80. Select a different bonus skill.", + "DG.Profession.Dialog.BondsIntro": "Bonds are the important relationships in your agent's life, such as family, friends, and other loved ones. They can provide a buffer against SAN loss, at the cost of that relationship. Initial relationship score is equal to CHA.", + "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.DamagedVeterans.Dialog.Title": "Damaged Veterans", + "DG.DamagedVeterans.Dialog.Intro": "Decide if your character is a fresh recruit or an experienced Delta Green Agent. If experienced, choose one of the other options to apply further modifications before starting play.", + "DG.DamagedVeterans.Dialog.FreshRecruit.Label": "Fresh Recruit:", + "DG.DamagedVeterans.Dialog.FreshRecruit.Body": "Your character is not yet part of Delta Green or has just joined. Start play with no modifications.", + "DG.DamagedVeterans.Dialog.ExtremeViolence.Label": "Extreme Violence:", + "DG.DamagedVeterans.Dialog.ExtremeViolence.Body": "+10 Occult, -5 SAN, -3 CHA, -3 to all Bonds. Start play Adapted to Violence.", + "DG.DamagedVeterans.Dialog.Captivity.Label": "Captivity or Imprisonment:", + "DG.DamagedVeterans.Dialog.Captivity.Body": "+10 Occult, -5 SAN, -3 POW. Start play Adapted to Helplessness.", + "DG.DamagedVeterans.Dialog.HardExperience.Label": "Hard Experience:", + "DG.DamagedVeterans.Dialog.HardExperience.Body": "+10 Occult, -5 SAN, remove one Bond. Gain +10 to any five skills other than Unnatural, to a maximum of 90%.", + "DG.DamagedVeterans.Dialog.ThingsMan.Label": "Things Man Was Not Meant to Know:", + "DG.DamagedVeterans.Dialog.ThingsMan.Body": "+10 Unnatural, +20 Occult, -SAN equal to POW. Start play with a Disorder caused by the Unnatural. Reset Breaking Point.", + "DG.DamagedVeterans.Dialog.SelectionRequired": "Select a damaged veteran option before submitting.", + "DG.DamagedVeterans.ThingsMan.Dialog.Title": "Things Man Was Not Meant to Know", + "DG.DamagedVeterans.ThingsMan.Dialog.Intro": "Choose one disorder with which to begin play. You can find the descriptions for these disorders on pages 71-72 of the Agent's Handbook.", + "DG.DamagedVeterans.ThingsMan.Disorder.Amnesia": "Amnesia", + "DG.DamagedVeterans.ThingsMan.Disorder.DepersonalizationDisorder": "Depersonalization Disorder", + "DG.DamagedVeterans.ThingsMan.Disorder.Depression": "Depression", + "DG.DamagedVeterans.ThingsMan.Disorder.DissociativeIdentityDisorder": "Dissociative Identity Disorder", + "DG.DamagedVeterans.ThingsMan.Disorder.Fugues": "Fugues", + "DG.DamagedVeterans.ThingsMan.Disorder.Megalomania": "Megalomania", + "DG.DamagedVeterans.ThingsMan.Disorder.Paranoia": "Paranoia", + "DG.DamagedVeterans.ThingsMan.Disorder.SleepDisorder": "Sleep Disorder", + "DG.DamagedVeterans.ThingsMan.Dialog.SelectionRequired": "Select a disorder before submitting.", + "DG.DamagedVeterans.ThingsMan.MotivationName": "Describe Motivation", + "DG.HardExperience.Dialog.Title": "Hard Experience", + "DG.HardExperience.Dialog.BonusIntro": "Select skills from the dropdowns to add 10% to their total. A skill may be selected multiple times. Unnatural cannot be selected, nor can any skill be increased above 90%.", + "DG.HardExperience.Dialog.BondRemovalHeader": "Remove One Bond", + "DG.HardExperience.Dialog.BondRemovalIntro": "Choose one bond to remove from your agent.", + "DG.HardExperience.Dialog.BondRemovalRequired": "Select a bond to remove before submitting.", + "DG.HardExperience.Dialog.BonusSkillRequired": "Select a skill in each of the five bonus skill dropdowns.", + "DG.HardExperience.Dialog.BonusSkillWasteTooHigh": "{skill} cannot be increased above 90. Select a different bonus skill.", + "DG.ProfessionSetup.PickStatistics.Title": "Pick Statistics", + "DG.ProfessionSetup.GoBack": "Go Back", + "DG.ProfessionSetup.PickStatistics.Intro": "Choose how to set this agent's statistics before picking skills.", + "DG.ProfessionSetup.PickStatistics.RollStats": "Roll Stats", + "DG.ProfessionSetup.PickStatistics.RollStatsTooltip": "Roll 4D6, drop lowest, then assign as desired.", + "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 +498,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 +531,18 @@ "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." } diff --git a/lang/es.json b/lang/es.json index 45c1f36..94d7943 100644 --- a/lang/es.json +++ b/lang/es.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "Apellido, nombre, alias o nombre en código", "DG.RollLuck": "Tira Suerte", "DG.Luck": "Suerte", - "DG.FallbackText.newItem": "Nuevo {type}", - "DG.Skills.accounting": "Contabilidad", "DG.Skills.alertness": "Alerta", "DG.Skills.anthropology": "Antropología", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Combate sin armas", "DG.Skills.unnatural": "Antinatural", "DG.Skills.flight": "Volar", - "DG.Skills.Tooltip.accounting": "El estudio de las finanzas y los negocios. Úsala para localizar anomalías en informes financieros como, por ejemplo, una cuenta bancaria oculta o blanqueo de dinero.", "DG.Skills.Tooltip.alertness": "La Alerta detecta el peligro. Úsala para escuchar cómo se quita el seguro de un arma, para entender los balbuceos al otro lado de una pared, para detectar el bulto de una pistola debajo de una chaqueta o para atrapar a alguien que está intentando escabullirse mediante Sigilo.", "DG.Skills.Tooltip.anthropology": "El estudio de las culturas humanas vivas. Úsala para entender valores morales, creencias religiosas y costumbres, y para identificar (pero no traducir) idiomas desconocidos. Mientras que la Historia trata sobre el pasado y la Arqueología estudia los restos físicos, la Antropología trata sobre el comportamiento de las culturas vivas, cómo se relacionan con el resto y su punto de vista acerca de las otras y del pasado.", @@ -82,7 +79,6 @@ "DG.Skills.Tooltip.swim": "La mayoría de los Agentes saben nadar como pasatiempo. Usa la habilidad Natación durante una crisis peligrosa: recorrer una larga distancia en aguas movidas, evitar que alguien se ahogue o subirte a un barco antes de que la cosa con tentáculos de ahí debajo te agarre.", "DG.Skills.Tooltip.unarmed_combat": "Autodefensa. Una pelea entre combatientes no entrenados a menudo implica más empujones y gritos que violencia real. Usa el Combate sin armas para lastimar o matar a un oponente con las manos de tu Agente (o los pies, codos, dientes o cabeza).", "DG.Skills.Tooltip.unnatural": "Conocimiento acerca de los secretos más fundamentales y desgarradores del universo. Úsala para recordar, reconocer o investigar hechos sobre todo aquello que los humanos consideran antinatural. Esto va mucho más allá del ocultismo, porque la habilidad Antinatural representa cosas que existen de verdad. Utilízala para poder filtrar información de los confines más oscuros de los mitos y el folclore y reconocer qué partes son ciertas. El valor de COR de tu Agente nunca puede ser superior a 99 menos su valor de habilidad Antinatural.", - "DG.Skills.AddTypedOrCustomSkill": "Añadir tipo o habilidad personalizada", "DG.Skills.AddSkill": "Añadir habilidad", "DG.Skills.Apply": "Aplicar", @@ -95,7 +91,6 @@ "DG.Skills.EditSkill": "Editar habilidad", "DG.Skills.SkillGroup": "Habilidad de grupo", "DG.Skills.SkillName": "Nombre de la habilidad", - "DG.TypeSkills.Art": "Arte", "DG.TypeSkills.Craft": "Oficio", "DG.TypeSkills.ForeignLanguage": "Idioma extranjero", @@ -103,7 +98,6 @@ "DG.TypeSkills.Pilot": "Pilotar", "DG.TypeSkills.Science": "Ciencia", "DG.TypeSkills.Other": "Otra", - "DG.TypeSkills.Tooltip.Art": "Pericia en la creación o representación de una obra que genera emoción y opinión. También incluye el conocimiento de técnicas y tendencias en tu campo, y la capacidad de distinguir las piezas reales de las falsas de un creador concreto. Cualquiera puede dibujar un boceto, pero la habilidad Arte refleja conocimiento, práctica y talento. Cada tipo de Arte es una habilidad independiente: Actuación, Canto, Danza, Escritura creativa, Escritura de guiones, Escultura, Falsificación, Flauta, Guitarra, Pintura, Poesía, Violín, etc.", "DG.TypeSkills.Tooltip.Craft": "Creación y reparación de herramientas y estructuras sofisticadas. Un trabajo que la mayoría de gente podría averiguar cómo se hace no requiere la habilidad Oficio, solo una prueba de INT o DES. Usa Oficio para el trabajo especializado que requiere formación: Oficio (Electricista) para cambiar el cableado a una casa, hacer el puente a un vehículo con rapidez, intervenir un teléfono o una línea de datos o identificar señales de sabotaje eléctrico; Oficio (Mecánica) para ensamblar apresuradamente una máquina o hacer que un motor averiado vuelva a funcionar… o para sabotear uno de manera que no se pueda volver a reparar; Oficio (Cerrajería) para abrir una cerradura sin la llave; Oficio (Armería) para reparar un arma de fuego estropeada. El Supervisor decide si una tarea concreta requiere un Oficio o no. Cada tipo de Oficio es una habilidad independiente: Armería, Arquitectura, Carpintería, Cerrajería, Electricista, Fontanería, Mecánica, Microelectrónica, etc.", "DG.TypeSkills.Tooltip.ForeignLanguage": "Soltura con otro idioma. Cada idioma extranjero es una habilidad independiente. Tener un 20% permite comunicar ideas sencillas; al llegar a 50%, tu Agente habla y lee como un nativo. Cuanto mayor es el valor de tu habilidad, mayor es la complejidad de información que tu Agente comprende y menor el tiempo que necesita para ello. No necesitas tirar los dados a no ser que el Supervisor diga que la situación es excepcionalmente difícil. A discreción del Supervisor, tener formación especial podría permitir el uso de la misma habilidad con un idioma de la misma familia.", @@ -111,7 +105,6 @@ "DG.TypeSkills.Tooltip.Pilot": "Pilotar, manejar y capitanear vehículos marítimos, aéreos o espaciales. Úsala para mantener una nave a salvo en una crisis como, por ejemplo, una tormenta o una persecución peligrosa. Cada tipo de nave es una habilidad independiente: Avión, Dron, Helicóptero, Barca, Barco, Lanzadera espacial, etc. A discreción del Supervisor, la habilidad con un tipo de nave podría permitir pilotar otro tipo de nave relacionado.", "DG.TypeSkills.Tooltip.Science": "El estudio profundo de los procesos que rigen nuestro mundo. Esta habilidad implica más que la educación común del colegio: cualquier persona puede hacer una prueba de INT para recordar algo de una clase del instituto. La Ciencia se usa para dar con un conocimiento clave sobre el modo en que funciona el universo… o, al menos, el modo en que debería funcionar. Cada Ciencia es una habilidad independiente: Astronomía, Biología, Botánica, Ciencias Planetarias, Física, Genética, Geología, Ingeniería, Matemáticas, Meteorología, Química, Zoología, etc.", "DG.TypeSkills.Tooltip.Other": "Otra", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Añadir entrenamiento especial", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Habilidades personalizadas/de tipo", "DG.SpecialTraining.Dialog.DropDown.Skills": "Habilidades", @@ -120,7 +113,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Nombre", "DG.SpecialTraining.Dialog.SkillLabel": "Habilidad o característica", "DG.SpecialTraining.Dialog.Title": "Crear/editar entrenamiento especial", - "DG.Attributes.HP": "Puntos de salud (PS)", "DG.Attributes.WP": "Puntos de voluntad (PV)", "DG.Attributes.SAN": "Puntos de Cordura (COR)", @@ -130,7 +122,6 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "POD", "DG.Attributes.cha": "CAR", - "DG.Attributes.Tooltip.HP": "Los puntos de Salud representan la cantidad de daño que tu Agente puede soportar. Cuando resulte herido, resta el daño de sus PS. Cuando tu Agente llegue a 2 PS, queda inconsciente. Cuando tu Agente llegue a 0 PS, muere. Los puntos de Salud se pueden recuperar mediante reposo y atención médica.", "DG.Attributes.Tooltip.WP": "Los puntos de Voluntad representan la fortaleza mental, y disminuyen cuando tu Agente trata de suprimir los síntomas de las enfermedades mentales (consulta RESISTIRSE A LA DEMENCIA en la página 73), cuando acaba agotado (consulta AGOTAMIENTO en la página 47), cuando intenta resistir contra la persuasión (consulta INTERROGATORIO en la página 168), cuando acaba quemado a nivel emocional (consulta PIFIA en la página 44) o cuando trata de avivar fenómenos antinaturales. Cuando tu Agente llegue a 2 PV, sufre un colapso emocional transitorio. Cuando tu Agente llegue a 0 PV, queda inconsciente. Los puntos de Voluntad se pueden recuperar mediante el reposo (consulta PUNTOS DE VOLUNTAD en la página 47).", "DG.Attributes.Tooltip.SAN": "Los puntos de Cordura representan la salud mental o, dicho de otra manera, la cantidad de traumas psicológicos o exposición a lo Antinatural que tu Agente puede soportar antes de caer en la demencia. En los momentos de trauma psicológico, tu Agente debe hacer una prueba de Cordura buscando un resultado igual o inferior a su COR con 1D100. Si la tirada falla, tu Agente pierde COR (consulta CORDURA en la página 64). Los puntos de Cordura se pueden recuperar mediante terapia psiquiátrica o la neutralización de amenazas antinaturales. Cuando tu Agente baje hasta los 0 puntos de Cordura, entra en un estado de demencia irreversible y se convierte en un PNJ de manera permanente bajo el control del Supervisor.", @@ -140,7 +131,6 @@ "DG.Attributes.Tooltip.int": "La capacidad de un Agente para recordar, conectar o darse cuenta de las cosas. Junto a su profesión, indica su educación y brillantez en general.", "DG.Attributes.Tooltip.pow": "Fuerza de personalidad, motivación y resiliencia física.", "DG.Attributes.Tooltip.cha": "Encanto, liderazgo y atractivo personal. Podría indicar atractivo físico.", - "DG.Navigation.Agent.skills": "Habilidades", "DG.Navigation.Agent.physical": "Características", "DG.Navigation.Agent.motivations": "Salud mental", @@ -151,7 +141,6 @@ "DG.Navigation.NPC.gear": "Ataques y atributos", "DG.Navigation.Vehicle.gear": "Equipo", "DG.Navigation.Vehicle.notes": "Notas", - "DG.About.Changelog": "Registro de cambios (en línea)", "DG.About.Copyright": "Publicado por Arc Dream Publishing de acuerdo a la Delta Green Partnership. La propiedad intelectual conocida como Delta Green es una marca comercial y derechos de autor de la Delta Green Partnership, que ha licenciado su uso en este sistema.", "DG.About.License": "Los contenidos de este sistema están licenciados según los términos incluidos en este documento, excepto aquellos elementos que son parte de la propiedad intelectual de Delta Green.", @@ -159,9 +148,7 @@ "DG.About.OnlineHelpTooltip": "Abre la página de GitHub en el navegador con la página de ayuda.", "DG.About.OnlineBugTracker": "Preguntas, errores y petición de nuevas funcionalidades.", "DG.About.OnlineBugTrackerTooltip": "Abre la página de GitHub en el navegador con la página de notificación de errores.", - "DG.Roll.Rolling": "Tirando", - "DG.Roll.Target": "Objetivo: ", "DG.Roll.Success": "Éxito", "DG.Roll.Failure": "Fallo", "DG.Roll.CriticalSuccess": "¡Crítico!", @@ -173,14 +160,11 @@ "DG.Roll.RollingDamageFor": "Tirando daño para", "DG.Roll.Or": "O", "DG.Roll.Roll": "Tirar", - "DG.Physical.Wounds": "Heridas", "DG.Physical.FirstAidLabel": "Ya se han aplicado primeros auxilios: ", "DG.Physical.Exhausted": "Exhausto:", "DG.Physical.ExhaustionPenalty": "Penalizador:", - "DG.Generic.AddButtonLabel": "Añadir", - "DG.Mental.BreakingPoint": "Punto de ruptura: ", "DG.Mental.Tooltip.BreakingPoint": "Cuando se falla una tirada de COR y la tirada es igual o está por debajo de este valor, el Agente gana un nuevo trastorno mental; ajusta el nuevo punto de ruptura a la COR actual - POD. Los trastornos se mantienen incluso si la COR vuelve a subir por encima del punto de ruptura anterior.", "DG.Mental.Reset": "Borrar", @@ -193,7 +177,6 @@ "DG.Mental.AdaptedToHelplessness": "Adaptado a la indefensión", "DG.Mental.Motivations": "Motivaciones", "DG.Mental.Disorders": "Desórdenes", - "DG.Gear.WeaponTypes.Firearms": "Armas de fuego", "DG.Gear.WeaponTypes.Melee": "Cuerpo a cuerpo", "DG.Gear.Weapons": "ARMAS", @@ -204,7 +187,6 @@ "DG.Gear.ArmorPiercing": "Perforación de armadura", "DG.Gear.ArmorRating": "Clasificación de armadura", "DG.Gear.Equipped": "Equipado", - "DG.CV.Sex": "Género", "DG.CV.SexPlaceholder": "Género", "DG.CV.AgeAndDoB": "Edad y f. de nac.", @@ -215,12 +197,10 @@ "DG.CV.NationalityPlaceholder": "Nacionalidad", "DG.CV.Education": "Educación", "DG.CV.EducationPlaceholder": "Educación y entrenamiento", - "DG.Bonds.Bond": "Lazo", "DG.Bonds.Name": "Nombre", "DG.Bonds.Score": "Valor", "DG.Bonds.NewBond": "Nuevo lazo", - "DG.Tooltip.MaximumHP": "La salud máxima es el promedio de FUE + CON, redondeado hacia arriba.", "DG.Tooltip.MaximumWP": "Los puntos de voluntad máximos son iguales al POD.", "DG.Tooltip.CurrentSanityPartOne": "Cordura actual, el punto de ruptura actual es ", @@ -236,17 +216,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Estar adaptado a la indefensión significa que el impulso personal de su Agente sufre cuando se aleja del mundo que lo rodea. Tan pronto como su Agente se adapte a la indefensión, perderá permanentemente 1D6 POD.", "DG.Tooltip.ToggleEquippedState": "Haga clic para alternar si este artículo está equipado o no.", "DG.Tooltip.Exhaustion": "Un Agente que trabaja demasiado o se enfrenta a peligros extremos sin descansar queda exhausto. El que ocurra o no queda a criterio del Controlador, pero como guía, cualquiera que permanezca despierto toda la noche sin dormir o no descanse tras haber perdido COR o puntos de vida quedará exhausto.", - "DG.Gear.Expense.NA": "N/A o ninguno", "DG.Gear.Expense.Incidental": "Imprevisto", "DG.Gear.Expense.Standard": "Corriente", "DG.Gear.Expense.Unusual": "Extraordinario", "DG.Gear.Expense.Major": "Considerable", "DG.Gear.Expense.Extreme": "Extremo", - "DG.ItemWindow.Bonds.Description": "Descripción/desarrollo", "DG.ItemWindow.Bonds.ScoreTooltip": "Una puntuación más alta representa una relación más fuerte entre el agente y el lazo.", - "DG.ItemWindow.Motivations.Attributes": "Atributos", "DG.ItemWindow.Motivations.Motivation": "Motivación", "DG.ItemWindow.Motivations.Description": "Descripción/notas", @@ -257,19 +234,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Marque esta opción si agrega un trastorno o si se ha perdido una motivación existente debido a la adquisición de un trastorno.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "El trastorno ha sido curado.", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Un éxito crítico en una tirada de suerte terapéutica puede 'curar' un trastorno. Sin embargo, puede regresar la próxima vez que se alcance un punto de ruptura. Consulte la sección Ir a terapia en las páginas 78-79 del Manual del Agente.", - "DG.ItemWindow.Gear.Attributes": "Atributos", "DG.ItemWindow.Gear.Description": "Descripción", "DG.ItemWindow.Gear.NamePlaceholder": "Nombre", "DG.ItemWindow.Gear.Expense": "Gasto", "DG.ItemWindow.Gear.Equipped": "Equipado", - "DG.ItemWindow.Armor.Description": "Descripción", "DG.ItemWindow.Armor.NamePlaceholder": "Nombre", "DG.ItemWindow.Armor.ArmorRating": "Armadura", "DG.ItemWindow.Armor.Expense": "Gasto", "DG.ItemWindow.Armor.Equipped": "Equipado", - "DG.ItemWindow.Weapons.Description": "Descripción", "DG.ItemWindow.Weapons.NamePlaceholder": "Nombre", "DG.ItemWindow.Weapons.Skill": "Habilidad", @@ -283,27 +257,21 @@ "DG.ItemWindow.Weapons.Ammo": "Munición (cantidad/tipo)", "DG.ItemWindow.Weapons.Range": "Alcance", "DG.ItemWindow.Weapons.KillRadius": "Radio muerte", - "DG.ModifySkillRollDialogue.Title": "Modificar tirada", "DG.ModifySkillRollDialogue.Test": "Tirada", "DG.ModifySkillRollDialogue.OriginalTarget": "Valor original", "DG.ModifySkillRollDialogue.TargetModifier": "Modificador", "DG.ModifySkillRollDialogue.ModifierTooltip": "Dar un modificador positivo o negativo aquí para sumar o restar al objetivo en la chequeo de habilidades.", - "DG.ModifyDamageRollDialogue.Weapon": "Arma:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Utilice este campo para modificar la fórmula, como duplicar el resultado.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Utilice este campo para añadir un modificador como '+1' u otro término como '+1d4'.", - "DG.Roll.Inhuman": "Inhumano", "DG.Tooltip.BreakPoint": "El punto de ruptura actual, actualizar cuando la Cordura actual sea igual o inferior a este valor. El punto de ruptura inicial es COR - POD.", "DG.Tooltip.BreakPointReset": "Actualizar el Punto de Ruptura al valor actual de COR - PVol.", "DG.Skills.ritual": "Ritual", - "DG.RollMode": "Modo de tirada", "DG.Bonds.Relationship": "Relación", - "DG.TypeSkills.Subskills.Painting": "Pintar", - "DG.Sheet.BlockHeaders.Attributes": "ATRIBUTOS", "DG.Sheet.BlockHeaders.Statistics": "CARACTERÍSTICAS", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "DESCRIPCIÓN FÍSICA Y NOTAS", @@ -313,9 +281,7 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVACIONES Y TRANSTORNOS MENTALES", "DG.Sheet.BlockHeaders.PersonalInfoCV": "INFORMACIÓN PERSONAL Y CURRÍCULO", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "LAZOS, RELACIONES Y CONTACTOS", - "DG.AgentSheet.ProfessionPlaceholder": "Profesión (rango, si es necesario)", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Ocultar habilidades no entrenadas", "DG.UnnaturalSheet.NotesLabel": "Notas", "DG.UnnaturalSheet.ShortDescriptionPlaceholder": "Descripción de la criatura o tipo", @@ -323,39 +289,31 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Habilidades & notas", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Ataques & habilidades", "DG.NPCSheet.BreakPointAbbreviation": "PR", - "DG.ImpossibleLanscapes.IHaveSeenThe": "He visto el ", "DG.ImpossibleLanscapes.YellowSign": "Signo Amarillo", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Corrup", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "ción", "DG.ImpossibleLanscapes.Gift": "Don", "DG.ImpossibleLanscapes.Insight": "Visión", - "DG.VehicleSheet.NotesTabName": "Notas", "DG.VehicleSheet.GearTabName": "Equipo", "DG.VehicleSheet.NotesLabel": "Notas", "DG.VehicleSheet.DescriptionPlaceHolder": "Descripción del vehículo", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Marca el cuadro de comprobación de lazo dañado hasta que termine la próxima escena de hogar.", "DG.Generic.ClearAll": "Borrar todo", - "DG.Generic.SanDamage": "Daño COR", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Objetivo", "DG.ItemWindow.Weapons.SkillModifier": "Mod. habilidad", "DG.ItemWindow.Custom": "Personalizado", - "DG.Sheet.class.item": "Hoja de objeto", "DG.Sheet.class.agent": "Hoja de agente", "DG.Sheet.class.npc": "Hoja de PNJ", "DG.Sheet.class.unnatural": "Hoja de antinatural", "DG.Sheet.class.vehicle": "Hoja de vehículo", - "TYPES.Actor.agent": "Agente", "TYPES.Actor.npc": "PNJ", "TYPES.Actor.unnatural": "Antinatural", "TYPES.Actor.vehicle": "Vehículo", - "TYPES.Item.armor": "Armadura", "TYPES.Item.bond": "Lazo", "TYPES.Item.gear": "Equipo", @@ -363,13 +321,10 @@ "TYPES.Item.weapon": "Arma", "TYPES.Item.tome": "Tomo", "TYPES.Item.ritual": "Ritual", - "DG.Gear.Tomes": "TOMOS", "DG.Gear.Rituals": "RITUALES", - "DG.Tomes.Name": "Nombre", "DG.Ritual.Name": "Nombre", - "DG.Tome.Name": "Nombre", "DG.Tome.Language": "Idioma", "DG.Tome.StudyTime": "Tiempo de estudio", @@ -381,15 +336,12 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Pérdida de Cordura al estudiarlo", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Datos | Controlador", "DG.ItemWindow.Tome.HandlerNotes": "Notas | Controlador", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "COR activación", "DG.ItemWindow.Ritual.ActivationTime": "Tiempo de activación", "DG.ItemWindow.Ritual.ActivationCosts": "Coste de activación", "DG.ItemWindow.Ritual.StudyTime": "Tiempo de estudio", "DG.ItemWindow.Ritual.LearnedSanityDamage": "COR aprendizaje", - "DG.MissionPaused": "Misión en pausa", - "DG.Settings.charactersheet.name": "Estilo de hoja de personaje", "DG.Settings.charactersheet.hint": "Elegir el estilo de las hojas de personaje. 'Programa' se ajusta a una agencia gubernamental moderna, mientras que 'Vaquero/Forajido' es antiguo, crudo, estilo vieja máquina de escribir.", "DG.Settings.charactersheet.cowboys": "Los vaqueros", @@ -417,15 +369,12 @@ "DG.Settings.skillFailure.name": "Marcar automáticamente fallo en habilidad", "DG.Settings.skillFailure.hint": "Active este ajuste para activar la casilla al fallar una habilidad. El Supervisor puede deshacer esta acción desde la tarjeta del chat de la tirada.", "DG.Settings.Saved": "Los ajustes han sido guardados.", - "DG.SettingsMenu.automation.name": "Menú de automatización", "DG.SettingsMenu.automation.label": "Menú de automatización", "DG.SettingsMenu.automation.title": "Menú de ajustes", - "DG.SettingsMenu.handler.name": "Ajustes del Supervisor", "DG.SettingsMenu.handler.label": "Ajustes del Supervisor", "DG.SettingsMenu.handler.title": "Ajustes solo para el Supervisor", - "DG.Messages.Skillsmark.Marked": "Habilidad marcada", "DG.Messages.Skillsmark.Tooltip": "Consulte la página 29 del Manual del Agente" } diff --git a/lang/fr.json b/lang/fr.json index 0bd6768..2ae6de1 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "NOM, PRÉNOM, INITIALES", "DG.RollLuck": "Jet de Chance", "DG.Luck": "Chance", - "DG.FallbackText.newItem": "Nouveau/el/elle {type}", - "DG.Skills.accounting": "Comptabilité", "DG.Skills.alertness": "Vigilance", "DG.Skills.anthropology": "Anthropologie", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Cbt à mains nues", "DG.Skills.unnatural": "Inconcevable", "DG.Skills.flight": "Voler", - "DG.Skills.Tooltip.accounting": "Repérez les signaux d'alerte financiers tels que les actifs cachés, les sociétés écrans ou les détournements de fonds.", "DG.Skills.Tooltip.alertness": "Remarquez les dangers cachés, les mouvements subtils ou les sons discrets avant qu'ils ne deviennent des menaces.", "DG.Skills.Tooltip.anthropology": "Comprendre la moralité, les croyances religieuses, les coutumes et les moeurs, ainsi qu’à identifier (mais pas à traduire) les langues obscures", @@ -82,7 +79,6 @@ "DG.Skills.Tooltip.swim": "Pour la plupart des Agents, la natation est un loisir. Servez-vous de la compétence Natation lors des situations de crise.", "DG.Skills.Tooltip.unarmed_combat": "Autodéfense. Servez-vous de Combat à mains nues pour blesser ou tuer un adversaire avec vos seuls poings (ainsi que vos pieds, vos coudes, vos dents et votre tête).", "DG.Skills.Tooltip.unnatural": "Connaissance des secrets fondamentaux de l’univers, propres à provoquer la folie.", - "DG.Skills.AddTypedOrCustomSkill": "Ajouter une compétence personnalisée", "DG.Skills.AddSkill": "Ajout Compétence", "DG.Skills.Apply": "Appliquer", @@ -95,7 +91,6 @@ "DG.Skills.EditSkill": "Éditer compétence", "DG.Skills.SkillGroup": "Groupe de compétence", "DG.Skills.SkillName": "Nom de la compétence", - "DG.TypeSkills.Art": "Art", "DG.TypeSkills.Craft": "Artisanat", "DG.TypeSkills.ForeignLanguage": "Langues étrangères", @@ -103,7 +98,6 @@ "DG.TypeSkills.Pilot": "Pilotage", "DG.TypeSkills.Science": "Science", "DG.TypeSkills.Other": "Autre", - "DG.TypeSkills.Tooltip.Art": "Maîtrise de la création ou de l’exécution d’une oeuvre capable d’altérer les émotions et les opinions.", "DG.TypeSkills.Tooltip.Craft": "Fabriquer et réparer des outils et structures sophistiqués.", "DG.TypeSkills.Tooltip.ForeignLanguage": "Parler couramment une autre langue. Chaque langue étrangère représente une compétence distincte.", @@ -111,7 +105,6 @@ "DG.TypeSkills.Tooltip.Pilot": "Piloter, diriger et naviguer avec des véhicules aquatiques, aériens ou aérospatiaux.", "DG.TypeSkills.Tooltip.Science": "Étude approfondie des phénomènes qui nous entourent.", "DG.TypeSkills.Tooltip.Other": "Autre", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Ajouter un Entraînement Spécial", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Compétences Personnalisées/Typées", "DG.SpecialTraining.Dialog.DropDown.Skills": "Compétences", @@ -120,7 +113,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Nom", "DG.SpecialTraining.Dialog.SkillLabel": "Compétence ou Statistique", "DG.SpecialTraining.Dialog.Title": "Créer/Éditer un Entraînement Spécial", - "DG.Attributes.HP": "PV", "DG.Attributes.WP": "VOL", "DG.Attributes.SAN": "SAN", @@ -130,7 +122,6 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "POU", "DG.Attributes.cha": "CHA", - "DG.Attributes.Tooltip.HP": "Points de vie, un personnage devient inconscient à 2PV et meurt à 0PV", "DG.Attributes.Tooltip.WP": "Les Points de Volonté (VOL) sont le carburant psychologique. Les Agents ont besoin de Points de Volonté pour continuer à travailler malgré l'épuisement ou les situations mentalement éprouvantes. Voir la page 47 du Manuel de l'agent pour plus de détails", "DG.Attributes.Tooltip.SAN": "Les points de santé mentale (SAN) indiquent un lien avec l'humanité et la réalité telles que la plupart des gens les perçoivent. Cliquer pour effectuer un jet de SAN.", @@ -140,7 +131,6 @@ "DG.Attributes.Tooltip.int": "Voir Page 19 du Manuel de l'Agent - Intelligence", "DG.Attributes.Tooltip.pow": "Voir Page 19 du Manuel de l'Agent - Pouvoir", "DG.Attributes.Tooltip.cha": "Voir Page 19 du Manuel de l'Agent - Charisme", - "DG.Navigation.Agent.skills": "Compétences", "DG.Navigation.Agent.physical": "Statistiques", "DG.Navigation.Agent.motivations": "Psychologie", @@ -151,7 +141,6 @@ "DG.Navigation.NPC.gear": "Attaques & Capacités", "DG.Navigation.Vehicle.gear": "Équipement", "DG.Navigation.Vehicle.notes": "Notes", - "DG.About.Changelog": "CHANGELOG (ONLINE)", "DG.About.Copyright": "Publié par Arc Dream Publishing en accord avec Delta Green Partnership. La propriété intellectuelle connue sous le nom de Delta Green est une marque déposée et protégée par les droits d'auteur appartenant à Delta Green Partnership, qui en a autorisé l'utilisation ici.", "DG.About.License": "Le contenu de ce système est concédé sous licence conformément aux conditions générales incluses dans le fichier licence, à l'exception des éléments qui font partie de la propriété intellectuelle de Delta Green.", @@ -159,9 +148,7 @@ "DG.About.OnlineHelpTooltip": "Lancer le navigateur avec l'aide GitHub en ligne.", "DG.About.OnlineBugTracker": "Questions, rapports de bug ou demandes de fonctionnalités (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Lancer le navigateur avec le suivi des problèmes GitHub.", - "DG.Roll.Rolling": "Jet de dés", - "DG.Roll.Target": "Cible: ", "DG.Roll.Success": "Succés", "DG.Roll.Failure": "Échec", "DG.Roll.CriticalSuccess": "Succés critique!", @@ -173,14 +160,11 @@ "DG.Roll.RollingDamageFor": "Jets de dégâts pour", "DG.Roll.Or": "Ou", "DG.Roll.Roll": "Lancer", - "DG.Physical.Wounds": "Blessures", "DG.Physical.FirstAidLabel": "Jet de Premiers Secours depuis sa dernière blessure: ", "DG.Physical.Exhausted": "Épuisé:", "DG.Physical.ExhaustionPenalty": "Pénalité:", - "DG.Generic.AddButtonLabel": "Ajouter", - "DG.Mental.BreakingPoint": "Seuil de Rupture: ", "DG.Mental.Tooltip.BreakingPoint": "le Seuil de Rupture est le point à partir duquel la perte de SAN déclenche des troubles psychiques à long terme. Dès que la SAN de l’Agent atteint le Seuil de Rupture, il reçoit un trouble. Réinitialisez le Seuil de Rupture : il est désormais égal à la nouvelle valeur de SAN moins POU", "DG.Mental.Reset": "Réinit.", @@ -193,7 +177,6 @@ "DG.Mental.AdaptedToHelplessness": "Accoutumée à l'impuissance", "DG.Mental.Motivations": "Motivations", "DG.Mental.Disorders": "Troubles psychiques", - "DG.Gear.WeaponTypes.Firearms": "Armes à feu", "DG.Gear.WeaponTypes.Melee": "Mêlée", "DG.Gear.Weapons": "ARMES", @@ -204,7 +187,6 @@ "DG.Gear.ArmorPiercing": "Perforant", "DG.Gear.ArmorRating": "Niveau d'Armure", "DG.Gear.Equipped": "Équipé", - "DG.CV.Sex": "Sexe", "DG.CV.SexPlaceholder": "Sexe", "DG.CV.AgeAndDoB": "Âge et Date de Naissance", @@ -215,12 +197,10 @@ "DG.CV.NationalityPlaceholder": "Nationalité", "DG.CV.Education": "Études et Carrière", "DG.CV.EducationPlaceholder": "Études et Carrière", - "DG.Bonds.Bond": "Attaches", "DG.Bonds.Name": "Nom", "DG.Bonds.Score": "Score", "DG.Bonds.NewBond": "Nouvelle Attache", - "DG.Tooltip.MaximumHP": "La Santé maximum est la moyenne de FOR+CON, arrondi au supérieur", "DG.Tooltip.MaximumWP": "La Volonté maximum est égale au POUVOIR", "DG.Tooltip.CurrentSanityPartOne": "Santé Mentale actuelle, le seuil de rupture actuel est ", @@ -236,17 +216,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Être adapté à l'impuissance signifie que la motivation personnelle de votre agent souffre lorsqu'il se retire du monde qui l'entoure. Dès que votre agent s'adapte à l'impuissance, il ou elle perd définitivement 1D6 POU", "DG.Tooltip.ToggleEquippedState": "Cliquez pour indiquer si cet élément est équipé ou non", "DG.Tooltip.Exhaustion": "Un agent qui travaille trop longtemps, qui fait face à un danger extrême ou des blessures sans repos devient épuisé", - "DG.Gear.Expense.NA": "N/A ou Aucun", "DG.Gear.Expense.Incidental": "Courante", "DG.Gear.Expense.Standard": "Standard", "DG.Gear.Expense.Unusual": "Importante", "DG.Gear.Expense.Major": "Substantielle", "DG.Gear.Expense.Extreme": "Considérable", - "DG.ItemWindow.Bonds.Description": "Description/développements", "DG.ItemWindow.Bonds.ScoreTooltip": "Un score plus élevé représente une relation plus forte entre l'Agent et l'Attache", - "DG.ItemWindow.Motivations.Attributes": "Caractéristiques", "DG.ItemWindow.Motivations.Motivation": "Motivation", "DG.ItemWindow.Motivations.Description": "Description/Notes", @@ -257,19 +234,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Cochez cette case si vous ajoutez un trouble psychique, ou si une motivation existante a été perdue en raison de l'acquisition d'un trouble", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "Le trouble psychique a été \"Soigné\"", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Un succès critique sur un jet de chance peut guérir un trouble psychique. Il peut cependant réapparaître la prochaine fois qu'un seuil de rupture est atteint", - "DG.ItemWindow.Gear.Attributes": "Caractéristiques", "DG.ItemWindow.Gear.Description": "Description", "DG.ItemWindow.Gear.NamePlaceholder": "Nom", "DG.ItemWindow.Gear.Expense": "Dépense", "DG.ItemWindow.Gear.Equipped": "Équipé", - "DG.ItemWindow.Armor.Description": "Description", "DG.ItemWindow.Armor.NamePlaceholder": "Nom", "DG.ItemWindow.Armor.ArmorRating": "Valeur d'Armure", "DG.ItemWindow.Armor.Expense": "Dépense", "DG.ItemWindow.Armor.Equipped": "Équipé", - "DG.ItemWindow.Weapons.Description": "Description", "DG.ItemWindow.Weapons.NamePlaceholder": "Nom", "DG.ItemWindow.Weapons.Skill": "Compétence", @@ -283,27 +257,21 @@ "DG.ItemWindow.Weapons.Ammo": "Munitions (Nombre/Type)", "DG.ItemWindow.Weapons.Range": "Portée", "DG.ItemWindow.Weapons.KillRadius": "Rayon de mortalité", - "DG.ModifySkillRollDialogue.Title": "Modifier le Lancer", "DG.ModifySkillRollDialogue.Test": "Test de:", "DG.ModifySkillRollDialogue.OriginalTarget": "Niveau actuel:", "DG.ModifySkillRollDialogue.TargetModifier": "Modificateur (+/-):", "DG.ModifySkillRollDialogue.ModifierTooltip": "Donnez ici un modificateur positif ou négatif à ajouter/soustraire à la cible pour le test de compétence.", - "DG.ModifyDamageRollDialogue.Weapon": "Arme:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Utilisez ce champ pour modifier la partie extérieure de la formule, par exemple pour doubler le résultat.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Utilisez ce champ pour ajouter un modificateur fixe tel que '+1' ou un autre terme de dé tel que '+1d4'.", - "DG.Roll.Inhuman": "Non humain", "DG.Tooltip.BreakPoint": "Le seuil de rupture actuel, réinitialisé lorsque la santé mentale actuelle tombe à cette valeur ou en dessous. Le seuil de rupture de départ est SAN - POU", "DG.Tooltip.BreakPointReset": "Le seuil de rupture est réinitialisé à la valeur actuelle SAN - POU", "DG.Skills.ritual": "Rituel", - "DG.RollMode": "Mode de Jet", "DG.Bonds.Relationship": "Type d'Attache", - "DG.TypeSkills.Subskills.Painting": "Peinture", - "DG.Sheet.BlockHeaders.Attributes": "CARACTÉRISTIQUES", "DG.Sheet.BlockHeaders.Statistics": "STATISTIQUES", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "DESCRIPTION PHYSIQUE / NOTES", @@ -313,9 +281,7 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVATIONS ET TROUBLES PSYCHIQUES", "DG.Sheet.BlockHeaders.PersonalInfoCV": "DÉTAILS PERSONNELS ET NOTES", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "ATTACHES, RELATIONS ET CONTACTS", - "DG.AgentSheet.ProfessionPlaceholder": "PROFESSION (RANG LE CAS ÉCHÉANT)", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Cacher compétences inconnues", "DG.UnnaturalSheet.NotesLabel": "Notes", "DG.UnnaturalSheet.ShortDescriptionFallbackText": "Description de créature ou du type de créature", @@ -323,39 +289,31 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Compétences & Notes", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Attaques & Capacités", "DG.NPCSheet.BreakPointAbbreviation": "SR", - "DG.ImpossibleLanscapes.IHaveSeenThe": "J'AI VU LE", "DG.ImpossibleLanscapes.YellowSign": "SIGNE JAUNE", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Corrup", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "tion", "DG.ImpossibleLanscapes.Gift": "Don", "DG.ImpossibleLanscapes.Insight": "Intuition", - "DG.VehicleSheet.NotesTabName": "Notes", "DG.VehicleSheet.GearTabName": "Équipement", "DG.VehicleSheet.NotesLabel": "NOTES", "DG.VehicleSheet.DescriptionPlaceHolder": "Description du Véhicule", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Quand une Attache est endommagée, cochez sa case jusqu'à la fin de la prochaine scène privée.", "DG.Generic.ClearAll": "Supprimer les coches", - "DG.Generic.SanDamage": "Perte de SAN", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Cible", "DG.ItemWindow.Weapons.SkillModifier": "Modificateur de compétence", "DG.ItemWindow.Custom": "Personnalisé", - "DG.Sheet.class.item": "Feuille d'Équipement", "DG.Sheet.class.agent": "Feuille d'Agent", "DG.Sheet.class.npc": "Feuille de PNJ", "DG.Sheet.class.unnatural": "Feuille d'Inconcevable", "DG.Sheet.class.vehicle": "Feuille de Véhicule", - "TYPES.Actor.agent": "Agent", "TYPES.Actor.npc": "PNJ", "TYPES.Actor.unnatural": "Inconcevable", "TYPES.Actor.vehicle": "Véhicule", - "TYPES.Item.armor": "Armure", "TYPES.Item.bond": "Lien", "TYPES.Item.gear": "Équipement", @@ -363,13 +321,10 @@ "TYPES.Item.weapon": "Arme", "TYPES.Item.tome": "Ouvrage", "TYPES.Item.ritual": "Rituel", - "DG.Gear.Tomes": "OUVRAGES", "DG.Gear.Rituals": "RITUELS", - "DG.Tomes.Name": "Titre", "DG.Ritual.Name": "Nom", - "DG.Tome.Name": "Titre", "DG.Tome.Language": "Langage", "DG.Tome.StudyTime": "Temps d'Étude", @@ -381,15 +336,12 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Perte de SAN dûe à l'étude", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Officier Uniquement", "DG.ItemWindow.Tome.HandlerNotes": "Notes de l'Officier", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "Dégâts SAN Activation", "DG.ItemWindow.Ritual.ActivationTime": "Temp d'Activation", "DG.ItemWindow.Ritual.ActivationCosts": "Coût d'Activation", "DG.ItemWindow.Ritual.StudyTime": "Temps d'étude", "DG.ItemWindow.Ritual.LearnedSanityDamage": "Dégâts SAN Étude", - "DG.MissionPaused": "Mission en pause", - "DG.Settings.charactersheet.name": "Style de Feuille de Personnage", "DG.Settings.charactersheet.hint": "Choisissez le style des fiches de Personnage. Le Programme est un style gouvernemental plus moderne, tandis que Les Cowboys et les Hors-la-loi ont un style machine à écrire plus ancien et plus brut.", "DG.Settings.charactersheet.cowboys": "Les Cowboys", @@ -417,15 +369,12 @@ "DG.Settings.skillFailure.name": "Marquer automatiquement l'échec d'une compétence", "DG.Settings.skillFailure.hint": "Activez ce paramètre pour toujours cocher la case « Échec » en cas d'échec d'une compétence. Le MJ peut annuler l'action à partir du message du jet dans le chat.", "DG.Settings.Saved": "Les paramètres actuels ont été enregistrés.", - "DG.SettingsMenu.automation.name": "Menu Automatisaton", "DG.SettingsMenu.automation.label": "Menu Automatisaton", "DG.SettingsMenu.automation.title": "Config. Automatisation", - "DG.SettingsMenu.handler.name": "Config. Officier Traitant", "DG.SettingsMenu.handler.label": "Config. Officier Traitant", "DG.SettingsMenu.handler.title": "Config. Officier Traitant seulement", - "DG.Messages.Skillsmark.Marked": "Cette compétence est marquée.", "DG.Messages.Skillsmark.Tooltip": "Voir p. 29 du Manuel de l'agent." } diff --git a/lang/ja.json b/lang/ja.json index 54107ca..99df8f1 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -3,10 +3,8 @@ "DG.AgentNameFieldTooltip": "姓, 名 (必要に応じ通称)", "DG.RollLuck": "幸運ロール", "DG.Luck": "幸運", - "DG.FallbackText.ProfessionAndOrRank": "職業 (必要に応じ役職)", "DG.FallbackText.newItem": "新規{type}", - "DG.Skills.accounting": "経理", "DG.Skills.alertness": "警戒", "DG.Skills.anthropology": "人類学", @@ -45,7 +43,6 @@ "DG.Skills.unarmed_combat": "徒手格闘", "DG.Skills.unnatural": "非自然", "DG.Skills.flight": "飛行", - "DG.Skills.AddTypedOrCustomSkill": "類型/カスタム技能を追加", "DG.Skills.AddSkill": "技能を追加", "DG.Skills.Apply": "適用", @@ -58,7 +55,6 @@ "DG.Skills.EditSkill": "技能を編集", "DG.Skills.SkillGroup": "技能グループ", "DG.Skills.SkillName": "技能の名前", - "DG.TypeSkills.Art": "芸術", "DG.TypeSkills.Craft": "製作", "DG.TypeSkills.ForeignLanguage": "ほかの言語", @@ -66,7 +62,6 @@ "DG.TypeSkills.Pilot": "操縦", "DG.TypeSkills.Science": "科学", "DG.TypeSkills.Other": "その他", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "特殊訓練を追加", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "カスタム/類型技能", "DG.SpecialTraining.Dialog.DropDown.Skills": "技能", @@ -75,7 +70,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "名前", "DG.SpecialTraining.Dialog.SkillLabel": "技能/能力値", "DG.SpecialTraining.Dialog.Title": "特殊訓練の作成/編集", - "DG.Attributes.HP": "HP", "DG.Attributes.WP": "WP", "DG.Attributes.SAN": "SAN", @@ -85,21 +79,17 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "POW", "DG.Attributes.cha": "CHA", - "DG.Navigation.Skills": "技能", "DG.Navigation.Physical": "能力値", "DG.Navigation.Mental": "精神", "DG.Navigation.Gear": "装備", "DG.Navigation.CV": "経歴", "DG.Navigation.Contacts": "絆", - "DG.About.OnlineHelp": "システムドキュメンテーション(オンライン)", "DG.About.OnlineHelpTooltip": "オンラインGitHubヘルプをブラウザで開く。", "DG.About.OnlineBugTracker": "質問、バグ報告、機能リクエスト(オンライン)", "DG.About.OnlineBugTrackerTooltip": "オンラインGitHubのissueトラッカーをブラウザで開く。", - "DG.Roll.Rolling": "ローリング", - "DG.Roll.Target": "目標値: ", "DG.Roll.Success": "成功", "DG.Roll.Failure": "失敗", "DG.Roll.Critical": "クリティカル", @@ -110,14 +100,11 @@ "DG.Roll.RollingDamageFor": "ダメージロール: ", "DG.Roll.Or": " / ", "DG.Roll.Roll": "ロール", - "DG.Physical.Wounds": "傷跡", "DG.Physical.FirstAidLabel": "応急手当済: ", "DG.Physical.Exhausted": "疲労:", "DG.Physical.ExhaustionPenalty": "ペナルティ:", - "DG.Generic.AddButtonLabel": "追加", - "DG.Mental.BreakingPoint": "限界点: ", "DG.Mental.Reset": "リセット", "DG.Mental.SanityLossIncidentsLabel": "SANを失ったが正気を保った出来事", @@ -127,7 +114,6 @@ "DG.Mental.AdaptedToHelplessness": "無力への慣れ", "DG.Mental.Motivations": "モチベーション", "DG.Mental.Disorders": "精神障害", - "DG.Gear.Weapons": "武器", "DG.Gear.Armor": "防具", "DG.Gear.OtherGear": "他の装備", @@ -136,7 +122,6 @@ "DG.Gear.ArmorPiercing": "装甲貫通", "DG.Gear.ArmorRating": "装甲値", "DG.Gear.Equipped": "装備", - "DG.CV.Sex": "性別", "DG.CV.SexPlaceholder": "性別", "DG.CV.AgeAndDoB": "年齢・誕生日", @@ -147,12 +132,10 @@ "DG.CV.NationalityPlaceholder": "国籍", "DG.CV.Education": "教育", "DG.CV.EducationPlaceholder": "教育と訓練", - "DG.Bonds.Bond": "絆", "DG.Bonds.Name": "名前", "DG.Bonds.Score": "スコア", "DG.Bonds.NewBond": "新規絆", - "DG.Attributes.Tooltip.HP": "ヒットポイント、HPが2になるとエージェントは意識を失い、HPが0で死亡する。", "DG.Tooltip.MaximumHP": "最大HPはSTRとCONの平均(切り上げ)。", "DG.Attributes.Tooltip.WP": "意志力ポイント(WP)は精神的な燃料。エージェントは、疲労や精神的に負担のかかる状況下でも活動を続けるために、WPを必要とする。詳細はAgent's Handbook 47ページ参照。", @@ -171,17 +154,14 @@ "DG.Tooltip.AdaptedToHelplessness": "無力に慣れるということは、彼らが周囲の世界から身を引くことによって、エージェントの個人的な意欲が損なわれることを意味する。無力に慣れるとすぐに、エージェントは1D6のPOWを永久的に失う。", "DG.Tooltip.ToggleEquippedState": "クリックでこのアイテムの装備の有無を切り替え。", "DG.Tooltip.Exhaustion": "長時間働きすぎたり、休息を取らずに極度の危険や負傷に直面したりすると、エージェントは疲労する。 これが起こるのはハンドラー次第だが、目安として、一晩眠らずに過ごしたり、SANやHPを失った後に休息を拒否したりすると、疲労につながる。", - "DG.Gear.Expense.NA": "N/A", "DG.Gear.Expense.Incidental": "付帯費用", "DG.Gear.Expense.Standard": "標準", "DG.Gear.Expense.Unusual": "特別", "DG.Gear.Expense.Major": "高額", "DG.Gear.Expense.Extreme": "極高額", - "DG.ItemWindow.Bonds.Description": "説明/進展", "DG.ItemWindow.Bonds.ScoreTooltip": "スコアが高いほど、エージェントと絆の関係性が強いことを示す。", - "DG.ItemWindow.Motivations.Attributes": "能力値", "DG.ItemWindow.Motivations.Motivation": "モチベーション", "DG.ItemWindow.Motivations.Description": "説明とノート", @@ -192,19 +172,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "精神障害である場合このボックスをチェックする", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "精神障害は\"治癒\"した", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "セラピーでの幸運ロールでクリティカル成功をすると、精神障害を「治療」することができる。しかし、限界点に達した場合それが次回に再発する可能性がある。Agent's Handbook 78~79ページの「Go To Therapy」を参照。", - "DG.ItemWindow.Gear.Attributes": "能力値", "DG.ItemWindow.Gear.Description": "説明", "DG.ItemWindow.Gear.NamePlaceholder": "名前", "DG.ItemWindow.Gear.Expense": "費用", "DG.ItemWindow.Gear.Equipped": "装備", - "DG.ItemWindow.Armor.Description": "説明", "DG.ItemWindow.Armor.NamePlaceholder": "名前", "DG.ItemWindow.Armor.ArmorRating": "装甲値", "DG.ItemWindow.Armor.Expense": "費用", "DG.ItemWindow.Armor.Equipped": "装備", - "DG.ItemWindow.Weapons.Description": "説明", "DG.ItemWindow.Weapons.NamePlaceholder": "名前", "DG.ItemWindow.Weapons.Skill": "技能", @@ -218,27 +195,21 @@ "DG.ItemWindow.Weapons.Ammo": "弾 (数量/種類)", "DG.ItemWindow.Weapons.Range": "射程", "DG.ItemWindow.Weapons.KillRadius": "致命半径", - "DG.ModifySkillRollDialogue.Title": "ロールの修正", "DG.ModifySkillRollDialogue.Test": "テスト:", "DG.ModifySkillRollDialogue.OriginalTarget": "元の目標値:", "DG.ModifySkillRollDialogue.TargetModifier": "目標値修正:", "DG.ModifySkillRollDialogue.ModifierTooltip": "技能チェックの目標値にプラスまたはマイナスの修正を加える。", - "DG.ModifyDamageRollDialogue.Weapon": "武器:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "ここでダイスの式の外を変えられる、結果を2倍にするために使われる。", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "ここでダイスの式の内側を変えられる、結果を「+1」とか、「+1d4」とかにするために使われる。", - "DG.Roll.Inhuman": "人ならざるもの", "DG.Tooltip.BreakPoint": "現在の限界点、現在SANがこの値以下に低下した場合にリセットする。初期値はSAN - POW。", "DG.Tooltip.BreakPointReset": "限界点を現在SAN - WPにリセットする。", "DG.Skills.ritual": "儀式", - "DG.RollMode": "ロールモード", "DG.Bonds.Relationship": "関係", - "DG.TypeSkills.Subskills.Painting": "絵画", - "DG.Sheet.BlockHeaders.Attributes": "能力値", "DG.Sheet.BlockHeaders.Statistics": "能力値", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "身体的特徴とメモ", @@ -248,7 +219,6 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "モチベーションと精神障害", "DG.Sheet.BlockHeaders.PersonalInfoCV": "個人情報 / 経歴", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "絆 / 関係 / 協力者", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "初期値の技能を非表示", "DG.UnnaturalSheet.NotesLabel": "ノート", "DG.UnnaturalSheet.ShortDescriptionFallbackText": "怪物や種類の説明", @@ -256,33 +226,26 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "技能・ノート", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "攻撃・能力", "DG.NPCSheet.BreakPointAbbreviation": "限界点", - "DG.ImpossibleLanscapes.IHaveSeenThe": "目撃: ", "DG.ImpossibleLanscapes.YellowSign": "黄色の印", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "腐", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "敗", "DG.ImpossibleLanscapes.Gift": "ギフト", "DG.ImpossibleLanscapes.Insight": "ひらめき", - "DG.VehicleSheet.NotesTabName": "ノート", "DG.VehicleSheet.GearTabName": "装備", "DG.VehicleSheet.NotesLabel": "ノート", "DG.VehicleSheet.DescriptionPlaceHolder": "車両の説明", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "次のホームシーンが終了するまで、ダメージを負った絆のボックスにチェックを入れる。", "DG.Generic.ClearAll": "全てのチェックを消す", - "DG.Generic.SanDamage": "SANダメージ", - "DG.ItemWindow.Weapons.CustomSkillTarget": "目標値", "DG.ItemWindow.Weapons.SkillModifier": "技能修正", "DG.ItemWindow.Custom": "カスタム", - "TYPES.Actor.agent": "エージェント", "TYPES.Actor.npc": "NPC", "TYPES.Actor.unnatural": "非自然", "TYPES.Actor.vehicle": "車両", - "TYPES.Item.armor": "防具", "TYPES.Item.bond": "絆", "TYPES.Item.gear": "装備", @@ -290,13 +253,10 @@ "TYPES.Item.weapon": "武器", "TYPES.Item.tome": "魔道書", "TYPES.Item.ritual": "儀式", - "DG.Gear.Tomes": "魔道書", "DG.Gear.Rituals": "儀式", - "DG.Tomes.Name": "名前", "DG.Ritual.Name": "名前", - "DG.Tome.Name": "名前", "DG.Tome.Language": "言語", "DG.Tome.StudyTime": "研究期間", @@ -308,13 +268,11 @@ "DG.ItemWindow.Tome.StudySanityDamage": "研究時SANダメージ", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "ハンドラー限定", "DG.ItemWindow.Tome.HandlerNotes": "ハンドラーノート", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "実行時SANダメージ", "DG.ItemWindow.Ritual.ActivationTime": "実行時間", "DG.ItemWindow.Ritual.ActivationCosts": "実行コスト", "DG.ItemWindow.Ritual.StudyTime": "研究期間", "DG.ItemWindow.Ritual.LearnedSanityDamage": "習得時SANダメージ", - "DG.Skills.ruby.accounting": "けいり", "DG.Skills.ruby.alertness": "けいかい", "DG.Skills.ruby.anthropology": "じんるいがく", @@ -353,7 +311,6 @@ "DG.Skills.ruby.unarmed_combat": "としゅかくとう", "DG.Skills.ruby.unnatural": "ひしぜん", "DG.Skills.ruby.flight": "ひこう", - "DG.Messages.Skillsmark.Marked": "このスキルはマークされています", "DG.Messages.Skillsmark.Tooltip": "エージェントハンドブック29ページ参照" } diff --git a/lang/pl.json b/lang/pl.json index c0bbc59..085c19b 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "NAZWISKO, IMIE (PSEUDONIM/KRYPTONIM, JESLI DOTYCZY)", "DG.RollLuck": "Test Szczęścia", "DG.Luck": "Szczęście", - "DG.FallbackText.newItem": "Nowy {type}", - "DG.Skills.accounting": "Księgowość", "DG.Skills.alertness": "Czujność", "DG.Skills.anthropology": "Antropologia", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Walka Bez Broni", "DG.Skills.unnatural": "Zjawiska Nadnaturalne", "DG.Skills.flight": "Latanie", - "DG.Skills.Tooltip.accounting": "Wykrywanie finansowych sygnałów ostrzegawczych, takich jak ukryte aktywa, firmy fasadowe lub defraudacje.", "DG.Skills.Tooltip.alertness": "Dostrzegaj ukryte zagrożenia, subtelne ruchy lub ciche dźwięki, zanim staną się one niebezpieczeństwem.", "DG.Skills.Tooltip.anthropology": "Zrozumienie przekonań, zachowań i tematów tabu w współczesnych kulturach i społeczeństwach.", @@ -82,7 +79,6 @@ "DG.Skills.Tooltip.swim": "Przetrwanie w wodzie pod przymusem - ratowanie innych, ucieczka lub znoszenie niebezpieczeństw.", "DG.Skills.Tooltip.unarmed_combat": "Poskromienie lub zranienie przeciwników przy użyciu pięści, chwytów, kopnięć i innych technik walki wręcz.", "DG.Skills.Tooltip.unnatural": "Rozpoznawanie, badanie lub przypominanie sobie prawd o bytach i siłach wykraczających poza ludzkie zrozumienie.", - "DG.Skills.AddTypedOrCustomSkill": "Dodaj Typową/Niestandardową Umiejętność", "DG.Skills.AddSkill": "Dodaj Umiejętność", "DG.Skills.Apply": "Zastosuj", @@ -95,7 +91,6 @@ "DG.Skills.EditSkill": "Edytuj Umiejętność", "DG.Skills.SkillGroup": "Grupa Umiejętności", "DG.Skills.SkillName": "Nazwa Umiejętności", - "DG.TypeSkills.Art": "Sztuka", "DG.TypeSkills.Craft": "Rzemiosło", "DG.TypeSkills.ForeignLanguage": "Język Obcy", @@ -103,7 +98,6 @@ "DG.TypeSkills.Pilot": "Pilotowanie", "DG.TypeSkills.Science": "Nauka", "DG.TypeSkills.Other": "Inne", - "DG.TypeSkills.Tooltip.Art": "Twórz lub oceniaj dzieła artystyczne i wykrywaj fałszerstwa w danej dziedzinie.", "DG.TypeSkills.Tooltip.Craft": "Budowanie, naprawianie lub sabotowanie specjalistycznych narzędzi lub systemów, takich jak silniki, zamki lub okablowanie.", "DG.TypeSkills.Tooltip.ForeignLanguage": "Mówisz, czytasz i rozumiesz dany język.", @@ -111,7 +105,6 @@ "DG.TypeSkills.Tooltip.Pilot": "Kontrolowanie i nawigowanie pojazdami powietrznymi, morskimi lub kosmicznymi w krytycznych sytuacjach lub warunkach.", "DG.TypeSkills.Tooltip.Science": "Zastosowanie specjalistycznej wiedzy naukowej do interpretacji lub badania świata fizycznego.", "DG.TypeSkills.Tooltip.Other": "Inne", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Dodaj Dodatkowe Szkolenie", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Niestandardowe Umiejętności", "DG.SpecialTraining.Dialog.DropDown.Skills": "Umiejętności", @@ -120,7 +113,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Nazwa", "DG.SpecialTraining.Dialog.SkillLabel": "Umiejętność lub Statystyka", "DG.SpecialTraining.Dialog.Title": "Stwórz/Edytuj Dodatkowe Szkolenie", - "DG.Attributes.HP": "PW", "DG.Attributes.WP": "PSW", "DG.Attributes.SAN": "PP", @@ -130,7 +122,6 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "MOC", "DG.Attributes.cha": "CHA", - "DG.Attributes.Tooltip.HP": "Punkty Wytrzymałości, postać traci przytomność przy 2 PW i umiera przy 0 PW.", "DG.Attributes.Tooltip.WP": "Punkty Siły Woli to paliwo mentalne. Agenci potrzebują Punktów Siły Woli, aby kontynuować mimo wyczerpania lub mentalnych trudności. Zobacz stronę 47 w Podręczniku Agenta po więcej szczegółów.", "DG.Attributes.Tooltip.SAN": "Kliknij, aby wykonać test na Poczytalność...", @@ -140,7 +131,6 @@ "DG.Attributes.Tooltip.int": "Zobacz stronę 19 Podręcznika Agenta – Inteligencja", "DG.Attributes.Tooltip.pow": "Zobacz stronę 19 Podręcznika Agenta – Siła Woli", "DG.Attributes.Tooltip.cha": "Zobacz stronę 19 Podręcznika Agenta – Charyzma", - "DG.Navigation.Agent.skills": "Umiejętności", "DG.Navigation.Agent.physical": "Statystyki", "DG.Navigation.Agent.motivations": "Psychika", @@ -151,7 +141,6 @@ "DG.Navigation.NPC.gear": "Ataki i Zdolności", "DG.Navigation.Vehicle.gear": "Wyposażenie", "DG.Navigation.Vehicle.notes": "Uwagi", - "DG.About.Changelog": "DZIENNIK ZMIAN (ONLINE)", "DG.About.Copyright": "Opublikowano przez Arc Dream Publishing na mocy umowy z Delta Green Partnership. Własność intelektualna znana jako Delta Green jest znakiem towarowym i prawem autorskim należącym do Delta Green Partnership, które udzieliło tutaj licencji na jej wykorzystanie.", "DG.About.License": "Zawartość tego systemu jest licencjonowana zgodnie z warunkami dołączonego pliku licencyjnego, z wyjątkiem tych elementów, które są składnikami własności intelektualnej Delta Green.", @@ -159,9 +148,7 @@ "DG.About.OnlineHelpTooltip": "Uruchom przeglądarkę z dokumentacją online na GitHubie.", "DG.About.OnlineBugTracker": "Pytania, zgłoszenia błędów lub prośby o funkcje (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Uruchom przeglądarkę z trackerem błędów na GitHubie.", - "DG.Roll.Rolling": "Rzut na", - "DG.Roll.Target": "Cel: ", "DG.Roll.Success": "Sukces", "DG.Roll.Failure": "Porażka", "DG.Roll.CriticalSuccess": "Krytyczny sukces!", @@ -173,14 +160,11 @@ "DG.Roll.RollingDamageFor": "Rzut na Obrażenia", "DG.Roll.Or": "Lub", "DG.Roll.Roll": "Rzuć", - "DG.Physical.Wounds": "Rany", "DG.Physical.FirstAidLabel": "Pierwsza pomoc została już podjęta: ", "DG.Physical.Exhausted": "Wyczerpany:", "DG.Physical.ExhaustionPenalty": "Kara:", - "DG.Generic.AddButtonLabel": "Dodaj", - "DG.Mental.BreakingPoint": "Punkt Załamania: ", "DG.Mental.Tooltip.BreakingPoint": "Punkt Załamania to dokładna wartość Poczytalności, przy której Twój Agent został wystarczająco wyczerpany traumą, by rozwinąć nową, długotrwałą chorobę psychiczną. Jeśli Poczytalność osiągnie ten punkt lub spadnie poniżej niego, odejmij Siłę Woli Agenta od nowej Poczytalności. To będzie nowy Punkt Załamania Twojego Agenta.", "DG.Mental.Reset": "Reset", @@ -191,7 +175,6 @@ "DG.Mental.AdaptedToHelplessness": "Adaptacja do bezsilności", "DG.Mental.Motivations": "Motywacje", "DG.Mental.Disorders": "Zaburzenia", - "DG.Gear.WeaponTypes.Firearms": "Broń Palna", "DG.Gear.WeaponTypes.Melee": "Walka wręcz", "DG.Gear.Weapons": "Broń", @@ -202,7 +185,6 @@ "DG.Gear.ArmorPiercing": "Przebicie Pancerza", "DG.Gear.ArmorRating": "Siła Pancerza", "DG.Gear.Equipped": "Wyposażony", - "DG.CV.Sex": "Płeć", "DG.CV.SexPlaceholder": "Płeć", "DG.CV.AgeAndDoB": "Wiek i Data Ur.", @@ -213,12 +195,10 @@ "DG.CV.NationalityPlaceholder": "Narodowość", "DG.CV.Education": "Edukacja", "DG.CV.EducationPlaceholder": "Edukacja lub Szkolenie", - "DG.Bonds.Bond": "Więź", "DG.Bonds.Name": "Nazwa", "DG.Bonds.Score": "Wynik", "DG.Bonds.NewBond": "Nowa Więź", - "DG.Tooltip.MaximumHP": "Maksymalne Punkty Wytrzymałości to średnia STR + CON, zaokrąglona w górę.", "DG.Tooltip.MaximumWP": "Maksymalne PSW to wartość MOC.", "DG.Tooltip.CurrentSanityPartOne": "Aktualne PP, aktualny punkt załamania to ", @@ -234,17 +214,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Zaadaptowanie do bezsilności oznacza, że motywacja agenta cierpi, gdy wycofuje się z otaczającego go świata. Gdy tylko agent dostosuje się do bezsilności, trwale traci 1D6 MOC.", "DG.Tooltip.ToggleEquippedState": "Kliknij, aby przełączyć stan wyposażenia.", "DG.Tooltip.Exhaustion": "Agent, który pracuje zbyt długo lub stawia czoła ekstremalnemu niebezpieczeństwu i obrażeniom bez odpoczynku, staje się wyczerpany. Kiedy to nastąpi, zależy od Oficera Prowadzącego, ale dobrą zasadą jest, że nieprzespana noc lub odmowa odpoczynku po utracie PP lub Punktów Wytrzymałości prowadzi do wyczerpania.", - "DG.Gear.Expense.NA": "N/A albo Brak", "DG.Gear.Expense.Incidental": "Drobny", "DG.Gear.Expense.Standard": "Standardowy", "DG.Gear.Expense.Unusual": "Niezwykły", "DG.Gear.Expense.Major": "Poważny", "DG.Gear.Expense.Extreme": "Ekstremalny", - "DG.ItemWindow.Bonds.Description": "Opis/Rozwój", "DG.ItemWindow.Bonds.ScoreTooltip": "Wyższy wynik oznacza silniejszą relację między agentem a więzią.", - "DG.ItemWindow.Motivations.Attributes": "Atrybuty", "DG.ItemWindow.Motivations.Motivation": "Motywacja", "DG.ItemWindow.Motivations.Description": "Opis/Uwagi", @@ -255,19 +232,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Zaznacz to, jeśli dodajesz zaburzenie, lub jeśli istniejąca motywacja została utracona na skutek zyskania zaburzenia.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "Zaburzenie zostało \"wyleczone\"", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Krytyczny sukces w teście terapii może 'wyleczyć' zaburzenie. Może ono powrócić przy następnym osiągnięciu punktu złamania. Zobacz sekcję Terapia na stronach 78-79 Podręcznika Agenta.", - "DG.ItemWindow.Gear.Attributes": "Atrybuty", "DG.ItemWindow.Gear.Description": "Opis", "DG.ItemWindow.Gear.NamePlaceholder": "Nazwa", "DG.ItemWindow.Gear.Expense": "Koszt", "DG.ItemWindow.Gear.Equipped": "Wyposażony", - "DG.ItemWindow.Armor.Description": "Opis", "DG.ItemWindow.Armor.NamePlaceholder": "Nazwa", "DG.ItemWindow.Armor.ArmorRating": "Siła Pancerza", "DG.ItemWindow.Armor.Expense": "Koszt", "DG.ItemWindow.Armor.Equipped": "Wyposażony", - "DG.ItemWindow.Weapons.Description": "Opis", "DG.ItemWindow.Weapons.NamePlaceholder": "Nazwa", "DG.ItemWindow.Weapons.Skill": "Umiejętność", @@ -281,27 +255,21 @@ "DG.ItemWindow.Weapons.Ammo": "Amunicja (Ilość/Typ)", "DG.ItemWindow.Weapons.Range": "Zasięg", "DG.ItemWindow.Weapons.KillRadius": "Zasięg Śmierci", - "DG.ModifySkillRollDialogue.Title": "Modyfikuj Rzut", "DG.ModifySkillRollDialogue.Test": "Test:", "DG.ModifySkillRollDialogue.OriginalTarget": "Oryginalny Cel:", "DG.ModifySkillRollDialogue.TargetModifier": "Modyfikator Celu:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Podaj dodatni lub ujemny modyfikator, aby dodać/odjąć od celu dla testu umiejętności.", - "DG.ModifyDamageRollDialogue.Weapon": "Broń:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Użyj tego pola, aby zmodyfikować zewnętrzną część formuły, na przykład aby podwoić wynik.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Użyj tego pola, aby dodać modyfikator stały, np. '+1' lub inną jednostkową kostkę, np. '+1d4'.", - "DG.Roll.Inhuman": "Nieludzki", "DG.Tooltip.BreakPoint": "Aktualny Punkt Załamania, resetowany, gdy aktualne PP spada do tego punktu lub poniżej. Początkowy punkt złamania to PP - MOC.", "DG.Tooltip.BreakPointReset": "Zresetuj Punkt Załamania, aby wynosił aktualne PP - PSW.", "DG.Skills.ritual": "Rytuał", - "DG.RollMode": "Tryb Rzutu", "DG.Bonds.Relationship": "Relacja", - "DG.TypeSkills.Subskills.Painting": "Malarstwo", - "DG.Sheet.BlockHeaders.Attributes": "ATRYBUTY", "DG.Sheet.BlockHeaders.Statistics": "STATYSTYKI", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "OPIS FIZYCZNY / UWAGI", @@ -311,9 +279,7 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTYWACJE I ZABURZENIA", "DG.Sheet.BlockHeaders.PersonalInfoCV": "DANE OSOBOWE / CV", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "WIĘZI / RELACJE / KONTAKTY", - "DG.AgentSheet.ProfessionPlaceholder": "ZAWÓD (RANGA, JEŚLI DOTYCZY)", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Ukryj Niewyuczone", "DG.UnnaturalSheet.NotesLabel": "Uwagi", "DG.UnnaturalSheet.ShortDescriptionPlaceholder": "Opis stworzenia lub typu stworzenia", @@ -321,39 +287,31 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Umiejętności i Uwagi", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Ataki i Zdolności", "DG.NPCSheet.BreakPointAbbreviation": "BP", - "DG.ImpossibleLanscapes.IHaveSeenThe": "WIDZIAŁEM", "DG.ImpossibleLanscapes.YellowSign": "ŻÓŁTY ZNAK", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Zepsu", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "cie", "DG.ImpossibleLanscapes.Gift": "Dar", "DG.ImpossibleLanscapes.Insight": "Wgląd", - "DG.VehicleSheet.NotesTabName": "Uwagi", "DG.VehicleSheet.GearTabName": "Wyposażenie", "DG.VehicleSheet.NotesLabel": "UWAGI", "DG.VehicleSheet.DescriptionPlaceHolder": "Opis pojazdu", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Zaznacz pole uszkodzonej więzi, dopóki kolejna scena w domu się nie skończy.", "DG.Generic.ClearAll": "Wyczyść zaznaczenia", - "DG.Generic.SanDamage": "Utrata PP", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Cel", "DG.ItemWindow.Weapons.SkillModifier": "Modyfikator Umiejętności", "DG.ItemWindow.Custom": "Niestandardowy", - "DG.Sheet.class.item": "Karta Przedmiotu", "DG.Sheet.class.agent": "Karta Agenta", "DG.Sheet.class.npc": "Karta NPC", "DG.Sheet.class.unnatural": "Karta Nadnaturalnego", "DG.Sheet.class.vehicle": "Karta Pojazdu", - "TYPES.Actor.agent": "Agent", "TYPES.Actor.npc": "NPC", "TYPES.Actor.unnatural": "Nadnaturalne", "TYPES.Actor.vehicle": "Pojazd", - "TYPES.Item.armor": "Pancerz", "TYPES.Item.bond": "Więź", "TYPES.Item.gear": "Wyposażenie", @@ -361,13 +319,10 @@ "TYPES.Item.weapon": "Broń", "TYPES.Item.tome": "Księga", "TYPES.Item.ritual": "Rytuał", - "DG.Gear.Tomes": "KSIĘGI", "DG.Gear.Rituals": "RYTUAŁY", - "DG.Tomes.Name": "Nazwa", "DG.Ritual.Name": "Nazwa", - "DG.Tome.Name": "Nazwa", "DG.Tome.Language": "Język", "DG.Tome.StudyTime": "Czas Nauki", @@ -379,15 +334,12 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Uszkodzenie PP po Studiowaniu", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Tylko dla Oficera Prowadzącego", "DG.ItemWindow.Tome.HandlerNotes": "Uwagi dla Oficera Prowadzącego", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "Utrata PP przy Aktywacji", "DG.ItemWindow.Ritual.ActivationTime": "Czas Aktywacji", "DG.ItemWindow.Ritual.ActivationCosts": "Koszt Aktywacji", "DG.ItemWindow.Ritual.StudyTime": "Czas Nauki", "DG.ItemWindow.Ritual.LearnedSanityDamage": "Utrata PP po Nauce", - "DG.MissionPaused": "Misja wstrzymana", - "DG.Settings.charactersheet.name": "Styl karty postaci", "DG.Settings.charactersheet.hint": "Wybierz styl kart postaci. „Program” to nowocześniejszy, rządowy styl, natomiast „Kowboj/Banita” to starszy, bardziej surowy styl przypominający maszynę do pisania.", "DG.Settings.charactersheet.cowboys": "Kowboje", @@ -412,15 +364,12 @@ "DG.Settings.skillFailure.name": "Automatyczne zaznaczanie niepowodzenia w umiejętności", "DG.Settings.skillFailure.hint": "Aktywuj tę opcję, aby zawsze zaznaczać pole niepowodzenia po nieudanym teście umiejętności. Oficer prowadzący może cofnąć tę akcję z wiadomości na czacie dotyczącej rzutu.", "DG.Settings.Saved": "Bieżące ustawienia zostały zapisane.", - "DG.SettingsMenu.automation.name": "Menu automatyzacji", "DG.SettingsMenu.automation.label": "Menu automatyzacji", "DG.SettingsMenu.automation.title": "Ustawienia automatyzacji", - "DG.SettingsMenu.handler.name": "Ustawienia Oficera Prowadzącego", "DG.SettingsMenu.handler.label": "Ustawienia Oficera Prowadzącego", "DG.SettingsMenu.handler.title": "Ustawienia tylko dla Oficera prowadzącego", - "DG.Messages.Skillsmark.Marked": "Ta umiejętność jest oznaczona.", "DG.Messages.Skillsmark.Tooltip": "Zobacz str. 29 Podręcznika Agenta." } diff --git a/lang/pt-BR.json b/lang/pt-BR.json index 3bd7a9f..0d31d37 100644 --- a/lang/pt-BR.json +++ b/lang/pt-BR.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "SOBRENOME, NOME (OU PSEUDÔNIMO)", "DG.RollLuck": "Rolar Sorte", "DG.Luck": "Sorte", - "DG.FallbackText.newItem": "Novo {type}", - "DG.Skills.accounting": "Contabilidade", "DG.Skills.alertness": "Prontidão", "DG.Skills.anthropology": "Antropologia", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Combate Desarmado", "DG.Skills.unnatural": "Antinatural", "DG.Skills.flight": "Pilotagem", - "DG.Skills.Tooltip.accounting": "Identificar irregularidades financeiras como ativos ocultos, empresas de fachada ou desvio de recursos.", "DG.Skills.Tooltip.alertness": "Perceber perigos ocultos, movimentos sutis ou sons discretos antes que se tornem ameaças.", "DG.Skills.Tooltip.anthropology": "Compreender crenças, comportamentos e tabus de culturas e sociedades vivas.", @@ -82,7 +79,6 @@ "DG.Skills.Tooltip.swim": "Sobreviver na água sob condições adversas — resgatar outros, escapar ou suportar riscos.", "DG.Skills.Tooltip.unarmed_combat": "Imobilizar ou ferir oponentes usando socos, agarrões, chutes e outras técnicas de combate corporal.", "DG.Skills.Tooltip.unnatural": "Reconhecer, pesquisar ou recordar verdades sobre entidades e forças além da compreensão humana.", - "DG.Skills.AddTypedOrCustomSkill": "Adicionar Habilidade Personalizada", "DG.Skills.AddSkill": "Adicionar Habilidade", "DG.Skills.Apply": "Aplicar", @@ -95,7 +91,6 @@ "DG.Skills.EditSkill": "Editar Habilidade", "DG.Skills.SkillGroup": "Grupo de Habilidade", "DG.Skills.SkillName": "Nome da Habilidade", - "DG.TypeSkills.Art": "Arte", "DG.TypeSkills.Craft": "Ofício", "DG.TypeSkills.ForeignLanguage": "Idioma Estrangeiro", @@ -103,7 +98,6 @@ "DG.TypeSkills.Pilot": "Pilotar", "DG.TypeSkills.Science": "Ciência", "DG.TypeSkills.Other": "Outros", - "DG.TypeSkills.Tooltip.Art": "Criar ou avaliar obras artísticas e identificar falsificações em uma área específica.", "DG.TypeSkills.Tooltip.Craft": "Construir, consertar ou sabotar ferramentas ou sistemas especializados, como motores, fechaduras ou fiação.", "DG.TypeSkills.Tooltip.ForeignLanguage": "Falar, ler e compreender um idioma específico.", @@ -111,7 +105,6 @@ "DG.TypeSkills.Tooltip.Pilot": "Controlar e navegar veículos aéreos, marítimos ou espaciais em situações ou condições críticas.", "DG.TypeSkills.Tooltip.Science": "Aplicar conhecimento científico especializado para interpretar ou investigar o mundo físico.", "DG.TypeSkills.Tooltip.Other": "Outros.", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Adicionar Treinamento Especial", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Habilidades Personalizadas", "DG.SpecialTraining.Dialog.DropDown.Skills": "Habilidades", @@ -120,7 +113,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Nome", "DG.SpecialTraining.Dialog.SkillLabel": "Habilidade ou Estatística", "DG.SpecialTraining.Dialog.Title": "Criar/Editar Treinamento Especial", - "DG.Attributes.HP": "PV", "DG.Attributes.WP": "FV", "DG.Attributes.SAN": "SAN", @@ -130,7 +122,6 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "POD", "DG.Attributes.cha": "CAR", - "DG.Attributes.Tooltip.HP": "Pontos de Vida (PV) indicam a saúde física. Um personagem fica inconsciente com 2 PV e morre ao chegar a 0 PV.", "DG.Attributes.Tooltip.WP": "Pontos de Força de Vontade (FV) indicam a resistência mental e a determinação. Agentes precisam de FV para continuar apesar do cansaço ou de situações mentalmente extenuantes. Veja a página 47 do Manual do Agente para mais detalhes.", "DG.Attributes.Tooltip.SAN": "Pontos de Sanidade (SAN) indicam a conexão com a humanidade e com a realidade como a maioria das pessoas a percebe. Clique para rolar um teste de SAN.", @@ -140,7 +131,6 @@ "DG.Attributes.Tooltip.int": "Veja a página 19 do Manual do Agente — Inteligência.", "DG.Attributes.Tooltip.pow": "Veja a página 19 do Manual do Agente — Poder.", "DG.Attributes.Tooltip.cha": "Veja a página 19 do Manual do Agente — Carisma.", - "DG.Navigation.Agent.skills": "Habilidades", "DG.Navigation.Agent.physical": "Estatísticas", "DG.Navigation.Agent.motivations": "Mental", @@ -151,7 +141,6 @@ "DG.Navigation.NPC.gear": "Ataques & Habilidades", "DG.Navigation.Vehicle.gear": "Equipamento", "DG.Navigation.Vehicle.notes": "Notas", - "DG.About.Changelog": "CHANGELOG (ONLINE)", "DG.About.Copyright": "Publicado pela Arc Dream Publishing por meio de acordo com a Delta Green Partnership. A propriedade intelectual conhecida como Delta Green é uma marca registrada e protegida por direitos autorais pertencente à Delta Green Partnership, que licenciou seu uso neste material.", "DG.About.License": "O conteúdo deste sistema é licenciado de acordo com os termos do arquivo de licença incluído, com exceção dos elementos que fazem parte da propriedade intelectual de Delta Green.", @@ -159,9 +148,7 @@ "DG.About.OnlineHelpTooltip": "Abrir navegador com ajuda online GitHub", "DG.About.OnlineBugTracker": "Dúvidas, Relatos de Erros ou Solicitações de Funcionalides (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Abrir navegador com rastreador de erros GitHub", - "DG.Roll.Rolling": "Rolando", - "DG.Roll.Target": "Alvo: ", "DG.Roll.Success": "Sucesso", "DG.Roll.Failure": "Falha", "DG.Roll.CriticalSuccess": "Sucesso Crítico!", @@ -173,14 +160,11 @@ "DG.Roll.RollingDamageFor": "Rolando DANO para", "DG.Roll.Or": "Ou", "DG.Roll.Roll": "Rolar", - "DG.Physical.Wounds": "Ferimentos", "DG.Physical.FirstAidLabel": "Já houve tentativa de Primeiro Socorros: ", "DG.Physical.Exhausted": "Exausto(a):", "DG.Physical.ExhaustionPenalty": "Penalidade:", - "DG.Generic.AddButtonLabel": "Adicionar", - "DG.Mental.BreakingPoint": "Ponto de Ruptura: ", "DG.Mental.Tooltip.BreakingPoint": "O Ponto de Ruptura é o valor exato de SAN no qual seu Agente foi suficientemente desgastado pelo trauma a ponto de desenvolver um novo distúrbio mental de longo prazo. Se a SAN atingir esse valor ou menos, subtraia o POD do Agente da nova SAN. Esse resultado passa a ser o novo Ponto de Ruptura do Agente.", "DG.Mental.Reset": "Resetar", @@ -193,7 +177,6 @@ "DG.Mental.AdaptedToHelplessness": "Adaptado ao Desamparo", "DG.Mental.Motivations": "Motivações", "DG.Mental.Disorders": "Distúrbios", - "DG.Gear.Weapons": "Armas", "DG.Gear.WeaponTypes.Firearms": "Armas de Fogo", "DG.Gear.WeaponTypes.Melee": "Armas Corpo a Corpo", @@ -204,7 +187,6 @@ "DG.Gear.ArmorPiercing": "Perfuração de Armadura", "DG.Gear.ArmorRating": "Categoria de Armadura", "DG.Gear.Equipped": "Equipado", - "DG.CV.Sex": "Sexo", "DG.CV.SexPlaceholder": "Sexo", "DG.CV.AgeAndDoB": "Idade e Data de Nascimento", @@ -215,12 +197,10 @@ "DG.CV.NationalityPlaceholder": "Nacionalidade", "DG.CV.Education": "Educação", "DG.CV.EducationPlaceholder": "Educação ou Treinamento", - "DG.Bonds.Bond": "Laço", "DG.Bonds.Name": "Nome", "DG.Bonds.Score": "Valor", "DG.Bonds.NewBond": "Novo Laço", - "DG.Tooltip.MaximumHP": "A Vida máxima é a média de FOR + CON, arredondado para CIMA.", "DG.Tooltip.MaximumWP": "FV máximo é igual a POD.", "DG.Tooltip.CurrentSanityPartOne": "Sanidade atual, atual ponto de ruptura é ", @@ -236,17 +216,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Estar adaptado ao desamparo significa que a motivação pessoal do seu agente sofre à medida que ele se afasta do mundo ao seu redor. Assim que seu Agente se adaptar ao desamparo, o agente perde permanentemente 1D6 de POD.", "DG.Tooltip.ToggleEquippedState": "Clique para alternar se este item está equipado ou não.", "DG.Tooltip.Exhaustion": "Um agente que trabalha por muito tempo ou enfrenta perigo extremo e ferimentos sem descansar fica exausto. Quando isso entra em jogo depende do Handler, mas uma boa regra é que passar uma noite sem dormir ou se recusar a descansar após perder SAN ou PV leva à exaustão.", - "DG.Gear.Expense.NA": "N/A", "DG.Gear.Expense.Incidental": "Incidental", "DG.Gear.Expense.Standard": "Padrão", "DG.Gear.Expense.Unusual": "Incomum", "DG.Gear.Expense.Major": "Maior", "DG.Gear.Expense.Extreme": "Extremo", - "DG.ItemWindow.Bonds.Description": "Descrição/Desenvolvimentos", "DG.ItemWindow.Bonds.ScoreTooltip": "Uma pontuação mais alta representa uma relação mais forte entre o agente e o vínculo.", - "DG.ItemWindow.Motivations.Attributes": "Atributos", "DG.ItemWindow.Motivations.Motivation": "Motivação", "DG.ItemWindow.Motivations.Description": "Descrição/Notas", @@ -257,19 +234,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Marque isso se adicionar um distúrbio ou se uma motivação existente foi perdida devido ao ganho de um distúrbio.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "Distúrbio foi \"Curado\"", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Um sucesso crítico em um teste de sorte na terapia pode 'curar' um distúrbio. No entanto, pode retornar na próxima vez que um ponto de ruptura for atingido. Consulte a seção Terapia nas páginas 78-79 do Manual do Agente.", - "DG.ItemWindow.Gear.Attributes": "Atributos", "DG.ItemWindow.Gear.Description": "Descrição", "DG.ItemWindow.Gear.NamePlaceholder": "Nome", "DG.ItemWindow.Gear.Expense": "Custo", "DG.ItemWindow.Gear.Equipped": "Equipado", - "DG.ItemWindow.Armor.Description": "Descrição", "DG.ItemWindow.Armor.NamePlaceholder": "Nome", "DG.ItemWindow.Armor.ArmorRating": "Categoria de Armadura", "DG.ItemWindow.Armor.Expense": "Custo", "DG.ItemWindow.Armor.Equipped": "Equipado", - "DG.ItemWindow.Weapons.Description": "Descrição", "DG.ItemWindow.Weapons.NamePlaceholder": "Nome", "DG.ItemWindow.Weapons.Skill": "Habilidade", @@ -283,27 +257,21 @@ "DG.ItemWindow.Weapons.Ammo": "Munição (quantidade/tipo)", "DG.ItemWindow.Weapons.Range": "Alcance", "DG.ItemWindow.Weapons.KillRadius": "Raio de Morte", - "DG.ModifySkillRollDialogue.Title": "Modificar Rolagem", "DG.ModifySkillRollDialogue.Test": "Teste:", "DG.ModifySkillRollDialogue.OriginalTarget": "Alvo Original:", "DG.ModifySkillRollDialogue.TargetModifier": "Modificar Alvo (+/-):", "DG.ModifySkillRollDialogue.ModifierTooltip": "Dê um modificador positivo ou negativo aqui para adicionar/subtrair do alvo para o teste de perícia.", - "DG.ModifyDamageRollDialogue.Weapon": "Arma:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Use este campo para modificar a parte externa da fórmula, como dobrar o resultado.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Use este campo para adicionar um modificador simples como '+1' ou outro termo de dados como '+1d4'.", - "DG.Roll.Inhuman": "Desumano", "DG.Tooltip.BreakPoint": "O ponto de ruptura atual, redefinido quando a sanidade atual cair para ou abaixo desse valor. O ponto de ruptura inicial é SAN - POW.", "DG.Tooltip.BreakPointReset": "Redefina o ponto de ruptura para ser SAN - FV.", "DG.Skills.ritual": "Ritual", - "DG.RollMode": "Modo de Rolagem", "DG.Bonds.Relationship": "Relacionamento", - "DG.TypeSkills.Subskills.Painting": "Pintura", - "DG.Sheet.BlockHeaders.Attributes": "ATRIBUTOS", "DG.Sheet.BlockHeaders.Statistics": "ESTATÍSTICAS", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "Descrição Física/Notas", @@ -313,50 +281,39 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVAÇÕES E DISTÚRBIOS", "DG.Sheet.BlockHeaders.PersonalInfoCV": "INFORMAÇÕES PESSOAIS / C.V.", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "LAÇOS / RELACIONAMENTOS / CONTATOS", - "DG.AgentSheet.ProfessionPlaceholder": "PROFISSÂO", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Esconder Habilidades não Treinadas", "DG.UnnaturalSheet.NotesLabel": "Notas", "DG.UnnaturalSheet.ShortDescriptionPlaceholder": "Descrição de Criatura ou Tipo de Criatura", "DG.UnnaturalSheet.ShowUntrainedSkillsLabel": "Mostrar Habilidades não Treinadas", "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Habilidades & Notas", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Ataques & Habilidades", - "DG.NPCSheet.BreakPointAbbreviation": "PR", - "DG.ImpossibleLanscapes.IHaveSeenThe": "EU JÁ VI O", "DG.ImpossibleLanscapes.YellowSign": "SÍMBOLO AMARELO", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Corrup", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "ção", "DG.ImpossibleLanscapes.Gift": "Dom", "DG.ImpossibleLanscapes.Insight": "Intuição", - "DG.VehicleSheet.NotesTabName": "Notas", "DG.VehicleSheet.GearTabName": "Equipamentos", "DG.VehicleSheet.NotesLabel": "NOTAS", "DG.VehicleSheet.DescriptionPlaceHolder": "Descrição do Veículo", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Marcar Vínculos Danificados até a Próxima Cena em Casa", "DG.Generic.ClearAll": "Limpar Tudo", - "DG.Generic.SanDamage": "Dano de SAN", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Alvo", "DG.ItemWindow.Weapons.SkillModifier": "Modificador de Habilidade", "DG.ItemWindow.Custom": "Personalizado", - "DG.Sheet.class.item": "Ficha de item", "DG.Sheet.class.agent": "Ficha de Agente", "DG.Sheet.class.npc": "Ficha de PNJ", "DG.Sheet.class.unnatural": "Ficha de Antinatural", "DG.Sheet.class.vehicle": "Ficha de Veículo", - "TYPES.Actor.agent": "Agente", "TYPES.Actor.npc": "PNJ", "TYPES.Actor.unnatural": "Antinatural", "TYPES.Actor.vehicle": "Veículo", - "TYPES.Item.armor": "Armadura", "TYPES.Item.bond": "Vinculo", "TYPES.Item.gear": "Equipamento", @@ -364,13 +321,10 @@ "TYPES.Item.weapon": "Arma", "TYPES.Item.tome": "Tomo", "TYPES.Item.ritual": "Ritual", - "DG.Gear.Tomes": "TOMOS", "DG.Gear.Rituals": "RITUAIS", - "DG.Tomes.Name": "Nome", "DG.Ritual.Name": "Nome", - "DG.Tome.Name": "Nome", "DG.Tome.Language": "Idioma", "DG.Tome.StudyTime": "Tempo de Estudo", @@ -382,59 +336,45 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Dano de SAN ao Estudar", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Chefe Somente", "DG.ItemWindow.Tome.HandlerNotes": "Notas do Chefe", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "Dano de SAN na Ativação", "DG.ItemWindow.Ritual.ActivationTime": "Tempo de Ativação", "DG.ItemWindow.Ritual.ActivationCosts": "Custos de Ativação", "DG.ItemWindow.Ritual.StudyTime": "Tempo de Estudo", "DG.ItemWindow.Ritual.LearnedSanityDamage": "Dano de SAN ao Aprender", - "DG.MissionPaused": "Operação Pausada", - "DG.Settings.charactersheet.name": "Estilo da Ficha de Personagem", "DG.Settings.charactersheet.hint": "Escolha como as fichas devem ser estilizadas. Programa tem um visual governamental moderno, enquanto Caubois/Fora-da-lei remete a um estilo mais antigo e áspero, como datilografado.", "DG.Settings.charactersheet.cowboys": "Os Caubois", "DG.Settings.charactersheet.outlaws": "Os Fora-da-lei", "DG.Settings.charactersheet.program": "O Programa", - "DG.Settings.sortskills.name": "Ordenar Perícias por Coluna?", "DG.Settings.sortskills.hint": "Marcado: ordena por coluna. Desmarcado: ordena por linha.", - "DG.Settings.skillTooltipDisplay.name": "Exibir dicas (tooltips)", "DG.Settings.skillTooltipDisplay.hint": "Escolha quando exibir as dicas das perícias nas fichas dos Agentes.", "DG.Settings.skillTooltipDisplay.hover": "Ao passar o mouse (padrão)", "DG.Settings.skillTooltipDisplay.never": "Nunca", "DG.Settings.skillTooltipDisplay.hoverShift": "Ao passar o mouse + Shift", - "DG.Settings.keepSanityPrivate.name": "Manter Sanidade Privada", "DG.Settings.keepSanityPrivate.hint": "Oculta a Sanidade dos jogadores tanto nas fichas quanto nas rolagens.", - "DG.Settings.skillImprovementFormula.name": "Rolagem Padrão de Aprimoramento de Perícia", "DG.Settings.skillImprovementFormula.hint": "Padrão: 1d4. Houve múltiplas erratas sobre isso; escolha a opção que melhor se adequa ao ritmo do seu jogo.", "DG.Settings.skillImprovementFormula.1": "+1% Fixo", "DG.Settings.skillImprovementFormula.2": "+1D3%", "DG.Settings.skillImprovementFormula.3": "+1D4%", "DG.Settings.skillImprovementFormula.4": "+1D4-1%", - "DG.Settings.alwaysShowHypergeometrySectionForPlayers.name": "Exibir Seção de Hipergeometria", "DG.Settings.alwaysShowHypergeometrySectionForPlayers.hint": "Sempre exibe a seção de itens de Hipergeometria (ex.: Rituais e Tomos) nas fichas dos Agentes, mesmo que o Agente não possua itens desse tipo.", - "DG.Settings.showImpossibleLandscapesContent.name": "Exibir Conteúdo de Impossible Landscapes", "DG.Settings.showImpossibleLandscapesContent.hint": "Exibe campos específicos de Impossible Landscapes nas fichas dos Agentes. Esses campos são sempre visíveis apenas para o Chefe, mesmo com esta opção ativada.", - "DG.Settings.skillFailure.name": "Marcar Falha de Perícia Automaticamente", "DG.Settings.skillFailure.hint": "Ative esta opção para marcar automaticamente a caixa de falha ao falhar em uma perícia. O Chefe pode desfazer a ação a partir da mensagem de chat da rolagem.", - "DG.Settings.Saved": "As configurações atuais foram salvas.", - "DG.SettingsMenu.automation.name": "Menu de Automação", "DG.SettingsMenu.automation.label": "Menu de Automação", "DG.SettingsMenu.automation.title": "Configurações de Automação", - "DG.SettingsMenu.handler.name": "Configurações do Chefe", "DG.SettingsMenu.handler.label": "Configurações do Chefe", "DG.SettingsMenu.handler.title": "Configurações Exclusivas do Chefe", - "DG.Messages.Skillsmark.Marked": "Esta perícia é marcada", "DG.Messages.Skillsmark.Tooltip": "Veja pág. 29 do Manual do Agente" } diff --git a/lang/sv.json b/lang/sv.json index c87f62a..6b8e152 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -3,10 +3,8 @@ "DG.AgentNameFieldTooltip": "EFTERNAMN, FÖRNAMN (OCH ALIAS OM RELEVANT)", "DG.RollLuck": "Turkast", "DG.Luck": "Tur", - "DG.FallbackText.ProfessionAndOrRank": "YRKE", "DG.FallbackText.newItem": "Ny {type}", - "DG.Skills.accounting": "Bokföring", "DG.Skills.alertness": "Vaksamhet", "DG.Skills.anthropology": "Antropologi", @@ -45,7 +43,6 @@ "DG.Skills.unarmed_combat": "Slagsmål", "DG.Skills.unnatural": "Onaturligt", "DG.Skills.flight": "Flyg", - "DG.Skills.AddTypedOrCustomSkill": "Lägg till anpassad färdighet", "DG.Skills.AddSkill": "Lägg till färdighet", "DG.Skills.Apply": "Tillämpa", @@ -58,7 +55,6 @@ "DG.Skills.EditSkill": "Redigera färdighet", "DG.Skills.SkillGroup": "Färdighetsgrupp", "DG.Skills.SkillName": "Färdighetsnamn", - "DG.TypeSkills.Art": "Konst", "DG.TypeSkills.Craft": "Hantverk", "DG.TypeSkills.ForeignLanguage": "Främmande språk", @@ -66,7 +62,6 @@ "DG.TypeSkills.Pilot": "Pilot", "DG.TypeSkills.Science": "Vetenskap", "DG.TypeSkills.Other": "Annat", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Lägg till specialträning", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Anpassade färdigheter", "DG.SpecialTraining.Dialog.DropDown.Skills": "Färdigheter", @@ -75,7 +70,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Namn", "DG.SpecialTraining.Dialog.SkillLabel": "Färdighet eller statistik", "DG.SpecialTraining.Dialog.Title": "Skapa/Redigera specialträning", - "DG.Attributes.HP": "HP", "DG.Attributes.WP": "VP", "DG.Attributes.SAN": "PSY", @@ -85,21 +79,17 @@ "DG.Attributes.int": "INT", "DG.Attributes.pow": "VIL", "DG.Attributes.cha": "KAR", - "DG.Navigation.Skills": "Färdigheter", "DG.Navigation.Physical": "Statistik", "DG.Navigation.Mental": "Psykologi", "DG.Navigation.Gear": "Utrustning", "DG.Navigation.CV": "Personligt", "DG.Navigation.Contacts": "Kontakter", - "DG.About.OnlineHelp": "Systemdokumentation på (ONLINE)", "DG.About.OnlineHelpTooltip": "Starta webbläsare med online GitHub-hjälp.", "DG.About.OnlineBugTracker": "Frågor, buggrapporter eller funktionsförfrågningar (ONLINE)", "DG.About.OnlineBugTrackerTooltip": "Starta webbläsare med GitHub-issue tracker.", - "DG.Roll.Rolling": "Slår för", - "DG.Roll.Target": "Mål: ", "DG.Roll.Success": "Träff", "DG.Roll.Failure": "Miss", "DG.Roll.Critical": "Kritisk", @@ -110,14 +100,11 @@ "DG.Roll.RollingDamageFor": "Slår SKADA för", "DG.Roll.Or": "Eller", "DG.Roll.Roll": "Slå", - "DG.Physical.Wounds": "Sår", "DG.Physical.FirstAidLabel": "Första hjälpen har redan försökts: ", "DG.Physical.Exhausted": "Utmattad:", "DG.Physical.ExhaustionPenalty": "Nedsättning:", - "DG.Generic.AddButtonLabel": "Lägg till", - "DG.Mental.BreakingPoint": "Brytpunkt: ", "DG.Mental.Reset": "Reset", "DG.Mental.SanityLossIncidentsLabel": "Episoder av PSY-förlust utan att bli galen", @@ -127,7 +114,6 @@ "DG.Mental.AdaptedToHelplessness": "Anpassad till hjälplöshet", "DG.Mental.Motivations": "Motivation", "DG.Mental.Disorders": "Störningar", - "DG.Gear.Weapons": "Vapen", "DG.Gear.Armor": "Kroppskydd", "DG.Gear.OtherGear": "Annan utrustning", @@ -136,7 +122,6 @@ "DG.Gear.ArmorPiercing": "Genomträngande", "DG.Gear.ArmorRating": "Skyddsnivå", "DG.Gear.Equipped": "Utrustad", - "DG.CV.Sex": "Kön", "DG.CV.SexPlaceholder": "Kön", "DG.CV.AgeAndDoB": "Ålder och födelsedatum", @@ -147,12 +132,10 @@ "DG.CV.NationalityPlaceholder": "Nationalitet", "DG.CV.Education": "Utbildning", "DG.CV.EducationPlaceholder": "Utbildning eller skola", - "DG.Bonds.Bond": "Kamrat", "DG.Bonds.Name": "Namn", "DG.Bonds.Score": "Poäng", "DG.Bonds.NewBond": "Ny Kamrat", - "DG.Attributes.Tooltip.HP": "Hälsopoäng, en karaktär blir medvetslös vid 2 HP och dör vid 0 HP.", "DG.Tooltip.MaximumHP": "Maximala hälsopoäng är hälften av STY + FYS, avrundat UPP.", "DG.Attributes.Tooltip.WP": "Viljepoäng är mentalt bränsle. Agenter behöver viljepoäng för att fortsätta trots utmattning eller mentalt påfrestande situationer. Se sidan 47 i Agenthandboken för mer detaljer.", @@ -171,17 +154,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Att vara anpassad till hjälplöshet innebär att din agent förlorat sin personliga drivkraft och drar sig tillbaka från världen omkring dem. När din agent är anpassad till hjälplöshet förlorar agenten permanent 1D6 VIL.", "DG.Tooltip.ToggleEquippedState": "Klicka för att välja om din agent har saken med sig eller ej.", "DG.Tooltip.Exhaustion": "En agent som arbetat för länge eller varit under press länge utan sömn blir utmattad.", - "DG.Gear.Expense.NA": "Ingen", "DG.Gear.Expense.Incidental": "Småsak", "DG.Gear.Expense.Standard": "Standard", "DG.Gear.Expense.Unusual": "Ovanlig", "DG.Gear.Expense.Major": "Stor", "DG.Gear.Expense.Extreme": "Extrem", - "DG.ItemWindow.Bonds.Description": "Beskrivning/Utvecklingar", "DG.ItemWindow.Bonds.ScoreTooltip": "Högre poäng representerar ett starkare förhållande mellan agenten och Kamraten.", - "DG.ItemWindow.Motivations.Attributes": "Egenskaper", "DG.ItemWindow.Motivations.Motivation": "Motivation", "DG.ItemWindow.Motivations.Description": "Beskrivning/Anteckningar", @@ -192,19 +172,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Kryssa i detta om du lägger till en störning, eller om en befintlig motivation har förlorats på grund av att du fått en störning.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "Störning har blivit \"botad\"", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "En kritisk framgång på ett terapi-turkast kan 'bota' en störning. Den kan dock återkomma nästa gång en brytpunkt nås. Se Terapisektionen på sidorna 78-79 i Agenthandboken.", - "DG.ItemWindow.Gear.Attributes": "Egenskaper", "DG.ItemWindow.Gear.Description": "Beskrivning", "DG.ItemWindow.Gear.NamePlaceholder": "Namn", "DG.ItemWindow.Gear.Expense": "Kostnad", "DG.ItemWindow.Gear.Equipped": "Utrustad", - "DG.ItemWindow.Armor.Description": "Beskrivning", "DG.ItemWindow.Armor.NamePlaceholder": "Namn", "DG.ItemWindow.Armor.ArmorRating": "Skyddsnivå", "DG.ItemWindow.Armor.Expense": "Kostnad", "DG.ItemWindow.Armor.Equipped": "Utrustad", - "DG.ItemWindow.Weapons.Description": "Beskrivning", "DG.ItemWindow.Weapons.NamePlaceholder": "Namn", "DG.ItemWindow.Weapons.Skill": "Färdighet", @@ -218,27 +195,21 @@ "DG.ItemWindow.Weapons.Ammo": "Ammo (Mängd/Typ)", "DG.ItemWindow.Weapons.Range": "Räckvidd", "DG.ItemWindow.Weapons.KillRadius": "Dödsradie", - "DG.ModifySkillRollDialogue.Title": "Modifiera kast", "DG.ModifySkillRollDialogue.Test": "Slag:", "DG.ModifySkillRollDialogue.OriginalTarget": "Ursprungligt mål:", "DG.ModifySkillRollDialogue.TargetModifier": "Modifiera mål:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Ange en positiv eller negativ modifiering här.", - "DG.ModifyDamageRollDialogue.Weapon": "Vapen:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Använd detta fält för att modifiera den yttre delen av formeln, som att dubbla resultatet.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Använd detta fält för att lägga till en platt modifierare såsom '+1' eller en annan tärningsterm såsom '+1d4'.", - "DG.Roll.Inhuman": "Omänsklig", "DG.Tooltip.BreakPoint": "Den nuvarande brytpunkten, återställs när nuvarande Stabilitet faller till eller under detta värde. Startvärdet på brytpunkten är PSY minus VIL.", "DG.Tooltip.BreakPointReset": "Återställ brytpunkten till att vara nuvarande PSY minus VP.", "DG.Skills.ritual": "Ritual", - "DG.RollMode": "Tärningskast-läge", "DG.Bonds.Relationship": "Relation", - "DG.TypeSkills.Subskills.Painting": "Målning", - "DG.Sheet.BlockHeaders.Attributes": "EGENSKAPER", "DG.Sheet.BlockHeaders.Statistics": "STATISTIK", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "ANTECKNINGAR", @@ -248,7 +219,6 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "MOTIVATIONER OCH STÖRNINGAR", "DG.Sheet.BlockHeaders.PersonalInfoCV": "PERSONLIG INFORMATION", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "KAMRATER OCH RELATIONER", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Dölj otränade färdigheter", "DG.UnnaturalSheet.NotesLabel": "Anteckningar", "DG.UnnaturalSheet.ShortDescriptionFallbackText": "Beskrivning av varelse", @@ -256,33 +226,26 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Färdigheter & Anteckningar", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Attacker & Förmågor", "DG.NPCSheet.BreakPointAbbreviation": "BP", - "DG.ImpossibleLanscapes.IHaveSeenThe": "JAG HAR SETT", "DG.ImpossibleLanscapes.YellowSign": "DET GULA MÄRKET", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Korrup", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "tion", "DG.ImpossibleLanscapes.Gift": "Gåva", "DG.ImpossibleLanscapes.Insight": "Insikt", - "DG.VehicleSheet.NotesTabName": "Anteckningar", "DG.VehicleSheet.GearTabName": "Utrustning", "DG.VehicleSheet.NotesLabel": "ANTECKNINGAR", "DG.VehicleSheet.DescriptionPlaceHolder": "Beskrivning av fordon", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Bocka i rutan om ett förhållande är skadat för att komma ihåg det under Hem-scenerna.", "DG.Generic.ClearAll": "Rensa alla", - "DG.Generic.SanDamage": "PSY-Skada", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Mål", "DG.ItemWindow.Weapons.SkillModifier": "Färdighetsmod", "DG.ItemWindow.Custom": "Anpassad", - "TYPES.Actor.agent": "Agent", "TYPES.Actor.npc": "NPC", "TYPES.Actor.unnatural": "Onaturligt", "TYPES.Actor.vehicle": "Fordon", - "TYPES.Item.armor": "Kroppskydd", "TYPES.Item.bond": "Kamrat", "TYPES.Item.gear": "Utrustning", @@ -290,13 +253,10 @@ "TYPES.Item.weapon": "Vapen", "TYPES.Item.tome": "Trollbok", "TYPES.Item.ritual": "Ritual", - "DG.Gear.Tomes": "Trollböcker", "DG.Gear.Rituals": "Ritualer", - "DG.Tomes.Name": "Namn", "DG.Ritual.Name": "Namn", - "DG.Tome.Name": "Namn", "DG.ItemWindow.Tome.Description": "Beskrivning", "DG.ItemWindow.Tome.LanguagePlaceholder": "Språk", @@ -306,13 +266,11 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Skada på Stabilitet att läsa", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Endast Handledaren", "DG.ItemWindow.Tome.HandlerNotes": "Handledarens anteckningar", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "PSY-Skada för aktivering", "DG.ItemWindow.Ritual.ActivationTime": "Aktiveringstid", "DG.ItemWindow.Ritual.ActivationCosts": "Aktiveringskostnader", "DG.ItemWindow.Ritual.StudyTime": "Studietid", "DG.ItemWindow.Ritual.LearnedSanityDamage": "Learn SAN Damage", - "DG.Messages.Skillsmark.Marked": "Denna färdighet är markerad", "DG.Messages.Skillsmark.Tooltip": "Se s. 29 Agenthandbok" } diff --git a/lang/uk.json b/lang/uk.json index 4e3e12c..9b13348 100644 --- a/lang/uk.json +++ b/lang/uk.json @@ -3,9 +3,7 @@ "DG.AgentNameFieldTooltip": "ПРІЗВИЩЕ, ІМ’Я (ТА ПСЕВДОНІМ, ЯКЩО Є)", "DG.RollLuck": "Кинути Удачу", "DG.Luck": "Удача", - "DG.FallbackText.newItem": "Новий(а) {type}", - "DG.Skills.accounting": "Бухгалтерія", "DG.Skills.alertness": "Пильність", "DG.Skills.anthropology": "Антропологія", @@ -44,7 +42,6 @@ "DG.Skills.unarmed_combat": "Рукопашний бій", "DG.Skills.unnatural": "Протиприродне", "DG.Skills.flight": "Пілотування", - "DG.Skills.Tooltip.accounting": "Помічати фінансові «червоні прапорці»: приховані активи, фірми-«прокладки» або розкрадання.", "DG.Skills.Tooltip.alertness": "Помічати приховані загрози, ледь помітні рухи або тихі звуки, доки вони не стали небезпекою.", "DG.Skills.Tooltip.anthropology": "Розуміти вірування, поведінку та табу живих культур і суспільств.", @@ -82,7 +79,6 @@ "DG.Skills.Tooltip.swim": "Виживати у воді під тиском: рятувати, тікати або витримувати загрози.", "DG.Skills.Tooltip.unarmed_combat": "Знешкоджувати або травмувати супротивника ударами, захватами та прийомами рукопашного бою.", "DG.Skills.Tooltip.unnatural": "Розпізнавати, досліджувати або пригадувати істини про сили, що виходять за межі людського розуміння.", - "DG.Skills.AddTypedOrCustomSkill": "Додати типізовану/власну навичку", "DG.Skills.AddSkill": "Додати навичку", "DG.Skills.Apply": "Застосувати", @@ -95,7 +91,6 @@ "DG.Skills.EditSkill": "Редагувати навичку", "DG.Skills.SkillGroup": "Група навичок", "DG.Skills.SkillName": "Назва навички", - "DG.TypeSkills.Art": "Мистецтво", "DG.TypeSkills.Craft": "Ремесло", "DG.TypeSkills.ForeignLanguage": "Іноземна мова", @@ -103,7 +98,6 @@ "DG.TypeSkills.Pilot": "Пілотування", "DG.TypeSkills.Science": "Наука", "DG.TypeSkills.Other": "Інше", - "DG.TypeSkills.Tooltip.Art": "Створювати або оцінювати мистецькі роботи та помічати підробки у вибраній сфері.", "DG.TypeSkills.Tooltip.Craft": "Будувати, лагодити або саботувати спеціалізовані інструменти чи системи (двигуни, замки, проводка).", "DG.TypeSkills.Tooltip.ForeignLanguage": "Говорити, читати й розуміти конкретну мову.", @@ -111,7 +105,6 @@ "DG.TypeSkills.Tooltip.Pilot": "Керувати та навігувати повітряні/морські/космічні апарати в критичних умовах.", "DG.TypeSkills.Tooltip.Science": "Застосовувати спеціалізовані наукові знання для тлумачення або дослідження фізичного світу.", "DG.TypeSkills.Tooltip.Other": "Інше", - "DG.SpecialTraining.Dialog.CreateSpecialTraining": "Додати спеціальну підготовку", "DG.SpecialTraining.Dialog.DropDown.CustomSkills": "Власні/типізовані навички", "DG.SpecialTraining.Dialog.DropDown.Skills": "Навички", @@ -120,7 +113,6 @@ "DG.SpecialTraining.Dialog.NameLabel": "Назва", "DG.SpecialTraining.Dialog.SkillLabel": "Навичка або атрибут", "DG.SpecialTraining.Dialog.Title": "Створити/Редагувати спеціальну підготовку", - "DG.Attributes.HP": "ПЗ", "DG.Attributes.WP": "ПВ", "DG.Attributes.SAN": "ГЗД", @@ -130,7 +122,6 @@ "DG.Attributes.int": "ІНТ", "DG.Attributes.pow": "НЕП", "DG.Attributes.cha": "ХАР", - "DG.Attributes.Tooltip.HP": "Пункти Здоров’я (ПЗ) показують фізичний стан. Персонаж непритомніє при 2 ПЗ і помирає при 0 ПЗ.", "DG.Attributes.Tooltip.WP": "Пункти Волі (ПВ) показують психічну витримку та внутрішній ресурс. Агентам потрібні ПВ, щоб діяти попри виснаження або ментально важкі ситуації. Див. стор. 47 в Agent's Handbook.", "DG.Attributes.Tooltip.SAN": "Пункти Глузду (ГЗД) показують зв’язок із людяністю та реальністю, як її сприймає більшість людей. Натисни, щоб кинути тест ГЗД.", @@ -140,7 +131,6 @@ "DG.Attributes.Tooltip.int": "Див. стор. 19 в Agent's Handbook — Intelligence (Інтелект)", "DG.Attributes.Tooltip.pow": "Див. стор. 19 в Agent's Handbook — Power (Непохитність)", "DG.Attributes.Tooltip.cha": "Див. стор. 19 в Agent's Handbook — Charisma (Харизма)", - "DG.Navigation.Agent.skills": "Навички", "DG.Navigation.Agent.physical": "Статистика", "DG.Navigation.Agent.motivations": "Психологія", @@ -151,7 +141,6 @@ "DG.Navigation.NPC.gear": "Атаки та здібності", "DG.Navigation.Vehicle.gear": "Спорядження", "DG.Navigation.Vehicle.notes": "Нотатки", - "DG.About.Changelog": "СПИСОК ЗМІН (ОНЛАЙН)", "DG.About.Copyright": "Опубліковано Arc Dream Publishing за домовленістю з Delta Green Partnership. Інтелектуальна власність під назвою Delta Green є торговельною маркою та об’єктом авторського права Delta Green Partnership, яка ліцензувала її використання тут.", "DG.About.License": "Вміст цієї системи ліцензовано згідно з умовами файлу license, за винятком елементів, які належать до інтелектуальної власності Delta Green.", @@ -159,9 +148,7 @@ "DG.About.OnlineHelpTooltip": "Відкрити браузер з онлайн-довідкою GitHub.", "DG.About.OnlineBugTracker": "Запитання, баг-репорти або пропозиції (ОНЛАЙН)", "DG.About.OnlineBugTrackerTooltip": "Відкрити браузер з трекером задач GitHub.", - "DG.Roll.Rolling": "Кидаємо...", - "DG.Roll.Target": "Ціль: ", "DG.Roll.Success": "Успіх", "DG.Roll.Failure": "Провал", "DG.Roll.CriticalSuccess": "Критичний успіх!", @@ -173,14 +160,11 @@ "DG.Roll.RollingDamageFor": "Кидок ШКОДИ для", "DG.Roll.Or": "Або", "DG.Roll.Roll": "Кинути", - "DG.Physical.Wounds": "Поранення", "DG.Physical.FirstAidLabel": "Перша допомога вже була застосована: ", "DG.Physical.Exhausted": "Виснаження:", "DG.Physical.ExhaustionPenalty": "Штраф:", - "DG.Generic.AddButtonLabel": "Додати", - "DG.Mental.BreakingPoint": "Точка зламу (ТЗ): ", "DG.Mental.Tooltip.BreakingPoint": "Точка зламу — це конкретне значення ГЗД, на якому Агент настільки виснажений травмою, що отримує новий довготривалий психічний розлад. Якщо ГЗД падає до цього значення або нижче, відніми НЕП (POW) від нового ГЗД. Це і є нова Точка зламу.", "DG.Mental.Reset": "Скинути", @@ -193,7 +177,6 @@ "DG.Mental.AdaptedToHelplessness": "Адаптований до безпорадності", "DG.Mental.Motivations": "Мотивації", "DG.Mental.Disorders": "Розлади", - "DG.Gear.WeaponTypes.Firearms": "Вогнепальна", "DG.Gear.WeaponTypes.Melee": "Холодна", "DG.Gear.Weapons": "ЗБРОЯ", @@ -204,7 +187,6 @@ "DG.Gear.ArmorPiercing": "Бронепробиття", "DG.Gear.ArmorRating": "Рейтинг броні", "DG.Gear.Equipped": "Екіпіровано", - "DG.CV.Sex": "Стать", "DG.CV.SexPlaceholder": "Стать", "DG.CV.AgeAndDoB": "Вік і дата народження", @@ -215,12 +197,10 @@ "DG.CV.NationalityPlaceholder": "Національність", "DG.CV.Education": "Освіта", "DG.CV.EducationPlaceholder": "Освіта або підготовка", - "DG.Bonds.Bond": "Зв’язок", "DG.Bonds.Name": "Ім’я", "DG.Bonds.Score": "Рівень", "DG.Bonds.NewBond": "Новий зв’язок", - "DG.Tooltip.MaximumHP": "Максимальні ПЗ — це середнє значення (СИЛ + СТА), округлене ВГОРУ.", "DG.Tooltip.MaximumWP": "Максимальні ПВ дорівнюють НЕП.", "DG.Tooltip.CurrentSanityPartOne": "Поточний ГЗД, поточна Точка зламу: ", @@ -236,17 +216,14 @@ "DG.Tooltip.AdaptedToHelplessness": "Адаптація до безпорадності означає, що внутрішній драйв Агента згасає. Щойно Агент адаптується, він назавжди втрачає 1D6 НЕП.", "DG.Tooltip.ToggleEquippedState": "Натисни, щоб перемкнути стан «Екіпіровано».", "DG.Tooltip.Exhaustion": "Агент, який працює надто довго або стикається з крайнім ризиком і травмами без відпочинку, виснажується. Коли це настає — вирішує Handler, але хороше правило: ніч без сну або відмова від відпочинку після втрати ГЗД чи ПЗ веде до виснаження.", - "DG.Gear.Expense.NA": "Н/Д або немає", "DG.Gear.Expense.Incidental": "Дрібне", "DG.Gear.Expense.Standard": "Стандартне", "DG.Gear.Expense.Unusual": "Незвичне", "DG.Gear.Expense.Major": "Значне", "DG.Gear.Expense.Extreme": "Екстремальне", - "DG.ItemWindow.Bonds.Description": "Опис/розвиток", "DG.ItemWindow.Bonds.ScoreTooltip": "Вищий рівень означає сильніший зв’язок між Агентом і цією людиною.", - "DG.ItemWindow.Motivations.Attributes": "Атрибути", "DG.ItemWindow.Motivations.Motivation": "Мотивація", "DG.ItemWindow.Motivations.Description": "Опис/нотатки", @@ -257,19 +234,16 @@ "DG.ItemWindow.Motivations.IsDisorderTooltip": "Познач, якщо додаєш розлад або якщо мотивацію втрачено через отримання розладу.", "DG.ItemWindow.Motivations.DisorderHasBeenCured": "Розлад було «вилікувано»", "DG.ItemWindow.Motivations.DisorderHasBeenCuredTooltip": "Критичний успіх на терапевтичному кидку Удачі може «вилікувати» розлад. Втім, він може повернутися наступного разу, коли буде досягнуто точки зламу. Див. стор. 78–79 в Agent's Handbook — Therapy.", - "DG.ItemWindow.Gear.Attributes": "Атрибути", "DG.ItemWindow.Gear.Description": "Опис", "DG.ItemWindow.Gear.NamePlaceholder": "Назва", "DG.ItemWindow.Gear.Expense": "Вартість", "DG.ItemWindow.Gear.Equipped": "Екіпіровано", - "DG.ItemWindow.Armor.Description": "Опис", "DG.ItemWindow.Armor.NamePlaceholder": "Назва", "DG.ItemWindow.Armor.ArmorRating": "Рейтинг броні", "DG.ItemWindow.Armor.Expense": "Вартість", "DG.ItemWindow.Armor.Equipped": "Екіпіровано", - "DG.ItemWindow.Weapons.Description": "Опис", "DG.ItemWindow.Weapons.NamePlaceholder": "Назва", "DG.ItemWindow.Weapons.Skill": "Навичка", @@ -283,27 +257,21 @@ "DG.ItemWindow.Weapons.Ammo": "Набої (кількість/тип)", "DG.ItemWindow.Weapons.Range": "Дальність", "DG.ItemWindow.Weapons.KillRadius": "Радіус ураження", - "DG.ModifySkillRollDialogue.Title": "Змінити кидок", "DG.ModifySkillRollDialogue.Test": "Тест:", "DG.ModifySkillRollDialogue.OriginalTarget": "Початкова ціль:", "DG.ModifySkillRollDialogue.TargetModifier": "Змінити ціль:", "DG.ModifySkillRollDialogue.ModifierTooltip": "Вкажи позитивний або негативний модифікатор, щоб додати/відняти від цілі для перевірки навички.", - "DG.ModifyDamageRollDialogue.Weapon": "Зброя:", "DG.ModifyDamageRollDialogue.OuterModifierTooltip": "Це поле змінює зовнішню частину формули, наприклад, щоб подвоїти результат.", "DG.ModifyDamageRollDialogue.InnerModifierTooltip": "Це поле додає фіксований модифікатор (наприклад, «+1») або інший кістковий термін (наприклад, «+1d4»).", - "DG.Roll.Inhuman": "Нелюдське", "DG.Tooltip.BreakPoint": "Поточна точка зламу. Скидається, коли поточний ГЗД падає до цього значення або нижче. Початкова точка зламу: ГЗД − НЕП.", "DG.Tooltip.BreakPointReset": "Скинути точку зламу до «поточний ГЗД − ПВ».", "DG.Skills.ritual": "Ритуал", - "DG.RollMode": "Режим кидка", "DG.Bonds.Relationship": "Стосунки", - "DG.TypeSkills.Subskills.Painting": "Живопис", - "DG.Sheet.BlockHeaders.Attributes": "АТРИБУТИ", "DG.Sheet.BlockHeaders.Statistics": "СТАТИСТИКА", "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes": "ФІЗИЧНИЙ ОПИС / НОТАТКИ", @@ -313,9 +281,7 @@ "DG.Sheet.BlockHeaders.MotivationsAndDisorders": "МОТИВАЦІЇ ТА РОЗЛАДИ", "DG.Sheet.BlockHeaders.PersonalInfoCV": "ОСОБИСТІ ДАНІ / РЕЗЮМЕ", "DG.Sheet.BlockHeaders.BondsRelationshipsContacts": "ЗВ’ЯЗКИ / СТОСУНКИ / КОНТАКТИ", - "DG.AgentSheet.ProfessionPlaceholder": "ПРОФЕСІЯ (ЗВАННЯ, ЯКЩО Є)", - "DG.UnnaturalSheet.HideUntrainedSkillsLabel": "Приховати нетреновані", "DG.UnnaturalSheet.NotesLabel": "Нотатки", "DG.UnnaturalSheet.ShortDescriptionPlaceholder": "Опис істоти або типу істоти", @@ -323,39 +289,31 @@ "DG.UnnaturalSheet.Tabs.NotesTabLabel": "Навички та нотатки", "DG.UnnaturalSheet.Tabs.ItemsTabLabel": "Атаки та здібності", "DG.NPCSheet.BreakPointAbbreviation": "ТЗ", - "DG.ImpossibleLanscapes.IHaveSeenThe": "Я БАЧИВ", "DG.ImpossibleLanscapes.YellowSign": "ЖОВТИЙ ЗНАК", "DG.ImpossibleLanscapes.CorruptionFirstHalf": "Розк", "DG.ImpossibleLanscapes.CorruptionSecondHalf": "лад", "DG.ImpossibleLanscapes.Gift": "Дар", "DG.ImpossibleLanscapes.Insight": "Осяяння", - "DG.VehicleSheet.NotesTabName": "Нотатки", "DG.VehicleSheet.GearTabName": "Спорядження", "DG.VehicleSheet.NotesLabel": "НОТАТКИ", "DG.VehicleSheet.DescriptionPlaceHolder": "Опис транспорту", - "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene": "Познач пошкоджений Зв’язок до завершення наступної Домашньої сцени.", "DG.Generic.ClearAll": "Очистити позначки", - "DG.Generic.SanDamage": "Втрата ГЗД", - "DG.ItemWindow.Weapons.CustomSkillTarget": "Ціль", "DG.ItemWindow.Weapons.SkillModifier": "Модифікатор навички", "DG.ItemWindow.Custom": "Власне", - "DG.Sheet.class.item": "Аркуш предмета", "DG.Sheet.class.agent": "Аркуш агента", "DG.Sheet.class.npc": "Аркуш NPC", "DG.Sheet.class.unnatural": "Аркуш протиприродного", "DG.Sheet.class.vehicle": "Аркуш транспорту", - "TYPES.Actor.agent": "Агент", "TYPES.Actor.npc": "NPC", "TYPES.Actor.unnatural": "Протиприродне", "TYPES.Actor.vehicle": "Транспорт", - "TYPES.Item.armor": "Броня", "TYPES.Item.bond": "Зв’язок", "TYPES.Item.gear": "Спорядження", @@ -363,13 +321,10 @@ "TYPES.Item.weapon": "Зброя", "TYPES.Item.tome": "Том", "TYPES.Item.ritual": "Ритуал", - "DG.Gear.Tomes": "ТОМИ", "DG.Gear.Rituals": "РИТУАЛИ", - "DG.Tomes.Name": "Назва", "DG.Ritual.Name": "Назва", - "DG.Tome.Name": "Назва", "DG.Tome.Language": "Мова", "DG.Tome.StudyTime": "Час вивчення", @@ -381,15 +336,12 @@ "DG.ItemWindow.Tome.StudySanityDamage": "Втрата ГЗД від вивчення", "DG.ItemWindow.Tome.HandlerOnlyTabHeader": "Лише для Handler", "DG.ItemWindow.Tome.HandlerNotes": "Нотатки для Handler", - "DG.ItemWindow.Ritual.ActivationSanityDamage": "Втрата ГЗД при активації", "DG.ItemWindow.Ritual.ActivationTime": "Час активації", "DG.ItemWindow.Ritual.ActivationCosts": "Вартість активації", "DG.ItemWindow.Ritual.StudyTime": "Час вивчення", "DG.ItemWindow.Ritual.LearnedSanityDamage": "Втрата ГЗД при вивченні", - "DG.MissionPaused": "Місію призупинено", - "DG.Settings.charactersheet.name": "Стиль аркуша персонажа", "DG.Settings.charactersheet.hint": "Обери стиль відображення аркушів. «Програма» — більш сучасний «урядовий» стиль. «Ковбої/Поза законом» — старіший, грубіший «друкарська машинка».", "DG.Settings.charactersheet.cowboys": "Ковбої", @@ -417,15 +369,12 @@ "DG.Settings.skillFailure.name": "Авто-позначка «Провал» для навички", "DG.Settings.skillFailure.hint": "Автоматично ставить позначку провалу при невдалій перевірці навички. Handler може скасувати дію з повідомлення кидка в чаті.", "DG.Settings.Saved": "Налаштування збережено.", - "DG.SettingsMenu.automation.name": "Меню автоматизації", "DG.SettingsMenu.automation.label": "Меню автоматизації", "DG.SettingsMenu.automation.title": "Налаштування автоматизації", - "DG.SettingsMenu.handler.name": "Налаштування Handler", "DG.SettingsMenu.handler.label": "Налаштування Handler", "DG.SettingsMenu.handler.title": "Налаштування лише для Handler", - "DG.Messages.Skillsmark.Marked": "Цю навичку позначено", "DG.Messages.Skillsmark.Tooltip": "Див. стор. 29 Agent's Handbook" } diff --git a/module/MODULES.md b/module/MODULES.md new file mode 100644 index 0000000..69c1e35 --- /dev/null +++ b/module/MODULES.md @@ -0,0 +1,38 @@ +# 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 catalog, validation, character-creation math, deferred commit ([`commit-character-creation.js`](profession/commit-character-creation.js)), Damaged Veteran modifiers ([`damaged-veteran-modifiers.js`](profession/damaged-veteran-modifiers.js)), disorder IDs ([`disorders.js`](profession/disorders.js)) | +| [`roll/`](roll/) | Dice roll subclasses, roll pipeline, and roll UX. [`roll/roll.js`](roll/roll.js) is the public API. Internal Roll subclasses live in [`roll/classes/`](roll/classes/). | +| [`sheets/`](sheets/) | ApplicationV2 sheets and mixins | +| [`applications/`](applications/) | Standalone dialogs, chargen orchestrator ([`profession-setup-flow.js`](applications/profession-setup-flow.js)), shared dialog UI ([`character-creation-ui.js`](applications/character-creation-ui.js)), and dialog factory ([`dg-dialog.js`](applications/dg-dialog.js)) | +| [`actor/`](actor/), [`item/`](item/) | Document subclasses | +| [`chat/`](chat/) | Chat cards ([`dg-chat-card.js`](chat/dg-chat-card.js)) and inline actions | +| [`integrations/`](integrations/) | Optional module hooks (Dice So Nice) | +| [`utils/`](utils/) | Generic helpers only (Handlebars, migration, skill layout/tooltips) | + +Root-level modules: [`settings.js`](settings.js), [`templates.js`](templates.js). + +## Import conventions + +- **Roll types and pipeline:** import `DGRoll`, `DGPercentileRoll`, `createDGRollFromDataset`, `processDGRoll`, etc. from [`roll/roll.js`](roll/roll.js) only — not from [`roll/classes/`](roll/classes/). +- **Roll modifier dialogs:** import from [`roll/roll-dialogs.js`](roll/roll-dialogs.js) only when calling dialogs directly (today only used from roll classes). +- **Skill improvement rolls:** import from [`roll/skill-improvement-roll.js`](roll/skill-improvement-roll.js) (used by agent sheet). +- **Profession logic:** import from [`profession/index.js`](profession/index.js) (includes `commitProfessionSetup`, `getDisorderLabel`, etc.). +- **Profession setup flow:** import `assignProfessionToAgent` from [`applications/profession-setup-flow.js`](applications/profession-setup-flow.js). +- **Dialog factory:** feature dialogs use [`showDgDialog`](applications/dg-dialog.js) — not raw `DialogV2`. +- **Chat cards:** rolls via [`createDGRollChatMessage`](chat/dg-chat-card.js); non-roll cards via [`createDGChatMessage`](chat/dg-chat-card.js). +- **Active effect runtime:** import exhaustion/stimulant sync from [`active-effect/runtime/`](active-effect/runtime/). +- **Derived actor math:** import from [`data/derived/actor-derived.js`](data/derived/actor-derived.js). +- **Agent skill defaults:** single source in [`data/actor/base/agent-skill-defaults.js`](data/actor/base/agent-skill-defaults.js) (used by human actor schema and profession code). + +## Profession setup flow + +[`profession-setup-flow.js`](applications/profession-setup-flow.js) orchestrates: Pick Statistics → Roll/Assign Stats → Character Creation → Damaged Veterans → (optional follow-ups) → `commitProfessionSetup`. Dialogs return `{ outcome: 'submitted' | 'back', ... }` or `null` (window close aborts). Character Creation alone uses **Cancel** to abort the entire assignment. diff --git a/module/active-effect/data/dg-active-effect-data.js b/module/active-effect/data/dg-active-effect-data.js new file mode 100644 index 0000000..a5d4360 --- /dev/null +++ b/module/active-effect/data/dg-active-effect-data.js @@ -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; + } +} diff --git a/module/active-effect/documents/dg-active-effect.js b/module/active-effect/documents/dg-active-effect.js new file mode 100644 index 0000000..7bcd27b --- /dev/null +++ b/module/active-effect/documents/dg-active-effect.js @@ -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); + } +} diff --git a/module/active-effect/effect-fields.js b/module/active-effect/effect-fields.js new file mode 100644 index 0000000..b427843 --- /dev/null +++ b/module/active-effect/effect-fields.js @@ -0,0 +1,77 @@ +/** @type {Record }>} */ +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 }>} + */ +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 }>} 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), + })), + })); +} diff --git a/module/active-effect/runtime/agent-condition-sync.js b/module/active-effect/runtime/agent-condition-sync.js new file mode 100644 index 0000000..b2ee904 --- /dev/null +++ b/module/active-effect/runtime/agent-condition-sync.js @@ -0,0 +1,25 @@ +import DG from "../../config/index.js"; + +/** + * @param {Actor} actor + * @returns {boolean} + */ +function hasActiveStimulantEffect(actor) { + return ( + actor.effects?.some( + (effect) => effect.getFlag(DG.ID, "stimulant") && !effect.isSuppressed, + ) ?? false + ); +} + +/** + * Persisted manual suppress or an active stimulant AE. + * + * @param {Actor} actor + * @returns {boolean} + */ +export default function getEffectiveSuppressExhaustion(actor) { + const persisted = + actor._source?.system?.physical?.suppressExhaustion ?? false; + return Boolean(persisted) || hasActiveStimulantEffect(actor); +} diff --git a/module/active-effect/runtime/derived.js b/module/active-effect/runtime/derived.js new file mode 100644 index 0000000..969c48d --- /dev/null +++ b/module/active-effect/runtime/derived.js @@ -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); +} diff --git a/module/active-effect/runtime/exhaustion-effect.js b/module/active-effect/runtime/exhaustion-effect.js new file mode 100644 index 0000000..1780904 --- /dev/null +++ b/module/active-effect/runtime/exhaustion-effect.js @@ -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>} */ +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} + */ +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} + */ +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} + */ +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)); +} diff --git a/module/active-effect/runtime/stimulant-effect.js b/module/active-effect/runtime/stimulant-effect.js new file mode 100644 index 0000000..2ba3b12 --- /dev/null +++ b/module/active-effect/runtime/stimulant-effect.js @@ -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} 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} + */ +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} + */ +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} + */ +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); + } + } +} diff --git a/module/active-effect/runtime/submit.js b/module/active-effect/runtime/submit.js new file mode 100644 index 0000000..9161946 --- /dev/null +++ b/module/active-effect/runtime/submit.js @@ -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; +} diff --git a/module/actor/actor.js b/module/actor/actor.js index fc1690d..9c1c639 100644 --- a/module/actor/actor.js +++ b/module/actor/actor.js @@ -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); + } } diff --git a/module/applications/add-profession-dialog.js b/module/applications/add-profession-dialog.js new file mode 100644 index 0000000..2068612 --- /dev/null +++ b/module/applications/add-profession-dialog.js @@ -0,0 +1,537 @@ +import { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { + bindDialogTabs, + getDialogContentRoot, + showDgDialog, +} from "./dg-dialog.js"; +import { + buildSkillDisplayRows, + renderValidationMessagesHtml, + updateSkillGridBody, +} from "./character-creation-ui.js"; +import { + BONUS_SKILL_COUNT, + buildBonusSkillCatalog, + buildCharacterCreationPayload, + computeSkillValues, + formatProfessionValidationMessages, + getTypedGroupDisplayName, + parseProfessionSkillKey, + prepareProfessionSkillRows, + splitProfessionSkillMap, +} from "../profession/index.js"; +import { splitIntoColumns } from "../utils/skill-layout.js"; +import { applySkillTooltipDisplayMode } from "../utils/skill-tooltip.js"; + +const CHARACTER_CREATION_OPTION_COLUMNS = 2; +const { renderTemplate } = foundry.applications.handlebars; + +/** + * @typedef {object} CharacterCreationDraft + * @property {string[]} checkedOptionKeys + * @property {Record} chooseOneLabels + * @property {string[]} bonusCatalogIds + * @property {string[]} bonusTypedLabels + * @property {string[]} bondNames + * @property {string[]} bondRelationships + */ + +/** + * @param {Item} professionItem + * @param {Actor} actor + * @param {object} [options] + * @param {CharacterCreationDraft} [options.draft] + * @returns {Promise<{ outcome: 'submitted', payload: import("../profession/commit-character-creation.js").CharacterCreationPayload, draft: CharacterCreationDraft } | null>} + */ +export default async function showPickSkillsDialog( + professionItem, + actor, + { draft } = {}, +) { + // eslint-disable-next-line no-use-before-define -- class defined below in this module + const controller = new AddProfessionDialogController(professionItem, actor, { + draft, + }); + return controller.run(); +} + +class AddProfessionDialogController { + /** + * @param {Item} professionItem + * @param {Actor} actor + * @param {object} [options] + * @param {CharacterCreationDraft} [options.draft] + */ + constructor(professionItem, actor, { draft } = {}) { + 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} */ + this.checkedOptionKeys = new Set(draft?.checkedOptionKeys ?? []); + /** @type {Record} */ + this.chooseOneLabels = { ...(draft?.chooseOneLabels ?? {}) }; + /** @type {string[]} */ + this.bonusCatalogIds = draft?.bonusCatalogIds + ? [...draft.bonusCatalogIds] + : Array(BONUS_SKILL_COUNT).fill(""); + /** @type {string[]} */ + this.bonusTypedLabels = draft?.bonusTypedLabels + ? [...draft.bonusTypedLabels] + : Array(BONUS_SKILL_COUNT).fill(""); + /** @type {string[]} */ + this.bondNames = draft?.bondNames ? [...draft.bondNames] : []; + /** @type {string[]} */ + this.bondRelationships = draft?.bondRelationships + ? [...draft.bondRelationships] + : []; + + /** @type {import("foundry.applications.api.DialogV2").default | null} */ + this.dialog = null; + /** @type {{ outcome: 'submitted', payload: import("../profession/commit-character-creation.js").CharacterCreationPayload, draft: CharacterCreationDraft } | null} */ + this.result = null; + } + + async run() { + const content = await this.#buildTemplate(); + this.result = null; + + 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(); + this.#restoreFormSelections(); + await this.#refreshUi(); + }, + close: () => this.result, + buttons: [ + { + action: "cancel", + label: game.i18n.localize("Cancel"), + callback: async (_event, _button, dialog) => { + this.result = null; + 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], + }; + } + + /** @returns {CharacterCreationDraft} */ + #getDraft() { + const formState = this.#getFormState(); + return { + checkedOptionKeys: [...formState.checkedOptionKeys], + chooseOneLabels: formState.chooseOneLabels, + bonusCatalogIds: formState.bonusCatalogIds, + bonusTypedLabels: formState.bonusTypedLabels, + bondNames: formState.bondNames, + bondRelationships: formState.bondRelationships, + }; + } + + #bondCount() { + return Math.max( + 1, + 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); + } + + async #buildTemplate() { + const computed = this.#compute(); + const chaScore = + this.actor.system.statistics.cha.effectiveValue ?? + this.actor.system.statistics.cha.value; + + const skillRows = 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] ?? ""; + } + } + + for (let i = 0; i < this.#bondCount(); i++) { + const nameInput = root.querySelector(`[name="bondName-${i}"]`); + if (nameInput) nameInput.value = this.bondNames[i] ?? ""; + const relInput = root.querySelector(`[name="bondRelationship-${i}"]`); + if (relInput) relInput.value = this.bondRelationships[i] ?? ""; + } + } + + /** + * @param {ReturnType} computed + */ + #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} computed + * @returns {string} + */ + #buildMessagesHtml(computed) { + const { validationMessages, capWarnings, wasteMessages } = + this.#getDisplayMessages(computed); + + const capHtml = capWarnings + .map( + (w) => + `

${foundry.utils.escapeHTML( + w.warning, + )}

`, + ) + .join(""); + + return ( + renderValidationMessagesHtml(validationMessages) + + capHtml + + renderValidationMessagesHtml(wasteMessages) + ); + } + + async #refreshUi() { + const root = this.#contentRoot(); + if (!root) return; + + this.#syncFormStateFromDom(); + + const computed = this.#compute(); + const rows = buildSkillDisplayRows(computed); + + updateSkillGridBody( + root.querySelector("[data-profession-skills-body]"), + rows, + ); + + 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; + } + + async #onSubmit() { + this.#syncFormStateFromDom(); + const computed = this.#compute(); + if (!computed.isValid) return; + + const draft = this.#getDraft(); + this.result = { + outcome: "submitted", + payload: buildCharacterCreationPayload(computed, this.professionItem, { + bondNames: this.bondNames, + bondRelationships: this.bondRelationships, + }), + draft, + }; + await this.dialog?.close(); + } +} diff --git a/module/applications/assign-stats-dialog.js b/module/applications/assign-stats-dialog.js new file mode 100644 index 0000000..be4e175 --- /dev/null +++ b/module/applications/assign-stats-dialog.js @@ -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' | 'back'} AssignStatsOutcome */ + +/** + * @param {DialogV2} dialog + * @param {Record} 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} 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} 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<{ outcome: 'submitted' } | { outcome: 'back' } | null>} + */ +export default async function showAssignStatsDialog(actor) { + /** @type {Record} */ + const values = getDefaultPointBuyValues(); + const { remaining } = validatePointBuyValues(values); + /** @type {{ outcome: 'submitted' } | { outcome: 'back' } | null} */ + let result = null; + + 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: "back", + label: game.i18n.localize("DG.ProfessionSetup.GoBack"), + callback: async (_event, _button, dialog) => { + result = { outcome: "back" }; + 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 = { outcome: "submitted" }; + await dialog.close(); + return false; + }, + }, + ], + }); +} diff --git a/module/applications/character-creation-ui.js b/module/applications/character-creation-ui.js new file mode 100644 index 0000000..145b31a --- /dev/null +++ b/module/applications/character-creation-ui.js @@ -0,0 +1,170 @@ +import { + buildSortedFixedSkillRows, + buildSortedTypedSkillRows, + getBonusTrackLabel, +} from "../profession/index.js"; +import { reorderForColumnSorting } from "../utils/skill-layout.js"; +import { + applySkillTooltipDisplayMode, + buildSkillTooltip, +} from "../utils/skill-tooltip.js"; + +export const CHARACTER_CREATION_SKILL_COLUMNS = 2; + +/** + * @param {{ sortLabel: string, rowId: string, label: string, value: number, isModified: boolean, tooltip?: string }[]} rows + * @param {number} [columns] + * @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]} + */ +export function orderSkillRowsForDisplay( + rows, + columns = CHARACTER_CREATION_SKILL_COLUMNS, +) { + const sorted = [...rows].sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + + const ordered = game.settings.get("deltagreen", "sortSkills") + ? reorderForColumnSorting(sorted, columns) + : sorted; + + return ordered.map(({ rowId, label, value, isModified, tooltip }) => ({ + rowId, + label, + value, + isModified: Boolean(isModified), + tooltip: tooltip ?? "", + })); +} + +/** + * @param {object} computed + * @param {Record} [typedValuesSource] + * @returns {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string, sortLabel: string }[]} + */ +export function buildSkillDisplayRows(computed, typedValuesSource) { + const typedValues = typedValuesSource ?? computed.typedValues ?? {}; + const modifiedFixed = new Set(computed.modifiedFixedKeys ?? []); + const modifiedTyped = new Set(computed.modifiedTypedKeys ?? []); + const fixedRows = buildSortedFixedSkillRows(computed.fixedValues); + const typedRows = buildSortedTypedSkillRows(computed.typedValues); + + return orderSkillRowsForDisplay([ + ...fixedRows.map((r) => ({ + rowId: r.key, + label: r.label, + value: r.value, + sortLabel: r.sortLabel, + isModified: modifiedFixed.has(r.key), + tooltip: buildSkillTooltip("dialog", null, { + kind: "fixed", + key: r.key, + }), + })), + ...typedRows.map((r) => { + const data = typedValues[r.storageKey]; + return { + rowId: r.storageKey, + label: r.label, + value: r.value, + sortLabel: r.sortLabel, + isModified: modifiedTyped.has(r.storageKey), + tooltip: buildSkillTooltip("dialog", null, { + kind: "typed", + group: data?.group ?? "", + label: r.label, + }), + }; + }), + ]); +} + +/** + * @param {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]} rows + * @returns {string} + */ +export function renderSkillGridHtml(rows) { + return rows + .map((r) => { + const modifiedClass = r.isModified ? " is-modified" : ""; + const tooltipAttr = r.tooltip + ? ` data-tooltip="${foundry.utils.escapeHTML(r.tooltip)}"` + : ""; + return `
${foundry.utils.escapeHTML( + r.label, + )}${ + r.value + }%
`; + }) + .join(""); +} + +/** + * @param {HTMLElement} body + * @param {{ rowId: string, label: string, value: number, isModified: boolean, tooltip: string }[]} rows + */ +export function updateSkillGridBody(body, rows) { + if (!body) return; + body.innerHTML = renderSkillGridHtml(rows); + applySkillTooltipDisplayMode(body); +} + +/** + * @param {string[]} validationErrors + * @param {object} [options] + * @param {string} [options.bonusSkillRequiredKey] + * @param {string} [options.bonusSkillWasteTooHighKey] + * @returns {string[]} + */ +export function formatBonusPickValidationMessages( + validationErrors, + { + bonusSkillRequiredKey = "DG.Profession.Dialog.BonusSkillRequired", + bonusSkillWasteTooHighKey = "DG.Profession.Dialog.BonusSkillWasteTooHigh", + } = {}, +) { + const messages = []; + + if (validationErrors.some((code) => /^bonus\d+$/.test(code))) { + messages.push(game.i18n.localize(bonusSkillRequiredKey)); + } + if (validationErrors.some((code) => code.startsWith("bonusType"))) { + messages.push( + game.i18n.localize("DG.Profession.Dialog.BonusSkillTypeRequired"), + ); + } + + /** @type {Set} */ + const bonusWasteTrackKeys = new Set(); + for (const code of validationErrors) { + const match = code.match(/^bonusWaste:\d+\|(\d+)\|(.+)$/); + if (match) bonusWasteTrackKeys.add(match[2]); + } + + for (const trackKey of bonusWasteTrackKeys) { + messages.push( + game.i18n.format(bonusSkillWasteTooHighKey, { + skill: getBonusTrackLabel(trackKey), + }), + ); + } + + return messages; +} + +/** + * @param {string[]} messages + * @returns {string} + */ +export function renderValidationMessagesHtml(messages) { + return messages + .map( + (message) => + `

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

`, + ) + .join(""); +} diff --git a/module/applications/damaged-veterans-dialog.js b/module/applications/damaged-veterans-dialog.js new file mode 100644 index 0000000..6b6c505 --- /dev/null +++ b/module/applications/damaged-veterans-dialog.js @@ -0,0 +1,120 @@ +import { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +/** @typedef {'freshRecruit' | 'extremeViolence' | 'captivity' | 'hardExperience' | 'thingsMan'} DamagedVeteranPath */ + +const VETERAN_OPTIONS = /** @type {const} */ ([ + { + value: "freshRecruit", + labelKey: "DG.DamagedVeterans.Dialog.FreshRecruit.Label", + bodyKey: "DG.DamagedVeterans.Dialog.FreshRecruit.Body", + }, + { + value: "extremeViolence", + labelKey: "DG.DamagedVeterans.Dialog.ExtremeViolence.Label", + bodyKey: "DG.DamagedVeterans.Dialog.ExtremeViolence.Body", + }, + { + value: "captivity", + labelKey: "DG.DamagedVeterans.Dialog.Captivity.Label", + bodyKey: "DG.DamagedVeterans.Dialog.Captivity.Body", + }, + { + value: "hardExperience", + labelKey: "DG.DamagedVeterans.Dialog.HardExperience.Label", + bodyKey: "DG.DamagedVeterans.Dialog.HardExperience.Body", + }, + { + value: "thingsMan", + labelKey: "DG.DamagedVeterans.Dialog.ThingsMan.Label", + bodyKey: "DG.DamagedVeterans.Dialog.ThingsMan.Body", + }, +]); + +/** + * @param {import("../profession/commit-character-creation.js").CharacterCreationPayload} payload + * @param {object} [options] + * @param {DamagedVeteranPath} [options.selectedPath] + * @returns {Promise<{ outcome: 'submitted', path: DamagedVeteranPath } | { outcome: 'back' } | null>} + */ +export default async function showDamagedVeteransDialog( + payload, + { selectedPath } = {}, +) { + const defaultPath = selectedPath ?? "freshRecruit"; + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/damaged-veterans.html`, + { + options: VETERAN_OPTIONS.map((option) => ({ + ...option, + checked: option.value === defaultPath, + })), + }, + ); + + /** @type {{ outcome: 'submitted', path: DamagedVeteranPath } | { outcome: 'back' } | null} */ + let result = null; + + await showDgDialog({ + modifier: "damaged-veterans", + content, + window: { + title: game.i18n.localize("DG.DamagedVeterans.Dialog.Title"), + }, + position: { width: 720, height: "auto" }, + form: { closeOnSubmit: false }, + close: () => result, + onRender: (dialog) => { + const root = getDialogContentRoot(dialog); + if (!root) return; + const submitBtn = dialog.element?.querySelector( + 'button[data-action="submit"]', + ); + const updateSubmitState = () => { + const selected = root.querySelector( + 'input[name="damagedVeteranPath"]:checked', + ); + if (submitBtn) submitBtn.disabled = !selected; + }; + root + .querySelectorAll('input[name="damagedVeteranPath"]') + .forEach((input) => { + input.addEventListener("change", updateSubmitState); + }); + updateSubmitState(); + }, + buttons: [ + { + action: "back", + label: game.i18n.localize("DG.ProfessionSetup.GoBack"), + callback: async (_event, _button, dialog) => { + result = { outcome: "back" }; + await dialog.close(); + return false; + }, + }, + { + action: "submit", + label: game.i18n.localize("DG.Profession.Dialog.Submit"), + default: true, + disabled: false, + callback: async (_event, _button, dialog) => { + const root = getDialogContentRoot(dialog); + const selected = root?.querySelector( + 'input[name="damagedVeteranPath"]:checked', + ); + if (!selected) return false; + + const path = /** @type {DamagedVeteranPath} */ (selected.value); + result = { outcome: "submitted", path }; + await dialog.close(); + return false; + }, + }, + ], + }); + + return result; +} diff --git a/module/applications/dg-active-effect-config.js b/module/applications/dg-active-effect-config.js new file mode 100644 index 0000000..641742f --- /dev/null +++ b/module/applications/dg-active-effect-config.js @@ -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|null} */ + #dgChangeTypes = null; + + /** @type {Array|null} */ + #fieldGroups = null; + + /** @returns {Record} */ + #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} */ + #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(), + }, + ); + } +} diff --git a/module/applications/dg-dialog.js b/module/applications/dg-dialog.js new file mode 100644 index 0000000..010c61b --- /dev/null +++ b/module/applications/dg-dialog.js @@ -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} [options.onRender] + * @param {string[]} [options.classes] — extra app classes merged after base + * @returns {Promise} + */ +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); + }, + }); +} diff --git a/module/sheets/edit-stats.js b/module/applications/edit-stats.js similarity index 57% rename from module/sheets/edit-stats.js rename to module/applications/edit-stats.js index 1b1eb5e..cc9e7c1 100644 --- a/module/sheets/edit-stats.js +++ b/module/applications/edit-stats.js @@ -1,4 +1,5 @@ -import DG, { BASE_TEMPLATE_PATH } from "../config.js"; +import DG, { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { applyDialogTheme } from "./dg-dialog.js"; export default class ActorEditStatForm extends foundry.applications.api.HandlebarsApplicationMixin( foundry.applications.api.ApplicationV2, @@ -11,9 +12,13 @@ export default class ActorEditStatForm extends foundry.applications.api.Handleba /** @override */ static DEFAULT_OPTIONS = { tag: "form", - classes: [DG.ID, "edit-stat-form"], - window: { title: "Edit statistics form", resizable: true }, - position: { width: 400, height: 200 }, + classes: [DG.ID, "edit-stat-form", "dg-dialog-app"], + window: { + title: "DG.EditStats.Title", + resizable: true, + contentClasses: ["standard-form"], + }, + position: { width: 600, height: "auto" }, actions: {}, form: { handler: this.formHandler, @@ -28,11 +33,20 @@ export default class ActorEditStatForm extends foundry.applications.api.Handleba }, }); + /** @override */ + async _onRender(context, options) { + await super._onRender(context, options); + applyDialogTheme(this); + } + /** @override */ async _prepareContext(options) { return { ...(await super._prepareContext(options)), actor: this.actor, + statKeys: DG.statistics, + sourceStatistics: + this.actor._source?.system?.statistics ?? this.actor.system.statistics, }; } diff --git a/module/applications/hard-experience-dialog.js b/module/applications/hard-experience-dialog.js new file mode 100644 index 0000000..2ce2964 --- /dev/null +++ b/module/applications/hard-experience-dialog.js @@ -0,0 +1,241 @@ +import { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { + HARD_EXPERIENCE_BONUS_COUNT, + buildBonusSkillCatalog, + computeHardExperienceSkillValues, +} from "../profession/index.js"; +import { + buildSkillDisplayRows, + formatBonusPickValidationMessages, + renderValidationMessagesHtml, + updateSkillGridBody, +} from "./character-creation-ui.js"; +import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +/** + * @typedef {object} HardExperienceDialogState + * @property {string[]} [bonusCatalogIds] + * @property {string[]} [bonusTypedLabels] + * @property {number|null} [removedBondIndex] + */ + +/** + * @param {import("../profession/commit-character-creation.js").CharacterCreationPayload} payload + * @param {HardExperienceDialogState} [state] + * @returns {Promise<{ outcome: 'submitted', path: 'hardExperience', bonusCatalogIds: string[], bonusTypedLabels: string[], removedBondIndex: number } | { outcome: 'back' } | null>} + */ +export default async function showHardExperienceDialog(payload, state = {}) { + const bonusCatalogIds = state.bonusCatalogIds + ? [...state.bonusCatalogIds] + : Array(HARD_EXPERIENCE_BONUS_COUNT).fill(""); + const bonusTypedLabels = state.bonusTypedLabels + ? [...state.bonusTypedLabels] + : Array(HARD_EXPERIENCE_BONUS_COUNT).fill(""); + let removedBondIndex = + state.removedBondIndex ?? (payload.bonds.length > 0 ? 0 : null); + + /** @type {{ outcome: 'submitted', path: 'hardExperience', bonusCatalogIds: string[], bonusTypedLabels: string[], removedBondIndex: number } | { outcome: 'back' } | null} */ + let result = null; + /** @type {import("foundry.applications.api.DialogV2").default | null} */ + let dialog = null; + + const bondRows = payload.bonds.map((bond, index) => ({ + index, + name: bond.name, + relationship: bond.relationship, + checked: index === removedBondIndex, + })); + + const bonusSlots = Array.from( + { length: HARD_EXPERIENCE_BONUS_COUNT }, + (_, index) => ({ index }), + ); + + const syncFormStateFromDom = () => { + const root = dialog ? getDialogContentRoot(dialog) : null; + if (!root) return; + + for (let i = 0; i < HARD_EXPERIENCE_BONUS_COUNT; i++) { + const select = root.querySelector(`[name="bonusSkill-${i}"]`); + bonusCatalogIds[i] = select?.value ?? ""; + const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`); + bonusTypedLabels[i] = typedInput?.value ?? ""; + } + + const bondSelected = root.querySelector( + 'input[name="removedBondIndex"]:checked', + ); + removedBondIndex = bondSelected ? Number(bondSelected.value) : null; + }; + + const compute = () => + computeHardExperienceSkillValues(payload, { + bonusCatalogIds, + bonusTypedLabels, + }); + + const buildTemplateData = () => { + const computed = compute(); + return { + skillRows: buildSkillDisplayRows(computed), + catalog: buildBonusSkillCatalog(), + bonusSlots, + bondRows, + validationMessages: formatBonusPickValidationMessages( + computed.validationErrors, + { + bonusSkillRequiredKey: "DG.HardExperience.Dialog.BonusSkillRequired", + bonusSkillWasteTooHighKey: + "DG.HardExperience.Dialog.BonusSkillWasteTooHigh", + }, + ), + isValid: computed.isValid && removedBondIndex !== null, + }; + }; + + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/hard-experience.html`, + buildTemplateData(), + ); + + const refreshUi = async () => { + syncFormStateFromDom(); + + const root = dialog ? getDialogContentRoot(dialog) : null; + if (!root) return; + + const templateData = buildTemplateData(); + updateSkillGridBody( + root.querySelector("[data-hard-experience-skills-body]"), + templateData.skillRows, + ); + + const messagesEl = root.querySelector("[data-hard-experience-messages]"); + if (messagesEl) { + let messagesHtml = renderValidationMessagesHtml( + templateData.validationMessages, + ); + if (removedBondIndex === null) { + messagesHtml += renderValidationMessagesHtml([ + game.i18n.localize("DG.HardExperience.Dialog.BondRemovalRequired"), + ]); + } + messagesEl.innerHTML = messagesHtml; + } + + const submitBtn = dialog?.element?.querySelector( + 'button[data-action="submit"]', + ); + if (submitBtn) { + submitBtn.disabled = !(templateData.isValid && removedBondIndex !== null); + } + + for (let i = 0; i < HARD_EXPERIENCE_BONUS_COUNT; i++) { + const select = root.querySelector(`[name="bonusSkill-${i}"]`); + if (select) select.value = bonusCatalogIds[i] ?? ""; + const typedInput = root.querySelector(`[name="bonusTypedLabel-${i}"]`); + const isTyped = (bonusCatalogIds[i] ?? "").startsWith("typed:"); + if (typedInput) { + typedInput.classList.toggle("hidden", !isTyped); + typedInput.value = bonusTypedLabels[i] ?? ""; + } + } + + if (removedBondIndex !== null) { + const bondInput = root.querySelector( + `input[name="removedBondIndex"][value="${removedBondIndex}"]`, + ); + if (bondInput) bondInput.checked = true; + } + }; + + const bindListeners = () => { + const root = dialog ? getDialogContentRoot(dialog) : null; + if (!root) return; + + root.querySelectorAll("select[data-bonus-index]").forEach((select) => { + select.addEventListener("change", () => { + const index = Number(select.dataset.bonusIndex); + bonusCatalogIds[index] = select.value; + const typedInput = root.querySelector( + `[name="bonusTypedLabel-${index}"]`, + ); + const isTyped = select.value.startsWith("typed:"); + if (typedInput) { + typedInput.classList.toggle("hidden", !isTyped); + if (!isTyped) { + typedInput.value = ""; + bonusTypedLabels[index] = ""; + } + } + refreshUi(); + }); + }); + + root.querySelectorAll("[data-bonus-typed-index]").forEach((input) => { + input.addEventListener("input", () => refreshUi()); + }); + + root.querySelectorAll('input[name="removedBondIndex"]').forEach((input) => { + input.addEventListener("change", () => refreshUi()); + }); + }; + + await showDgDialog({ + modifier: "hard-experience", + content, + window: { + title: game.i18n.localize("DG.HardExperience.Dialog.Title"), + }, + position: { width: 1080, height: 780 }, + form: { closeOnSubmit: false }, + close: () => result, + onRender: async (instance) => { + dialog = instance; + bindListeners(); + await refreshUi(); + }, + buttons: [ + { + action: "back", + label: game.i18n.localize("DG.ProfessionSetup.GoBack"), + callback: async (_event, _button, instance) => { + syncFormStateFromDom(); + result = { + outcome: "back", + bonusCatalogIds: [...bonusCatalogIds], + bonusTypedLabels: [...bonusTypedLabels], + removedBondIndex, + }; + await instance.close(); + return false; + }, + }, + { + action: "submit", + label: game.i18n.localize("DG.Profession.Dialog.Submit"), + default: true, + disabled: true, + callback: async () => { + syncFormStateFromDom(); + const computed = compute(); + if (!computed.isValid || removedBondIndex === null) return false; + + result = { + outcome: "submitted", + path: "hardExperience", + bonusCatalogIds: [...bonusCatalogIds], + bonusTypedLabels: [...bonusTypedLabels], + removedBondIndex, + }; + await dialog?.close(); + return false; + }, + }, + ], + }); + + return result; +} diff --git a/module/applications/pick-statistics-dialog.js b/module/applications/pick-statistics-dialog.js new file mode 100644 index 0000000..e275e2b --- /dev/null +++ b/module/applications/pick-statistics-dialog.js @@ -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} + */ +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", + }, + ], + }); +} diff --git a/module/applications/profession-setup-flow.js b/module/applications/profession-setup-flow.js new file mode 100644 index 0000000..244c214 --- /dev/null +++ b/module/applications/profession-setup-flow.js @@ -0,0 +1,186 @@ +import showPickSkillsDialog from "./add-profession-dialog.js"; +import showAssignStatsDialog from "./assign-stats-dialog.js"; +import showDamagedVeteransDialog from "./damaged-veterans-dialog.js"; +import showHardExperienceDialog from "./hard-experience-dialog.js"; +import showPickStatisticsDialog from "./pick-statistics-dialog.js"; +import showRollStatsDialog from "./roll-stats-dialog.js"; +import showThingsDisorderDialog from "./things-disorder-dialog.js"; +import { commitProfessionSetup } from "../profession/index.js"; + +/** + * @param {Actor} actor + * @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 }); +} + +/** + * @param {Actor} actor + * @param {object} [options] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} + */ +async function runStatsPhase(actor, { token = null } = {}) { + const choice = await showPickStatisticsDialog(); + if (choice === null) return false; + if (choice === "skip") return true; + + if (choice === "roll") { + const result = await showRollStatsDialog(actor, { token }); + if (result === null) return false; + if (result.outcome === "back") return runStatsPhase(actor, { token }); + return true; + } + + if (choice === "assign") { + const result = await showAssignStatsDialog(actor); + if (result === null) return false; + if (result.outcome === "back") return runStatsPhase(actor, { token }); + return true; + } + + return false; +} + +/** + * @param {import("../profession/commit-character-creation.js").CharacterCreationPayload} payload + * @param {object} [options] + * @param {string} [options.selectedPath] + * @param {import("./hard-experience-dialog.js").HardExperienceDialogState} [options.heState] + * @param {import("./things-disorder-dialog.js").ThingsDisorderDialogState} [options.tdState] + * @returns {Promise} + */ +async function resolveVeteranResult( + payload, + { selectedPath, heState, tdState } = {}, +) { + const dvResult = await showDamagedVeteransDialog(payload, { selectedPath }); + if (dvResult === null) return null; + if (dvResult.outcome === "back") return { outcome: "back" }; + + const { path } = dvResult; + + if (path === "hardExperience") { + const heResult = await showHardExperienceDialog(payload, heState); + if (heResult === null) return null; + if (heResult.outcome === "back") { + return resolveVeteranResult(payload, { + selectedPath: "hardExperience", + heState: { + bonusCatalogIds: heResult.bonusCatalogIds, + bonusTypedLabels: heResult.bonusTypedLabels, + removedBondIndex: heResult.removedBondIndex, + }, + }); + } + + return { + path: "hardExperience", + bonusCatalogIds: heResult.bonusCatalogIds, + bonusTypedLabels: heResult.bonusTypedLabels, + removedBondIndex: heResult.removedBondIndex, + }; + } + + if (path === "thingsMan") { + const tdResult = await showThingsDisorderDialog(tdState); + if (tdResult === null) return null; + if (tdResult.outcome === "back") { + return resolveVeteranResult(payload, { + selectedPath: "thingsMan", + tdState: { disorder: tdResult.disorder }, + }); + } + + return { + path: "thingsMan", + disorder: tdResult.disorder, + }; + } + + return { path }; +} + +/** + * @param {Item} professionItem + * @param {Actor} actor + * @param {import("./add-profession-dialog.js").CharacterCreationDraft} [characterDraft] + * @returns {Promise} + */ +async function runCharacterCreationPhase( + professionItem, + actor, + characterDraft, +) { + const ccResult = await showPickSkillsDialog(professionItem, actor, { + draft: characterDraft, + }); + if (ccResult === null) return false; + + const veteranResult = await resolveVeteranResult(ccResult.payload); + if (veteranResult === null) return false; + if (veteranResult.outcome === "back") { + return runCharacterCreationPhase(professionItem, actor, ccResult.draft); + } + + await commitProfessionSetup( + actor, + professionItem, + ccResult.payload, + veteranResult, + ); + return true; +} + +/** + * Run the full profession setup flow: Pick Statistics → optional stat setup → Character Creation → Damaged Veterans. + * + * @param {Item} professionItem Profession item or unsaved stub on the actor + * @param {Actor} actor + * @param {object} [options] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} True if the full setup flow completed successfully. + */ +async function runProfessionSetupFlow( + professionItem, + actor, + { token = null } = {}, +) { + const statsComplete = await runStatsPhase(actor, { token }); + if (!statsComplete) return false; + + return runCharacterCreationPhase(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} + */ +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; + + return actor.items.find((item) => item.type === "profession") ?? null; +} diff --git a/module/applications/rename-profession-dialog.js b/module/applications/rename-profession-dialog.js new file mode 100644 index 0000000..61a1332 --- /dev/null +++ b/module/applications/rename-profession-dialog.js @@ -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} 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; +} diff --git a/module/applications/roll-stats-dialog.js b/module/applications/roll-stats-dialog.js new file mode 100644 index 0000000..5b1ffa5 --- /dev/null +++ b/module/applications/roll-stats-dialog.js @@ -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' | 'back'} RollStatsOutcome */ + +/** + * @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<{ outcome: 'submitted' } | { outcome: 'back' } | null>} + */ +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, + title: 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 {{ outcome: 'submitted' } | { outcome: 'back' } | null} */ + let result = null; + + 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: "back", + label: game.i18n.localize("DG.ProfessionSetup.GoBack"), + callback: async (_event, _button, dialog) => { + result = { outcome: "back" }; + 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} */ + 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 = { outcome: "submitted" }; + await dialog.close(); + return false; + }, + }, + ], + }); +} diff --git a/module/applications/things-disorder-dialog.js b/module/applications/things-disorder-dialog.js new file mode 100644 index 0000000..65024fe --- /dev/null +++ b/module/applications/things-disorder-dialog.js @@ -0,0 +1,100 @@ +import { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { DISORDER_OPTIONS } from "../profession/disorders.js"; +import { getDialogContentRoot, showDgDialog } from "./dg-dialog.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +/** + * @typedef {object} ThingsDisorderDialogState + * @property {string} [disorder] + */ + +/** + * @param {ThingsDisorderDialogState} [state] + * @returns {Promise<{ outcome: 'submitted', path: 'thingsMan', disorder: string } | { outcome: 'back' } | null>} + */ +export default async function showThingsDisorderDialog(state = {}) { + const defaultDisorder = state.disorder ?? "amnesia"; + const content = await renderTemplate( + `${BASE_TEMPLATE_PATH}/dialog/things-disorder.html`, + { + disorders: DISORDER_OPTIONS.map(({ id, labelKey }) => ({ + value: id, + labelKey, + checked: id === defaultDisorder, + })), + }, + ); + + /** @type {{ outcome: 'submitted', path: 'thingsMan', disorder: string } | { outcome: 'back' } | null} */ + let result = null; + + await showDgDialog({ + modifier: "things-disorder", + content, + window: { + title: game.i18n.localize("DG.DamagedVeterans.ThingsMan.Dialog.Title"), + }, + position: { width: 560, height: "auto" }, + form: { closeOnSubmit: false }, + close: () => result, + onRender: (dialog) => { + const root = getDialogContentRoot(dialog); + if (!root) return; + const submitBtn = dialog.element?.querySelector( + 'button[data-action="submit"]', + ); + const updateSubmitState = () => { + const selected = root.querySelector( + 'input[name="thingsDisorder"]:checked', + ); + if (submitBtn) submitBtn.disabled = !selected; + }; + root.querySelectorAll('input[name="thingsDisorder"]').forEach((input) => { + input.addEventListener("change", updateSubmitState); + }); + updateSubmitState(); + }, + buttons: [ + { + action: "back", + label: game.i18n.localize("DG.ProfessionSetup.GoBack"), + callback: async (_event, _button, dialog) => { + const root = getDialogContentRoot(dialog); + const selected = root?.querySelector( + 'input[name="thingsDisorder"]:checked', + ); + result = { + outcome: "back", + disorder: selected?.value, + }; + await dialog.close(); + return false; + }, + }, + { + action: "submit", + label: game.i18n.localize("DG.Profession.Dialog.Submit"), + default: true, + disabled: false, + callback: async (_event, _button, dialog) => { + const root = getDialogContentRoot(dialog); + const selected = root?.querySelector( + 'input[name="thingsDisorder"]:checked', + ); + if (!selected) return false; + + result = { + outcome: "submitted", + path: "thingsMan", + disorder: selected.value, + }; + await dialog.close(); + return false; + }, + }, + ], + }); + + return result; +} diff --git a/module/chat/dg-chat-card.js b/module/chat/dg-chat-card.js new file mode 100644 index 0000000..8dab4da --- /dev/null +++ b/module/chat/dg-chat-card.js @@ -0,0 +1,496 @@ +/* global TokenDocument */ + +import DG from "../config/index.js"; +import { getCharacterSheetThemeClass } from "../applications/dg-dialog.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"); +} + +/** + * Portrait image for a Delta Green chat card. + * @param {object} params + * @param {Actor|null} [params.actor] + * @param {TokenDocument|object|string|null} [params.token] + * @param {ChatMessage.SpeakerData|null} [params.speaker] + * @returns {string} + */ +export function getDGChatPortraitSrc({ + actor = null, + token = null, + speaker = null, +} = {}) { + const tokenDoc = + resolveDGTokenDocument(token) ?? getTokenFromChatSpeaker(speaker); + const actorDoc = + actor ?? (speaker ? ChatMessage.getSpeakerActor(speaker) : null); + return tokenDoc?.texture?.src ?? actorDoc?.img ?? "icons/svg/mystery-man.svg"; +} + +/** + * @param {object} params + * @param {string} [params.title] + * @param {string} [params.subtitle] + * @param {string} [params.label] Backward-compat fallback treated as title + * @param {string} [params.flavor] Foundry flavor fallback treated as title + * @param {string} [params.rollLabel] Explicit skill/target line below the card header + * @returns {{ rollLabel: string }} + */ +function normalizeChatCardHeader({ + title = "", + subtitle = "", + label = "", + flavor = "", + rollLabel = "", +} = {}) { + if (rollLabel) return { rollLabel }; + const resolvedTitle = title || label || flavor; + if (resolvedTitle && subtitle) { + return { rollLabel: `${resolvedTitle}: ${subtitle}` }; + } + return { rollLabel: resolvedTitle }; +} + +/** + * @param {object} params + * @param {string} [params.title] + * @param {string} [params.subtitle] + * @param {string} [params.label] + * @param {string} [params.flavor] + * @param {string} [params.rollLabel] + * @returns {boolean} + */ +function shouldUseChatCard({ + title = "", + subtitle = "", + label = "", + flavor = "", + rollLabel = "", +} = {}) { + return Boolean( + normalizeChatCardHeader({ title, subtitle, label, flavor, rollLabel }) + .rollLabel, + ); +} + +/** + * @param {ChatMessage} message + * @param {HTMLElement} element + */ +export function enrichDGChatCardMessage(message, element) { + const card = element.querySelector(".dg-chat-card"); + if (!card) return; + + card.classList.remove("program-style", "cowboy-style", "outlaw-style"); + card.classList.add(getCharacterSheetThemeClass()); + + const foundryHeader = element.querySelector(".message-header"); + const header = card.querySelector(".dg-chat-card__header"); + + let portrait = card.querySelector(".dg-chat-card__portrait"); + if (!portrait) { + portrait = card.querySelector(".dg-chat-card__icon"); + if (portrait) portrait.classList.add("dg-chat-card__portrait"); + } + if (portrait) { + portrait.src = getDGChatPortraitSrc({ speaker: message.speaker }); + } + + const speakerEl = card.querySelector(".dg-chat-card__speaker"); + const authorEl = card.querySelector(".dg-chat-card__author"); + const speakerName = message.alias ?? ""; + const authorName = message.author?.name ?? ""; + + if (speakerEl) speakerEl.textContent = speakerName; + + if (authorEl) { + const whisperEl = foundryHeader?.querySelector(".whisper-to"); + if (whisperEl) { + authorEl.textContent = whisperEl.textContent.trim(); + authorEl.hidden = false; + whisperEl.remove(); + } else if (speakerName !== authorName && authorName) { + authorEl.textContent = authorName; + authorEl.hidden = false; + } else { + authorEl.hidden = true; + } + } + + let metadataSlot = card.querySelector(".dg-chat-card__metadata"); + if (!metadataSlot && header) { + metadataSlot = document.createElement("span"); + metadataSlot.className = "dg-chat-card__metadata"; + header.appendChild(metadataSlot); + } + + const foundryMetadata = foundryHeader?.querySelector(".message-metadata"); + if (metadataSlot && foundryMetadata) { + metadataSlot.replaceWith(foundryMetadata); + foundryMetadata.classList.add("dg-chat-card__metadata"); + } + + foundryHeader?.querySelector(".flavor-text")?.remove(); +} + +/** + * @param {ChatMessage.SpeakerData} speaker + * @param {string} [authorId] + * @returns {{ speakerName: string, authorName: string, showAuthor: boolean }} + */ +function getDGChatCardIdentity(speaker, authorId = game.user.id) { + const speakerName = getDGChatSpeakerName(speaker); + const authorName = game.users.get(authorId)?.name ?? ""; + return { + speakerName, + authorName, + showAuthor: Boolean(authorName) && speakerName !== authorName, + }; +} + +/** + * @param {object} params + * @param {string} params.portraitSrc + * @param {string} [params.rollLabel] + * @param {string} [params.speakerName] + * @param {string} [params.authorName] + * @param {boolean} [params.showAuthor] + * @param {string} params.content + * @returns {Promise} + */ +export async function renderDGChatCard({ + portraitSrc, + rollLabel = "", + speakerName = "", + authorName = "", + showAuthor = false, + content, +}) { + return renderTemplate(CHAT_CARD_TEMPLATE, { + portraitSrc, + rollLabel, + speakerName, + authorName, + showAuthor, + 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} + */ +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.title] + * @param {string} [options.subtitle] + * @param {string} [options.label] Backward-compat fallback treated as title + * @param {Actor|null} [options.actor] + * @param {TokenDocument|null} [options.token] + * @returns {Promise} + */ +export async function wrapDGChatMessageData( + messageData, + { title, subtitle, label, rollLabel, actor = null, token = null } = {}, +) { + const header = normalizeChatCardHeader({ + title, + subtitle, + label: label ?? messageData.flavor, + rollLabel, + }); + if (!header.rollLabel) return messageData; + + const portraitSrc = getDGChatPortraitSrc({ + actor, + token, + speaker: messageData.speaker, + }); + const identity = getDGChatCardIdentity( + messageData.speaker, + messageData.author, + ); + const wrappedContent = await renderDGChatCard({ + portraitSrc, + ...header, + ...identity, + 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.title] + * @param {string} [params.subtitle] + * @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, + title = "", + subtitle = "", + label = "", + rollLabel = "", + content, + messageMode, + flags = {}, +}) { + const mode = messageMode ?? game.settings.get("core", "messageMode"); + const mappedMode = foundry.dice.Roll._mapLegacyRollMode(mode); + const resolvedActor = actor ?? roll.actor ?? null; + const resolvedToken = token ?? roll.options?.token ?? null; + const useCard = shouldUseChatCard({ title, subtitle, label, rollLabel }); + + if (!roll._evaluated) { + await roll.evaluate({ allowInteractive: mappedMode !== "blind" }); + } + + const speaker = getDGSpeaker({ + actor: resolvedActor, + token: resolvedToken, + 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
, so embed rolls in the body. + if (useCard && !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: useCard } }, flags), + }; + + if (useCard) { + messageData = await wrapDGChatMessageData(messageData, { + title, + subtitle, + label, + rollLabel, + actor: resolvedActor, + token: resolvedToken, + }); + } + + 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.title] + * @param {string} [params.subtitle] + * @param {string} [params.label] + * @param {string} [params.content] Card body HTML + * @param {string} [params.messageMode] + * @param {object} [params.flags] + * @returns {Promise} + */ +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.title] + * @param {string} [params.subtitle] + * @param {string} [params.label] Backward-compat fallback treated as title + * @param {string} params.content Card body HTML + * @param {string} [params.messageMode] + * @param {object} [params.flags] + * @returns {Promise} + */ +export async function createDGChatMessage({ + actor, + token = null, + scene = null, + title = "", + subtitle = "", + label = "", + rollLabel = "", + content, + messageMode, + flags = {}, +}) { + const header = normalizeChatCardHeader({ title, subtitle, label, rollLabel }); + if (!header.rollLabel) { + return ChatMessage.create({ + speaker: getDGSpeaker({ actor, token, scene }), + content, + messageMode: messageMode ?? game.settings.get("core", "messageMode"), + flags, + }); + } + + const speaker = getDGSpeaker({ actor, token, scene }); + const portraitSrc = getDGChatPortraitSrc({ actor, token, speaker }); + const identity = getDGChatCardIdentity(speaker); + const wrappedContent = await renderDGChatCard({ + portraitSrc, + ...header, + ...identity, + content, + }); + + return ChatMessage.create({ + speaker, + content: wrappedContent, + messageMode: messageMode ?? game.settings.get("core", "messageMode"), + flags: foundry.utils.mergeObject({ [DG.ID]: { chatCard: true } }, flags), + }); +} diff --git a/module/other/inline.js b/module/chat/inline.js similarity index 100% rename from module/other/inline.js rename to module/chat/inline.js diff --git a/module/chat/resource-chat.js b/module/chat/resource-chat.js new file mode 100644 index 0000000..dc9dc1c --- /dev/null +++ b/module/chat/resource-chat.js @@ -0,0 +1,72 @@ +import { createDGRollChatMessage } from "./dg-chat-card.js"; + +/** + * Underlined "{amount} Willpower" span with a current/max tooltip for chat roll labels. + * + * @param {object} params + * @param {number} params.amount Willpower lost or regained. + * @param {number} params.current + * @param {number} params.max + * @returns {string} + */ +export function buildWillpowerChangeSpan({ amount, current, max }) { + const willpower = game.i18n.localize("DG.Physical.Chat.Willpower"); + const tooltip = game.i18n.format("DG.Physical.Chat.WillpowerTooltip", { + current, + max, + }); + const label = game.i18n.format("DG.Physical.Chat.WillpowerAmount", { + amount, + willpower, + }); + + return `${foundry.utils.escapeHTML(label)}`; +} + +/** + * @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 the roll-label row when rollLabelKey is omitted. + * @param {string} [params.rollLabelKey] i18n key for a formatted roll-label row (HTML). + * @param {Record} params.i18nData + * @param {Roll} params.roll Roll attached to the chat message for display and Dice So Nice. + * @param {string|null} [params.extraContentKey] Optional i18n key appended on a new line after the main body. + * @returns {Promise} + */ +export default async function createAgentResourceChatMessage({ + actor, + token = null, + contentKey = null, + labelKey = "", + rollLabelKey = null, + i18nData, + roll, + extraContentKey = null, +}) { + let content = contentKey ? game.i18n.format(contentKey, i18nData) : ""; + if (extraContentKey) { + const extra = game.i18n.format(extraContentKey, i18nData); + content = content ? `${content}
${extra}` : extra; + } + + const rollLabel = rollLabelKey + ? game.i18n.format(rollLabelKey, i18nData) + : labelKey + ? game.i18n.localize(labelKey) + : ""; + + const messageMode = game.settings.get("core", "messageMode"); + + return createDGRollChatMessage({ + actor, + token, + roll, + rollLabel, + content, + messageMode, + }); +} diff --git a/module/config.js b/module/config/index.js similarity index 99% rename from module/config.js rename to module/config/index.js index 7d0c7a4..fe4ec10 100644 --- a/module/config.js +++ b/module/config/index.js @@ -47,7 +47,6 @@ const DG = /** @type {const} */ ({ "swim", "unarmed_combat", "unnatural", - "ritual", ], skillImprovementFormulas: { diff --git a/module/config/rich-text-fields.js b/module/config/rich-text-fields.js new file mode 100644 index 0000000..37b0bba --- /dev/null +++ b/module/config/rich-text-fields.js @@ -0,0 +1,61 @@ +/** + * Rich-text field specs keyed by actor sheet part id. + * @type {Record>} + */ +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} + */ +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 []; +} diff --git a/module/data/actor/agent.js b/module/data/actor/agent.js index a3b1fc8..cc0e10f 100644 --- a/module/data/actor/agent.js +++ b/module/data/actor/agent.js @@ -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); + } + } } diff --git a/module/data/actor/base/agent-skill-defaults.js b/module/data/actor/base/agent-skill-defaults.js new file mode 100644 index 0000000..b4f1a75 --- /dev/null +++ b/module/data/actor/base/agent-skill-defaults.js @@ -0,0 +1,41 @@ +/** Default proficiency for fixed agent skills (single source of truth for schema initials). */ +const AGENT_SKILL_DEFAULTS = /** @type {Record} */ ({ + accounting: 10, + alertness: 20, + anthropology: 0, + archeology: 0, + artillery: 0, + athletics: 30, + bureaucracy: 10, + computer_science: 0, + criminology: 10, + demolitions: 0, + disguise: 10, + dodge: 30, + drive: 20, + firearms: 20, + first_aid: 10, + forensics: 0, + heavy_machiner: 10, + heavy_weapons: 0, + history: 10, + humint: 10, + law: 0, + medicine: 0, + melee_weapons: 30, + navigate: 10, + occult: 10, + persuade: 20, + pharmacy: 0, + psychotherapy: 10, + ride: 10, + search: 20, + sigint: 0, + stealth: 10, + surgery: 0, + survival: 10, + swim: 20, + unarmed_combat: 40, +}); + +export default AGENT_SKILL_DEFAULTS; diff --git a/module/data/actor/base/general.js b/module/data/actor/base/general.js index 0d702bf..0add609 100644 --- a/module/data/actor/base/general.js +++ b/module/data/actor/base/general.js @@ -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: "" }), }); } diff --git a/module/data/actor/base/human-skills.js b/module/data/actor/base/human-skills.js index 9e5bf7c..200a72a 100644 --- a/module/data/actor/base/human-skills.js +++ b/module/data/actor/base/human-skills.js @@ -1,3 +1,4 @@ +import AGENT_SKILL_DEFAULTS from "./agent-skill-defaults.js"; import { skillField } from "./general.js"; const { SchemaField, NumberField, StringField, ArrayField, ObjectField } = @@ -6,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" }), }), }), diff --git a/module/data/actor/npc.js b/module/data/actor/npc.js index 412cfc1..c3e5eea 100644 --- a/module/data/actor/npc.js +++ b/module/data/actor/npc.js @@ -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); + } } diff --git a/module/data/actor/unnatural.js b/module/data/actor/unnatural.js index 7e293b6..5a3b565 100644 --- a/module/data/actor/unnatural.js +++ b/module/data/actor/unnatural.js @@ -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); + } } diff --git a/module/data/actor/vehicle.js b/module/data/actor/vehicle.js index e6e75e3..376909d 100644 --- a/module/data/actor/vehicle.js +++ b/module/data/actor/vehicle.js @@ -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); + } } diff --git a/module/data/derived/actor-derived.js b/module/data/derived/actor-derived.js new file mode 100644 index 0000000..bc24413 --- /dev/null +++ b/module/data/derived/actor-derived.js @@ -0,0 +1,297 @@ +/** + * 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), + ); +} + +/** + * Compute agent HP/WP maximums from current statistics (including AE bonuses on max). + * @param {Actor} actor + * @returns {{ healthMax: number, wpMax: number }} + */ +export function computeAgentResourceMaximums(actor) { + const { system } = actor; + const sourceStatistics = actor._source?.system?.statistics; + + const healthMax = cleanDerivedNumber( + system, + "health.max", + calculateHealthMax(system.statistics, sourceStatistics) + + (system.health.maxBonus ?? 0), + ); + const wpMax = cleanDerivedNumber( + system, + "wp.max", + getStatisticEffectiveValue(system.statistics.pow, sourceStatistics?.pow) + + (system.wp.maxBonus ?? 0), + ); + + return { healthMax, wpMax }; +} + +/** + * @param {Actor} actor + * @param {string} veteranPath + * @returns {number} + */ +export function computeAgentBreakingPoint(actor, veteranPath) { + const sourceStatistics = actor._source?.system?.statistics; + const pow = getStatisticEffectiveValue( + actor.system.statistics.pow, + sourceStatistics?.pow, + ); + + if (veteranPath === "thingsMan") { + const sanity = Number(actor.system.sanity.value) || 0; + return Math.max(0, sanity - pow); + } + + const fullStartingSan = pow * 5; + return Math.max(0, fullStartingSan - pow); +} + +/** + * Persist agent HP/WP max from current statistics, fill both resources to max, + * initialize SAN for fresh recruits, and set breaking point after chargen. + * @param {Actor} actor + * @param {string} [veteranPath] + * @returns {Promise} + */ +export async function finalizeAgentStartingResources( + actor, + veteranPath = "freshRecruit", +) { + if (actor.type !== "agent") return; + + const { healthMax, wpMax } = computeAgentResourceMaximums(actor); + const breakingPoint = computeAgentBreakingPoint(actor, veteranPath); + + /** @type {Record} */ + const updateData = { + "system.health.max": healthMax, + "system.health.value": healthMax, + "system.wp.max": wpMax, + "system.wp.value": wpMax, + "system.sanity.currentBreakingPoint": breakingPoint, + }; + + const persistedSanity = Number(actor._source?.system?.sanity?.value) ?? 0; + if (veteranPath === "freshRecruit" && persistedSanity >= 100) { + const sourceStatistics = actor._source?.system?.statistics; + const pow = getStatisticEffectiveValue( + actor.system.statistics.pow, + sourceStatistics?.pow, + ); + updateData["system.sanity.value"] = pow * 5; + } + + await actor.update(updateData); +} + +export function setSkillTargetProficiencies(skills) { + for (const skill of Object.values(skills)) { + skill.targetProficiency = skill.proficiency; + } +} + +/** + * @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. + } +} diff --git a/module/data/fields/html-content-field.js b/module/data/fields/html-content-field.js new file mode 100644 index 0000000..491c005 --- /dev/null +++ b/module/data/fields/html-content-field.js @@ -0,0 +1,15 @@ +const { HTMLField } = foundry.data.fields; + +/** + * Standard Delta Green HTML / ProseMirror field defaults. + * @param {Partial} [options] + * @returns {foundry.data.fields.HTMLField} + */ +export default function DGHTMLField(options = {}) { + return new HTMLField({ + required: false, + blank: true, + initial: "", + ...options, + }); +} diff --git a/module/data/item/base-fields.js b/module/data/item/base-fields.js index 896b518..c1423e9 100644 --- a/module/data/item/base-fields.js +++ b/module/data/item/base-fields.js @@ -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(), }; } diff --git a/module/data/item/motivation.js b/module/data/item/motivation.js index 5d0831f..9ac6a13 100644 --- a/module/data/item/motivation.js +++ b/module/data/item/motivation.js @@ -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 }), }; } } diff --git a/module/data/item/profession.js b/module/data/item/profession.js new file mode 100644 index 0000000..414cd92 --- /dev/null +++ b/module/data/item/profession.js @@ -0,0 +1,32 @@ +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) { + if ("bonds" in source) { + 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 */ diff --git a/module/data/item/ritual.js b/module/data/item/ritual.js index 7386cf8..f4ea744 100644 --- a/module/data/item/ritual.js +++ b/module/data/item/ritual.js @@ -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 }), }; } diff --git a/module/data/item/tome.js b/module/data/item/tome.js index 2673153..5c3a9a5 100644 --- a/module/data/item/tome.js +++ b/module/data/item/tome.js @@ -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 }), }; } diff --git a/module/deltagreen.js b/module/deltagreen.js index 5f7aad5..12b68c6 100644 --- a/module/deltagreen.js +++ b/module/deltagreen.js @@ -1,26 +1,33 @@ // 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 { enrichDGChatCardMessage } from "./chat/dg-chat-card.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 +39,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 +69,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 +106,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 +141,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 +155,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} + */ +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 +266,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 +339,14 @@ Hooks.on("renderChatLog", async (app, element) => { addEventListenerToChatMessage(element); }); -Hooks.on("renderChatMessageHTML", async (app, element, context) => { +Hooks.on("renderChatMessageHTML", async (message, element, context) => { + if (message.getFlag(DG.ID, "chatCard")) { + element.classList.add("dg-chat-card-message"); + enrichDGChatCardMessage(message, element); + } + // ignore non chat card notifications if (!context.canClose) return; + addEventListenerToChatMessage(element); }); diff --git a/module/integrations/dice-so-nice.js b/module/integrations/dice-so-nice.js new file mode 100644 index 0000000..9c2f32d --- /dev/null +++ b/module/integrations/dice-so-nice.js @@ -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} 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} + */ +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} + */ +export async function waitForDiceSoNiceMessageAnimation(messageId) { + if (!isDiceSoNiceAvailable()) return; + if (typeof game.dice3d.waitFor3DAnimationByMessageID !== "function") return; + await game.dice3d.waitFor3DAnimationByMessageID(messageId); +} diff --git a/module/item/item-sheet.js b/module/item/item-sheet.js deleted file mode 100644 index dd2d011..0000000 --- a/module/item/item-sheet.js +++ /dev/null @@ -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(); - } -} diff --git a/module/item/item.js b/module/item/item.js index 1beafa7..bc1fb07 100644 --- a/module/item/item.js +++ b/module/item/item.js @@ -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); diff --git a/module/other/macro-functions.js b/module/macros/macro-functions.js similarity index 92% rename from module/other/macro-functions.js rename to module/macros/macro-functions.js index 4e8e707..c678dc0 100644 --- a/module/other/macro-functions.js +++ b/module/macros/macro-functions.js @@ -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); diff --git a/module/other/stat-parser-macro.js b/module/macros/stat-parser-macro.js similarity index 97% rename from module/other/stat-parser-macro.js rename to module/macros/stat-parser-macro.js index cdbf909..5082b81 100644 --- a/module/other/stat-parser-macro.js +++ b/module/macros/stat-parser-macro.js @@ -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() { `; - 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(); } diff --git a/module/other/register-helpers.js b/module/other/register-helpers.js deleted file mode 100644 index 1356dde..0000000 --- a/module/other/register-helpers.js +++ /dev/null @@ -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; - }); -} diff --git a/module/profession/catalog.js b/module/profession/catalog.js new file mode 100644 index 0000000..21cb518 --- /dev/null +++ b/module/profession/catalog.js @@ -0,0 +1,349 @@ +import DG from "../config/index.js"; +import { + BONUS_SKILL_CATALOG_EXCLUDED_FIXED, + BONUS_SKILL_COUNT, + BONUS_SKILL_INCREMENT, + MAX_ALLOWED_BONUS_WASTE, + SKILL_CAP, + TYPED_SKILL_TEMPLATE_GROUPS, +} from "./constants.js"; +import { + formatProfessionSkillKey, + formatProfessionSkillLabel, + getAgentSkillDefaults, + getTypedGroupDisplayName, + parseProfessionSkillKey, +} from "./keys.js"; + +/** + * @typedef {object} SkillCatalogEntry + * @property {string} id - Select value (`fixed:accounting` or `typed:Craft`) + * @property {ProfessionSkillRef} ref + * @property {string} label + * @property {string} sortLabel + */ + +/** + * @returns {SkillCatalogEntry[]} + */ +export function buildSkillCatalog() { + /** @type {SkillCatalogEntry[]} */ + const entries = []; + + for (const key of DG.skills) { + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + const label = formatProfessionSkillLabel(ref); + entries.push({ + id: `fixed:${key}`, + ref, + label, + sortLabel: label, + }); + } + + for (const group of TYPED_SKILL_TEMPLATE_GROUPS) { + const ref = /** @type {TypedSkillRef} */ ({ + kind: "typed", + group, + label: "", + }); + const label = getTypedGroupDisplayName(group); + entries.push({ + id: `typed:${group}`, + ref, + label, + sortLabel: label, + }); + } + + entries.sort((a, b) => + a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang), + ); + return entries; +} + +/** + * Skill catalog for character-creation bonus picks (excludes Unnatural). + * + * @returns {SkillCatalogEntry[]} + */ +export function buildBonusSkillCatalog() { + return buildSkillCatalog().filter( + (entry) => + entry.ref.kind !== "fixed" || + !BONUS_SKILL_CATALOG_EXCLUDED_FIXED.has(entry.ref.key), + ); +} + +/** + * @param {string} catalogId + * @param {string} [typedLabel] + * @param {{ allowEmptyTypedLabel?: boolean }} [options] + * @returns {ProfessionSkillRef | null} + */ +export function catalogIdToSkillRef( + catalogId, + typedLabel = "", + { allowEmptyTypedLabel = false } = {}, +) { + if (!catalogId) return null; + if (catalogId.startsWith("fixed:")) { + const key = catalogId.slice(6); + if (!DG.skills.includes(key)) return null; + return { kind: "fixed", key }; + } + if (catalogId.startsWith("typed:")) { + const group = catalogId.slice(6); + if (!TYPED_SKILL_TEMPLATE_GROUPS.includes(group)) return null; + const label = typedLabel.trim(); + if (!label && !allowEmptyTypedLabel) return null; + return { kind: "typed", group, label }; + } + return null; +} + +/** + * @param {ProfessionSkillRef} ref + * @returns {string} + */ +function getBonusTrackKey(ref) { + if (ref.kind === "fixed") return `fixed:${ref.key}`; + return formatProfessionSkillKey(ref); +} + +/** + * @param {string} trackKey + * @returns {string} + */ +export function getBonusTrackLabel(trackKey) { + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + return formatProfessionSkillLabel( + /** @type {FixedSkillRef} */ ({ kind: "fixed", key }), + ); + } + const ref = parseProfessionSkillKey(trackKey); + if (ref) return formatProfessionSkillLabel(ref); + return trackKey; +} + +/** + * @param {string} trackKey + * @param {Record} baseFixed + * @param {Record} baseTyped + * @param {Record} defaults + * @returns {number} + */ +function getBonusTrackBaseValue(trackKey, baseFixed, baseTyped, defaults) { + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + return baseFixed[key] ?? defaults[key] ?? 0; + } + return baseTyped[trackKey]?.value ?? 0; +} + +/** + * @typedef {object} BonusPickOptions + * @property {number} [count] + * @property {number} [increment] + * @property {number} [cap] + * @property {number} [maxWaste] + */ + +/** + * Bonus skill slots that would waste points over the creation cap. + * + * @param {Record} baseFixed + * @param {Record} baseTyped + * @param {string[]} bonusCatalogIds + * @param {string[]} bonusTypedLabels + * @param {BonusPickOptions} [options] + * @returns {string[]} + */ +export function collectBonusCapValidationErrors( + baseFixed, + baseTyped, + bonusCatalogIds, + bonusTypedLabels, + { + count = BONUS_SKILL_COUNT, + increment = BONUS_SKILL_INCREMENT, + cap = SKILL_CAP, + maxWaste = MAX_ALLOWED_BONUS_WASTE, + } = {}, +) { + const defaults = getAgentSkillDefaults(); + /** @type {Record} */ + const counts = {}; + /** @type {(string | null)[]} */ + const slotTrackKeys = []; + + for (let i = 0; i < 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} */ + const violatingTrackKeys = new Set(); + + for (const [trackKey, countForTrack] of Object.entries(counts)) { + const base = getBonusTrackBaseValue( + trackKey, + baseFixed, + baseTyped, + defaults, + ); + const final = base + countForTrack * increment; + const waste = Math.max(0, final - cap); + if (base < cap && waste >= maxWaste) { + violatingTrackKeys.add(trackKey); + } + } + + /** @type {string[]} */ + const errors = []; + for (let i = 0; i < count; i++) { + const trackKey = slotTrackKeys[i]; + if (trackKey && violatingTrackKeys.has(trackKey)) { + const base = getBonusTrackBaseValue( + trackKey, + baseFixed, + baseTyped, + defaults, + ); + const countForTrack = counts[trackKey] ?? 0; + const final = base + countForTrack * increment; + const waste = Math.max(0, final - cap); + + errors.push(`bonusWaste:${i}|${waste}|${trackKey}`); + } + } + + return errors; +} + +/** + * @param {Record} baseFixed + * @param {Record} baseTyped + * @param {{ bonusCatalogIds?: string[], bonusTypedLabels?: string[] }} formState + * @param {BonusPickOptions} [options] + * @returns {{ + * fixedValues: Record, + * typedValues: Record, + * modifiedFixedKeys: string[], + * modifiedTypedKeys: string[], + * validationErrors: string[], + * isValid: boolean, + * }} + */ +export function applyBonusSkillPicks( + baseFixed, + baseTyped, + formState, + { + count = BONUS_SKILL_COUNT, + increment = BONUS_SKILL_INCREMENT, + cap = SKILL_CAP, + maxWaste = MAX_ALLOWED_BONUS_WASTE, + } = {}, +) { + const defaults = getAgentSkillDefaults(); + const fixedValues = { ...baseFixed }; + const typedValues = foundry.utils.deepClone(baseTyped); + /** @type {Set} */ + const modifiedFixedKeys = new Set(); + /** @type {Set} */ + const modifiedTypedKeys = new Set(); + /** @type {Record} */ + const bonusCounts = {}; + + const bonusIds = formState.bonusCatalogIds ?? []; + const bonusLabels = formState.bonusTypedLabels ?? []; + + for (let i = 0; i < count; i++) { + const catalogId = bonusIds[i]; + if (catalogId) { + const typedLabel = bonusLabels[i] ?? ""; + const ref = catalogIdToSkillRef(catalogId, typedLabel); + if (ref) { + if (ref.kind === "fixed") modifiedFixedKeys.add(ref.key); + else modifiedTypedKeys.add(formatProfessionSkillKey(ref)); + + const trackKey = getBonusTrackKey(ref); + bonusCounts[trackKey] = (bonusCounts[trackKey] ?? 0) + 1; + } + } + } + + for (const [trackKey, pickCount] of Object.entries(bonusCounts)) { + const bonus = pickCount * increment; + if (trackKey.startsWith("fixed:")) { + const key = trackKey.slice(6); + fixedValues[key] = (fixedValues[key] ?? defaults[key] ?? 0) + bonus; + } else { + const ref = parseProfessionSkillKey(trackKey); + if (ref?.kind === "typed") { + const base = typedValues[trackKey]?.value ?? 0; + typedValues[trackKey] = { + group: ref.group, + label: ref.label, + value: base + bonus, + }; + } + } + } + + for (const key of Object.keys(fixedValues)) { + if (fixedValues[key] > cap) fixedValues[key] = cap; + } + for (const data of Object.values(typedValues)) { + if (data.value > cap) data.value = cap; + } + + /** @type {string[]} */ + const validationErrors = []; + + for (let i = 0; i < count; i++) { + const catalogId = bonusIds[i]; + if (!catalogId) { + validationErrors.push(`bonus${i}`); + } else { + const ref = catalogIdToSkillRef(catalogId, bonusLabels[i] ?? ""); + if (!ref) { + validationErrors.push(`bonus${i}`); + } else if (ref.kind === "typed" && !bonusLabels[i]?.trim()) { + validationErrors.push(`bonusType${i}`); + } + } + } + + validationErrors.push( + ...collectBonusCapValidationErrors( + baseFixed, + baseTyped, + bonusIds, + bonusLabels, + { count, increment, cap, maxWaste }, + ), + ); + + return { + fixedValues, + typedValues, + modifiedFixedKeys: [...modifiedFixedKeys], + modifiedTypedKeys: [...modifiedTypedKeys], + validationErrors, + isValid: validationErrors.length === 0, + }; +} diff --git a/module/profession/commit-character-creation.js b/module/profession/commit-character-creation.js new file mode 100644 index 0000000..93b66e1 --- /dev/null +++ b/module/profession/commit-character-creation.js @@ -0,0 +1,134 @@ +import { applyDamagedVeteranModifications } from "./damaged-veteran-modifiers.js"; +import { finalizeAgentStartingResources } from "../data/derived/actor-derived.js"; + +/** + * @typedef {object} CharacterCreationBond + * @property {string} name + * @property {string} relationship + */ + +/** + * @typedef {object} CharacterCreationPayload + * @property {Record} fixedValues + * @property {Record} typedValues + * @property {string} professionName + * @property {CharacterCreationBond[]} bonds + */ + +/** + * @param {ReturnType} computed + * @param {Item} professionItem + * @param {{ bondNames: string[], bondRelationships: string[] }} formState + * @returns {CharacterCreationPayload} + */ +export function buildCharacterCreationPayload( + computed, + professionItem, + formState, +) { + const bonds = []; + const bondCount = Math.max( + 1, + Math.trunc(Number(professionItem.system.bonds) || 1), + ); + + for (let i = 0; i < bondCount; i++) { + bonds.push({ + name: formState.bondNames?.[i]?.trim() ?? "", + relationship: formState.bondRelationships?.[i]?.trim() ?? "", + }); + } + + return { + fixedValues: { ...computed.fixedValues }, + typedValues: foundry.utils.deepClone(computed.typedValues), + professionName: professionItem.name, + bonds, + }; +} + +/** + * @param {Actor} actor + * @param {CharacterCreationPayload} payload + * @param {{ skipBondIndices?: number[] }} [options] + */ +export async function applyCharacterCreationPayload( + actor, + payload, + { skipBondIndices = [] } = {}, +) { + const skipSet = new Set(skipBondIndices); + const updateData = {}; + + for (const [key, value] of Object.entries(payload.fixedValues)) { + updateData[`system.skills.${key}.proficiency`] = value; + updateData[`system.skills.${key}.failure`] = false; + } + + const typedSkills = { ...(actor.system.typedSkills ?? {}) }; + for (const [, data] of Object.entries(payload.typedValues)) { + const id = foundry.utils.randomID(); + typedSkills[id] = { + label: data.label, + group: data.group, + proficiency: data.value, + failure: false, + }; + } + updateData["system.typedSkills"] = typedSkills; + updateData["system.biography.profession"] = payload.professionName; + + await actor.update(updateData); + + const chaScore = + actor.system.statistics.cha.effectiveValue ?? + actor.system.statistics.cha.value; + + /** @type {object[]} */ + const bondDocs = []; + payload.bonds.forEach((bond, index) => { + if (skipSet.has(index)) return; + bondDocs.push({ + name: bond.name, + type: "bond", + system: { + score: chaScore, + relationship: bond.relationship, + }, + }); + }); + + if (bondDocs.length) { + await actor.createEmbeddedDocuments("Item", bondDocs); + } +} + +/** + * @param {Item} professionStub + * @param {Actor} actor + * @param {CharacterCreationPayload} payload + * @param {object} veteranResult + */ +export async function commitProfessionSetup( + actor, + professionStub, + payload, + veteranResult, +) { + const skipBondIndices = + veteranResult.path === "hardExperience" && + Number.isInteger(veteranResult.removedBondIndex) + ? [veteranResult.removedBondIndex] + : []; + + await applyCharacterCreationPayload(actor, payload, { skipBondIndices }); + + await applyDamagedVeteranModifications(actor, veteranResult, payload); + + await finalizeAgentStartingResources(actor, veteranResult.path); + + const createData = professionStub.toObject(); + delete createData._id; + const created = await actor.createEmbeddedDocuments("Item", [createData]); + return created[0] ?? null; +} diff --git a/module/profession/compute.js b/module/profession/compute.js new file mode 100644 index 0000000..84b56d0 --- /dev/null +++ b/module/profession/compute.js @@ -0,0 +1,207 @@ +import { SKILL_CAP } from "./constants.js"; +import { + applyBonusSkillPicks, + collectBonusCapValidationErrors, +} from "./catalog.js"; +import { + formatProfessionSkillKey, + formatProfessionSkillLabel, + getAgentSkillDefaults, + parseProfessionSkillKey, +} from "./keys.js"; +import { validateProfessionFormState } from "./validation.js"; + +/** + * @param {Record} automaticSkills + * @param {Record} optionSkills + * @param {number} optionPicks + * @param {object} formState + * @param {Set} formState.checkedOptionKeys + * @param {Record} formState.chooseOneLabels - keyed by profession map key + * @param {string[]} formState.bonusCatalogIds - length 8 + * @param {string[]} formState.bonusTypedLabels - length 8 + * @param {string[]} [formState.bondNames] + * @param {string[]} [formState.bondRelationships] + * @param {object} [context] + * @param {Record} [context.automaticMeta] + * @param {Record} [context.optionMeta] + * @param {number} [context.bondCount] + * @returns {{ + * fixedValues: Record, + * typedValues: Record, + * capWarnings: { label: string, attempted: number, waste: number }[], + * isValid: boolean, + * validationErrors: string[], + * modifiedFixedKeys: string[], + * modifiedTypedKeys: string[], + * }} + */ +export default function computeSkillValues( + automaticSkills, + optionSkills, + optionPicks, + formState, + { automaticMeta = {}, optionMeta = {}, bondCount = 0 } = {}, +) { + const defaults = getAgentSkillDefaults(); + /** @type {Record} */ + const fixedValues = { ...defaults }; + /** @type {Record} */ + const typedValues = {}; + /** @type {Set} */ + const modifiedFixedKeys = new Set(); + /** @type {Set} */ + const modifiedTypedKeys = new Set(); + + /** + * @param {TypedSkillRef} ref + * @param {string} label + */ + const markTypedModified = (ref, label) => { + modifiedTypedKeys.add( + formatProfessionSkillKey({ kind: "typed", group: ref.group, label }), + ); + }; + + /** + * @param {string} mapKey + * @param {number} rating + * @param {{ labelOverride?: string }} [options] + * @returns {string | null} Merged typed storage key, if applied. + */ + const applyOverride = (mapKey, rating, options = {}) => { + const ref = parseProfessionSkillKey(mapKey); + if (!ref) return null; + const value = Number(rating); + if (ref.kind === "fixed") { + fixedValues[ref.key] = value; + return null; + } + + const label = options.labelOverride?.trim() ?? ref.label?.trim() ?? ""; + if (!label) return null; + + const storageKey = formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label, + }); + typedValues[storageKey] = { + group: ref.group, + label, + value, + }; + return storageKey; + }; + + for (const [key, rating] of Object.entries(automaticSkills ?? {})) { + const ref = parseProfessionSkillKey(key); + if (ref) { + 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); + } + } + } + } + } + + /** Pre-bonus base for cap warnings */ + const baseFixed = { ...fixedValues }; + const baseTyped = foundry.utils.deepClone(typedValues); + + const bonusResult = applyBonusSkillPicks(baseFixed, baseTyped, formState); + + /** @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(bonusResult.fixedValues)) { + const base = baseFixed[key] ?? defaults[key] ?? 0; + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + checkCap(formatProfessionSkillLabel(ref), base, final); + } + + for (const [storageKey, data] of Object.entries(bonusResult.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); + } + + const validationErrors = validateProfessionFormState(optionPicks, formState, { + automaticSkills, + optionSkills, + automaticMeta, + optionMeta, + bondCount, + }); + + validationErrors.push( + ...collectBonusCapValidationErrors( + baseFixed, + baseTyped, + formState.bonusCatalogIds ?? [], + formState.bonusTypedLabels ?? [], + ), + ); + + return { + fixedValues: bonusResult.fixedValues, + typedValues: bonusResult.typedValues, + capWarnings, + isValid: validationErrors.length === 0, + validationErrors, + modifiedFixedKeys: [ + ...new Set([...modifiedFixedKeys, ...bonusResult.modifiedFixedKeys]), + ], + modifiedTypedKeys: [ + ...new Set([...modifiedTypedKeys, ...bonusResult.modifiedTypedKeys]), + ], + }; +} diff --git a/module/profession/constants.js b/module/profession/constants.js new file mode 100644 index 0000000..065d372 --- /dev/null +++ b/module/profession/constants.js @@ -0,0 +1,46 @@ +/** @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; + +export const HARD_EXPERIENCE_BONUS_COUNT = 5; +export const HARD_EXPERIENCE_BONUS_INCREMENT = 10; +export const HARD_EXPERIENCE_SKILL_CAP = 90; + +/** @type {ReadonlySet} */ +export const BONUS_SKILL_CATALOG_EXCLUDED_FIXED = new Set(["unnatural"]); +export const SKILL_CAP = 80; +/** Bonus picks that would waste this many points or more are rejected (e.g. 80 + 20% at cap). */ +export const MAX_ALLOWED_BONUS_WASTE = 20; + +export const TYPED_KEY_PATTERN = + /^(Art|Craft|Foreign Language|ForeignLanguage|Military Science|MilitaryScience|Pilot|Science|Other)\s*\(([^)]+)\)\s*$/i; + +/** Legacy internal keys from earlier builds (`typed:ForeignLanguage:abc12345`). */ +export const INTERNAL_TYPED_KEY_PATTERN = + /^typed:([A-Za-z]+):([A-Za-z0-9]{8,16})(?::(.+))?$/; diff --git a/module/profession/damaged-veteran-modifiers.js b/module/profession/damaged-veteran-modifiers.js new file mode 100644 index 0000000..e8195b6 --- /dev/null +++ b/module/profession/damaged-veteran-modifiers.js @@ -0,0 +1,222 @@ +import { + HARD_EXPERIENCE_BONUS_COUNT, + HARD_EXPERIENCE_BONUS_INCREMENT, + HARD_EXPERIENCE_SKILL_CAP, +} from "./constants.js"; +import { applyBonusSkillPicks } from "./catalog.js"; +import { formatProfessionSkillKey } from "./keys.js"; + +function getPowRating(actor) { + return ( + actor.system.statistics.pow.effectiveValue ?? + actor.system.statistics.pow.value + ); +} + +function getChaRating(actor) { + return ( + actor.system.statistics.cha.effectiveValue ?? + actor.system.statistics.cha.value + ); +} + +const DEFAULT_SANITY_POW_MULTIPLIER = 5; +const THINGS_MAN_SANITY_POW_MULTIPLIER = 4; + +/** + * @param {Actor} actor + * @param {number} [powMultiplier] + * @returns {number} + */ +function getSanityFromPow( + actor, + powMultiplier = DEFAULT_SANITY_POW_MULTIPLIER, +) { + const pow = getPowRating(actor); + return Math.max(0, pow * powMultiplier); +} + +function buildOccultSanityStatUpdate(actor, options) { + const occult = Number(actor.system.skills.occult?.proficiency) || 0; + const sanity = Math.max( + 0, + getSanityFromPow(actor, DEFAULT_SANITY_POW_MULTIPLIER) + + (options.sanityDelta ?? 0), + ); + + /** @type {Record} */ + const updateData = { + "system.skills.occult.proficiency": occult + (options.occultBonus ?? 0), + "system.sanity.value": sanity, + }; + + if (options.chaDelta) { + updateData["system.statistics.cha.value"] = + getChaRating(actor) + options.chaDelta; + } + if (options.powDelta) { + updateData["system.statistics.pow.value"] = + getPowRating(actor) + options.powDelta; + } + + return updateData; +} + +async function adjustAllBondScores(actor, delta) { + const bonds = actor.items.filter((item) => item.type === "bond"); + if (!bonds.length) return; + + const updates = bonds.map((bond) => ({ + _id: bond.id, + system: { + score: Math.max(0, Number(bond.system.score) + delta), + }, + })); + await actor.updateEmbeddedDocuments("Item", updates); +} + +async function applyExtremeViolence(actor) { + const updateData = buildOccultSanityStatUpdate(actor, { + occultBonus: 10, + sanityDelta: -5, + chaDelta: -3, + }); + updateData["system.sanity.adaptations.violence.incident1"] = true; + updateData["system.sanity.adaptations.violence.incident2"] = true; + updateData["system.sanity.adaptations.violence.incident3"] = true; + + await actor.update(updateData); + await adjustAllBondScores(actor, -3); +} + +async function applyCaptivity(actor) { + const updateData = buildOccultSanityStatUpdate(actor, { + occultBonus: 10, + sanityDelta: -5, + powDelta: -3, + }); + updateData["system.sanity.adaptations.helplessness.incident1"] = true; + updateData["system.sanity.adaptations.helplessness.incident2"] = true; + updateData["system.sanity.adaptations.helplessness.incident3"] = true; + + await actor.update(updateData); +} + +async function applyHardExperience(actor, veteranResult, payload) { + const occultSanityUpdate = buildOccultSanityStatUpdate(actor, { + occultBonus: 10, + sanityDelta: -5, + }); + + const computed = applyBonusSkillPicks( + payload.fixedValues, + payload.typedValues, + { + bonusCatalogIds: veteranResult.bonusCatalogIds, + bonusTypedLabels: veteranResult.bonusTypedLabels, + }, + { + count: HARD_EXPERIENCE_BONUS_COUNT, + increment: HARD_EXPERIENCE_BONUS_INCREMENT, + cap: HARD_EXPERIENCE_SKILL_CAP, + maxWaste: HARD_EXPERIENCE_BONUS_INCREMENT, + }, + ); + + const updateData = { ...occultSanityUpdate }; + for (const [key, value] of Object.entries(computed.fixedValues)) { + updateData[`system.skills.${key}.proficiency`] = value; + } + + const typedSkills = { ...(actor.system.typedSkills ?? {}) }; + const existingByKey = new Map(); + for (const [id, skill] of Object.entries(typedSkills)) { + const storageKey = formatProfessionSkillKey({ + kind: "typed", + group: skill.group, + label: skill.label, + }); + existingByKey.set(storageKey, id); + } + + for (const [storageKey, data] of Object.entries(computed.typedValues)) { + const existingId = existingByKey.get(storageKey); + if (existingId) { + typedSkills[existingId].proficiency = data.value; + } else { + const id = foundry.utils.randomID(); + typedSkills[id] = { + label: data.label, + group: data.group, + proficiency: data.value, + failure: false, + }; + } + } + updateData["system.typedSkills"] = typedSkills; + + await actor.update(updateData); +} + +async function applyThingsMan(actor, veteranResult) { + const sanity = getSanityFromPow(actor, THINGS_MAN_SANITY_POW_MULTIPLIER); + + const occult = Number(actor.system.skills.occult?.proficiency) || 0; + const unnatural = Number(actor.system.skills.unnatural?.proficiency) || 0; + + await actor.update({ + "system.skills.unnatural.proficiency": unnatural + 10, + "system.skills.occult.proficiency": occult + 20, + "system.sanity.value": sanity, + }); + + await actor.createEmbeddedDocuments("Item", [ + { + name: game.i18n.localize("DG.DamagedVeterans.ThingsMan.MotivationName"), + type: "motivation", + system: { + crossedOut: true, + disorder: veteranResult.disorder, + }, + }, + ]); +} + +export async function applyDamagedVeteranModifications( + actor, + veteranResult, + payload, +) { + if (veteranResult.path === "freshRecruit") { + return; + } + if (veteranResult.path === "extremeViolence") { + await applyExtremeViolence(actor); + return; + } + if (veteranResult.path === "captivity") { + await applyCaptivity(actor); + return; + } + if (veteranResult.path === "hardExperience") { + await applyHardExperience(actor, veteranResult, payload); + return; + } + if (veteranResult.path === "thingsMan") { + await applyThingsMan(actor, veteranResult); + } +} + +export function computeHardExperienceSkillValues(payload, formState) { + return applyBonusSkillPicks( + payload.fixedValues, + payload.typedValues, + formState, + { + count: HARD_EXPERIENCE_BONUS_COUNT, + increment: HARD_EXPERIENCE_BONUS_INCREMENT, + cap: HARD_EXPERIENCE_SKILL_CAP, + maxWaste: HARD_EXPERIENCE_BONUS_INCREMENT, + }, + ); +} diff --git a/module/profession/disorders.js b/module/profession/disorders.js new file mode 100644 index 0000000..3d22d96 --- /dev/null +++ b/module/profession/disorders.js @@ -0,0 +1,89 @@ +/** @typedef {typeof DISORDER_OPTIONS[number]["id"]} DisorderId */ + +export const DISORDER_OPTIONS = /** @type {const} */ ([ + { + id: "amnesia", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Amnesia", + }, + { + id: "depersonalizationDisorder", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.DepersonalizationDisorder", + }, + { + id: "depression", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Depression", + }, + { + id: "dissociativeIdentityDisorder", + labelKey: + "DG.DamagedVeterans.ThingsMan.Disorder.DissociativeIdentityDisorder", + }, + { + id: "fugues", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Fugues", + }, + { + id: "megalomania", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Megalomania", + }, + { + id: "paranoia", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.Paranoia", + }, + { + id: "sleepDisorder", + labelKey: "DG.DamagedVeterans.ThingsMan.Disorder.SleepDisorder", + }, +]); + +/** @type {Map} */ +const LABEL_KEY_BY_ID = new Map( + DISORDER_OPTIONS.map((entry) => [entry.id, entry.labelKey]), +); + +/** Legacy English display strings from earlier builds. */ +/** @type {Map} */ +const LEGACY_LABEL_TO_ID = new Map([ + ["Amnesia", "amnesia"], + ["Depersonalization Disorder", "depersonalizationDisorder"], + ["Depression", "depression"], + ["Dissociative Identity Disorder", "dissociativeIdentityDisorder"], + ["Fugues", "fugues"], + ["Megalomania", "megalomania"], + ["Paranoia", "paranoia"], + ["Sleep Disorder", "sleepDisorder"], +]); + +/** + * @param {string} value + * @returns {string|null} + */ +export function resolveDisorderId(value) { + const trimmed = value?.trim(); + if (!trimmed) return null; + if (LABEL_KEY_BY_ID.has(trimmed)) return trimmed; + return LEGACY_LABEL_TO_ID.get(trimmed) ?? null; +} + +/** + * @param {string} value + * @returns {boolean} + */ +export function isKnownDisorderId(value) { + return resolveDisorderId(value) !== null; +} + +/** + * @param {string} value Stored disorder id or legacy display string. + * @returns {string} + */ +export function getDisorderLabel(value) { + const trimmed = value?.trim(); + if (!trimmed) return ""; + + const id = resolveDisorderId(trimmed) ?? trimmed; + const labelKey = LABEL_KEY_BY_ID.get(id); + if (labelKey) return game.i18n.localize(labelKey); + + return trimmed; +} diff --git a/module/profession/display.js b/module/profession/display.js new file mode 100644 index 0000000..e67b5cd --- /dev/null +++ b/module/profession/display.js @@ -0,0 +1,70 @@ +import { + formatProfessionSkillKeyLabel, + formatProfessionSkillLabel, +} from "./keys.js"; + +/** + * Sorted fixed skill rows for display. + * @param {Record} fixedValues + * @returns {{ key: string, label: string, value: number }[]} + */ +export function buildSortedFixedSkillRows(fixedValues) { + const rows = Object.entries(fixedValues).map(([key, value]) => { + const ref = /** @type {FixedSkillRef} */ ({ kind: "fixed", key }); + return { + key, + label: formatProfessionSkillLabel(ref), + value, + sortLabel: formatProfessionSkillLabel(ref), + }; + }); + rows.sort((a, b) => a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang)); + return rows; +} + +/** + * Sorted typed skill rows for display. + * @param {Record} typedValues + * @returns {{ storageKey: string, label: string, value: number, sortLabel: string }[]} + */ +export function buildSortedTypedSkillRows(typedValues) { + const rows = Object.entries(typedValues).map(([storageKey, data]) => { + const ref = { + kind: "typed", + group: data.group, + label: data.label, + }; + const label = formatProfessionSkillLabel(ref); + return { + storageKey, + label, + value: data.value, + sortLabel: label, + }; + }); + rows.sort((a, b) => a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang)); + return rows; +} + +/** + * Prepare sorted skill rows from profession item maps. + * @param {Record} skillMap + * @param {Record} [skillMeta] + * @returns {{ key: string, label: string, rating: number }[]} + */ +export function prepareProfessionSkillRows(skillMap, skillMeta = {}) { + const rows = Object.entries(skillMap ?? {}).map(([key, rating]) => { + const label = formatProfessionSkillKeyLabel(key, { + automaticMeta: skillMeta, + optionMeta: skillMeta, + }); + return { + key, + label, + rating: Number(rating), + sortLabel: label, + }; + }); + rows.sort((a, b) => a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang)); + return rows; +} diff --git a/module/profession/index.js b/module/profession/index.js new file mode 100644 index 0000000..642b37f --- /dev/null +++ b/module/profession/index.js @@ -0,0 +1,54 @@ +export { + TYPED_SKILL_TEMPLATE_GROUPS, + BONUS_SKILL_COUNT, + HARD_EXPERIENCE_BONUS_COUNT, +} from "./constants.js"; +export { + buildCharacterCreationPayload, + applyCharacterCreationPayload, + commitProfessionSetup, +} from "./commit-character-creation.js"; +export { + applyDamagedVeteranModifications, + computeHardExperienceSkillValues, +} from "./damaged-veteran-modifiers.js"; +export { + DISORDER_OPTIONS, + getDisorderLabel, + isKnownDisorderId, + resolveDisorderId, +} from "./disorders.js"; +export { + getChooseOnePlaceholderLabel, + normalizeTypedSkillName, + isChooseOnePlaceholderLabel, + isChooseOneProfessionSkillKey, + findTypedSkillNameConflict, + getAgentSkillDefaults, + normalizeTypedGroup, + getTypedGroupDisplayName, + formatProfessionSkillKey, + allocateProfessionSkillStorageKey, + parseProfessionSkillKey, + formatProfessionSkillLabel, + formatProfessionSkillKeyLabel, + splitProfessionSkillMap, +} from "./keys.js"; +export { + buildSkillCatalog, + buildBonusSkillCatalog, + catalogIdToSkillRef, + collectBonusCapValidationErrors, + applyBonusSkillPicks, + getBonusTrackLabel, +} from "./catalog.js"; +export { default as computeSkillValues } from "./compute.js"; +export { + validateProfessionFormState, + formatProfessionValidationMessages, +} from "./validation.js"; +export { + buildSortedFixedSkillRows, + buildSortedTypedSkillRows, + prepareProfessionSkillRows, +} from "./display.js"; diff --git a/module/profession/keys.js b/module/profession/keys.js new file mode 100644 index 0000000..bea1d5b --- /dev/null +++ b/module/profession/keys.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} automaticMeta + * @param {Record} optionMeta + * @returns {boolean} + */ +export function isChooseOneProfessionSkillKey( + mapKey, + automaticMeta = {}, + optionMeta = {}, +) { + return Boolean( + automaticMeta[mapKey]?.chooseOne ?? optionMeta[mapKey]?.chooseOne, + ); +} + +/** + * @returns {Record} + */ +export function getAgentSkillDefaults() { + return { ...AGENT_SKILL_DEFAULTS }; +} + +/** + * @param {string} group + * @returns {string} + */ +export function normalizeTypedGroup(group) { + if (group == null) return ""; + const trimmed = String(group).trim(); + const map = { + "Foreign Language": "ForeignLanguage", + "Military Science": "MilitaryScience", + }; + return map[trimmed] ?? trimmed.replace(/\s+/g, ""); +} + +/** + * @param {string} 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} params.automaticSkills + * @param {Record} params.optionSkills + * @param {Record} [params.automaticMeta] + * @param {Record} [params.optionMeta] + * @param {string} [params.excludeKey] + * @returns {string | null} Conflicting map key, if any. + */ +export function findTypedSkillNameConflict({ + group, + label, + chooseOne, + automaticSkills, + optionSkills, + automaticMeta = {}, + optionMeta = {}, + excludeKey = null, +}) { + // Multiple choose-one slots per category are allowed on the profession item. + if (chooseOne) return null; + if (!group) return null; + + const normalizedGroup = normalizeTypedGroup(group); + const normalizedNew = normalizeTypedSkillName(label); + if (!normalizedNew) return "__empty__"; + + const maps = [ + [automaticSkills, automaticMeta], + [optionSkills, optionMeta], + ]; + + for (const [skillMap, metaMap] of maps) { + for (const mapKey of Object.keys(skillMap ?? {})) { + if (mapKey !== excludeKey && mapKey !== PROFESSION_OPTION_PICKS_KEY) { + 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} [metaContext.automaticMeta] + * @param {Record} [metaContext.optionMeta] + * @returns {string} + */ +export function formatProfessionSkillKeyLabel( + key, + { automaticMeta = {}, optionMeta = {} } = {}, +) { + const ref = parseProfessionSkillKey(key); + if (!ref) return key; + if ( + ref.kind === "typed" && + isChooseOneProfessionSkillKey(key, automaticMeta, optionMeta) + ) { + return formatProfessionSkillKey({ + kind: "typed", + group: ref.group, + label: getChooseOnePlaceholderLabel(), + }); + } + if (ref.kind === "typed" && !ref.label?.trim()) { + return getTypedGroupDisplayName(ref.group); + } + return formatProfessionSkillLabel(ref); +} + +/** + * @param {Record} obj + * @returns {{ optionPicks: number, skills: Record }} + */ +export function splitProfessionSkillMap(obj) { + const source = obj ?? {}; + const optionPicks = Number(source[PROFESSION_OPTION_PICKS_KEY]) || 0; + /** @type {Record} */ + const skills = {}; + for (const [key, value] of Object.entries(source)) { + if (key !== PROFESSION_OPTION_PICKS_KEY) { + skills[key] = Number(value); + } + } + return { optionPicks, skills }; +} diff --git a/module/profession/stat-setup.js b/module/profession/stat-setup.js new file mode 100644 index 0000000..75b5868 --- /dev/null +++ b/module/profession/stat-setup.js @@ -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} valuesByKey + * @returns {Promise} + */ +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} 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) => + `${foundry.utils.escapeHTML(String(entry.total))}`, + ); + const rows = []; + for (let i = 0; i < cells.length; i += 3) { + rows.push(`${cells.slice(i, i + 3).join("")}`); + } + return `${rows.join( + "", + )}
`; +} + +/** + * @returns {Record} + */ +export function getDefaultPointBuyValues() { + return Object.fromEntries(STAT_KEYS.map((key) => [key, STAT_MIN])); +} diff --git a/module/profession/validation.js b/module/profession/validation.js new file mode 100644 index 0000000..b215dc8 --- /dev/null +++ b/module/profession/validation.js @@ -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} [context.automaticSkills] + * @param {Record} [context.optionSkills] + * @param {Record} [context.automaticMeta] + * @param {Record} [context.optionMeta] + * @param {number} [context.bondCount] + * @returns {string[]} + */ +export function validateProfessionFormState( + optionPicks, + formState, + context = {}, +) { + const errors = []; + const checked = formState.checkedOptionKeys ?? new Set(); + const picks = Number(optionPicks) || 0; + const { + automaticSkills = {}, + 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>} */ + 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, bonusCount = BONUS_SKILL_COUNT, skillCap = 80 } = {}, +) { + const messages = []; + const seen = new Set(); + + const push = (msg) => { + if (seen.has(msg)) return; + seen.add(msg); + messages.push(msg); + }; + + let needsOptionPicks = false; + let needsBondName = false; + let needsBondRelationship = false; + let needsBonusSkill = false; + let needsBonusSkillType = false; + /** @type {Set} */ + const typedNameRequiredGroups = new Set(); + /** @type {Set} */ + const bonusWasteTrackKeys = new Set(); + + 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.add(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: bonusCount, + }), + ); + } + 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; +} diff --git a/module/roll/classes/dg-damage-roll.js b/module/roll/classes/dg-damage-roll.js new file mode 100644 index 0000000..3ce1576 --- /dev/null +++ b/module/roll/classes/dg-damage-roll.js @@ -0,0 +1,65 @@ +/** @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 { + /** + * @returns {{ rollLabel: string }} + */ + createChatHeader() { + try { + const armorPiercingTooltip = game.i18n.localize("DG.Gear.ArmorPiercing"); + const subtitle = `(${ + this.item.system.armorPiercing + })`; + + const title = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n + .localize("DG.Roll.Damage") + .toUpperCase()} ${game.i18n.localize("DG.Roll.For")} ${ + this.item.name + }`; + + return { + rollLabel: `${title}: ${subtitle}`, + }; + } catch (ex) { + return { + rollLabel: `${game.i18n.localize( + "DG.Roll.RollingDamageFor", + )} ${this.formula.toUpperCase()}`, + }; + } + } + + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * @returns {Promise} - the created chat message. + * @override + */ + async toChat() { + const { rollLabel } = this.createChatHeader(); + const html = await renderTemplate( + "systems/deltagreen/templates/roll/damage-roll.hbs", + { + formula: this.formula, + total: this.total, + }, + ); + + return this.toMessage({ content: html, rollLabel }); + } + + async showDialog() { + return showDamageRollModifyDialog({ + itemName: this.item?.name, + formula: this.formula, + }); + } +} diff --git a/module/roll/classes/dg-lethality-roll.js b/module/roll/classes/dg-lethality-roll.js new file mode 100644 index 0000000..e645896 --- /dev/null +++ b/module/roll/classes/dg-lethality-roll.js @@ -0,0 +1,132 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import { buildRollTargetDisplayHtml } from "../../utils/roll-target-tooltip.js"; +import { DGPercentileRoll } from "./dg-percentile-roll.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +export class DGLethalityRoll extends DGPercentileRoll { + /** + * See constructor for DGPercentileRoll. This theoretically could be done in the parent class' + * constructor, but since Lethality rolls needs its own class for custom methods anyway, + * we will set the target and localized key here. + * + * @param {String} formula + * @param {Object} data + * @param {Object} options + */ + constructor(formula, data, options) { + super(formula, data, options); + this.target = options.item.system.lethality; + this.localizedKey = game.i18n.localize("DG.ItemWindow.Weapons.Lethality"); + } + + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * Lays out and styles message based on outcome of the roll. + * + * Overrides `DGPercentileRoll.toChat()` + * + * @returns {Promise} - the created chat message. + * @override + */ + async toChat() { + let resultString = ""; + let styleOverride = ""; + if (this.total <= this.target) { + resultString = `${game.i18n.localize("DG.Roll.Lethal").toUpperCase()}`; + styleOverride = "color: red"; + } else { + resultString = `${game.i18n.localize("DG.Roll.Failure")}`; + } + + const { nonLethalDamage } = this; + const { rollLabel } = this.createChatHeader(); + + 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, rollLabel }); + } + + /** + * @returns {{ rollLabel: string }} + */ + createChatHeader() { + const base = Number(this.target) || 0; + const manualModifier = Number(this.modifier) || 0; + const effectiveTarget = base + manualModifier; + const targetLine = buildRollTargetDisplayHtml({ + targetValue: `${effectiveTarget}%`, + base, + manualModifier, + finalTarget: effectiveTarget, + }); + + const title = `${game.i18n.localize("DG.Roll.Rolling")} ${game.i18n + .localize("DG.Roll.Lethality") + .toUpperCase()} ${game.i18n.localize( + "DG.Roll.For", + )} ${this.item.name.toUpperCase()}`; + + return { + rollLabel: `${title}: ${targetLine}`, + }; + } + + /** + * Calculates the damage for when a lethality roll fails. + * If roll has not been evaluated, return null. + * + * See full rules on page 57 of agent's handbook. + * + * Note, this getter does not actually care if the roll has failed. + * + * @returns {null|Object} - return data about the non-lethal damage. + */ + get nonLethalDamage() { + if (!this.total) { + return null; + } + + // Try to determine what the d100 result would be as if it was two d10's being rolled. + const totalString = this.total.toString(); + const digits = totalString.length; + let die1; + let die2; + switch (digits) { + case 1: + // If one digit in the result, one die is a 10, and the other is the result. + [die1, die2] = [10, this.total]; + break; + case 2: + // If two digits in the result, each die is the value of one of the digits. If one of those digits is 0, make it 10. + [die1, die2] = totalString + .split("") + .map((digit) => parseInt(digit)) + .map((digit) => digit || 10); + break; + case 3: + // If three digits in the result (aka result === 100), each die is a 10. + [die1, die2] = [10, 10]; + break; + default: + break; + } + + const total = die1 + die2; + return { die1, die2, total }; + } +} diff --git a/module/roll/classes/dg-percentile-roll.js b/module/roll/classes/dg-percentile-roll.js new file mode 100644 index 0000000..bcaa39c --- /dev/null +++ b/module/roll/classes/dg-percentile-roll.js @@ -0,0 +1,437 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +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 { formatProfessionSkillLabel } from "../../profession/index.js"; +import { buildRollTargetDisplayHtml } from "../../utils/roll-target-tooltip.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} - 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} - 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 { rollLabel } = this.createChatHeader(); + + 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, + rollLabel, + }); + + 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, rollLabel }); + } + + /** + * 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 = formatProfessionSkillLabel({ + kind: "typed", + group: skill.group, + label: 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 }; + } + + /** + * i18n key for a user-facing warning when this roll must not be made, or null if allowed. + * @returns {string|null} + */ + get blockedRollMessage() { + if (this.type !== "stat") return null; + + const x5 = Number(this.target); + if (!Number.isFinite(x5) || x5 < 1) { + return "DG.Roll.CannotRollStat"; + } + return null; + } + + /** + * Active-effect roll target field for this roll type (excludes luck). + * @returns {"system.rollTarget.allSkills"|"system.rollTarget.sanity"|"system.rollTarget.statistics"|null} + */ + get rollTargetFieldKey() { + if (this.type === "luck") return null; + if (this.type === "sanity") return "system.rollTarget.sanity"; + if (this.type === "stat") return "system.rollTarget.statistics"; + return "system.rollTarget.allSkills"; + } + + /** + * Create the roll label shown below the card header. + * + * @returns {{ rollLabel: string }} + */ + createChatHeader() { + let displayKey = this.localizedKey; + if ( + this.type === "weapon" && + this.item?.name && + this.key !== "unarmed_combat" + ) { + displayKey = `${this.localizedKey} (${this.item.name})`; + } + + const title = this.isInhuman + ? `${displayKey} [${game.i18n + .localize("DG.Roll.Inhuman") + .toUpperCase()}]` + : `${displayKey}`; + + const targetValue = this.isInhuman + ? `${this.effectiveTarget}` + : `${this.effectiveTarget}%`; + + const base = Number(this.target) || 0; + const targetLine = buildRollTargetDisplayHtml({ + targetValue, + actor: this.actor, + rollTargetFieldKey: this.rollTargetFieldKey, + base, + manualModifier: this.modifier, + finalTarget: this.effectiveTarget, + allowOver99: this.target > 99 && this.type === "stat", + }); + + return { rollLabel: `${title}: ${targetLine}` }; + } + + /** + * 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 Active Effects and manual modifiers. + * All modifiers are summed on the base target, then clamped to 1–99 (or above 99 for inhuman stats). + * + * @returns {null|integer} + */ + get effectiveTarget() { + if (this.target == null || Number.isNaN(this.target)) { + return null; + } + + const base = parseInt(this.target); + const rollTargetModifier = Number(this.rollTargetModifier) || 0; + const manualModifier = + this.modifier && !Number.isNaN(this.modifier) + ? parseInt(this.modifier) + : 0; + const totalModifier = rollTargetModifier + manualModifier; + + if (!totalModifier) return base; + + return clampPercentileRollTarget(base, totalModifier, { + allowOver99: this.target > 99 && this.type === "stat", + }); + } +} diff --git a/module/roll/classes/dg-roll.js b/module/roll/classes/dg-roll.js new file mode 100644 index 0000000..bb854f6 --- /dev/null +++ b/module/roll/classes/dg-roll.js @@ -0,0 +1,83 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import { + createDGRollChatMessage, + prepareDGRollChatMessageData, +} from "../../chat/dg-chat-card.js"; + +export class DGRoll extends Roll { + /** + * NOTE: This class will rarely be called on its own. It should generally be extended. Look to DGPercentileRoll as an example. + * + * Customize our roll with some useful information, passed in the `options` Object. + * + * @param {string} formula Unused - The string formula to parse (from Foundry) + * @param {Object} data Unused - The data object against which to parse attributes within the formula + * @param {Object} [options] Additional data which is preserved in the database + * @param {Number} [options.rollType] The type of roll (stat, skill, sanity, damage, etc). + * @param {String} [options.key] The key of the skill, stat, etc. to use as a basis for this roll. + * @param {DeltaGreenActor} [options.actor] The actor that this roll originates from. + * @param {DeltaGreenItem} [options.item] Optional - The item from which the roll originates. + */ + constructor(formula, data = {}, options = {}) { + super(formula, data, options); + const { rollType, key, actor, item } = options; + this.type = rollType; + this.key = key; + this.actor = actor; + this.item = item; + this.modifier = 0; + } + + /** + * Posts a roll to chat with the Delta Green card shell and an explicit `rolls` + * array for Dice So Nice (listen-path compatible). + * + * @override + * The following `@param` descriptions comes from the Foundry VTT code. + * @param {object} messageData The data object to use when creating the message + * @param {options} [options] Additional options which modify the created message. + * @param {string} [options.messageMode] A key of CONFIG.ChatMessage.modes + * @param {boolean} [options.create=true] Whether to automatically create the chat message, or only return the + * prepared chatData object. + * @returns {Promise} A promise which resolves to the created ChatMessage document if create is + * true, or the Object of prepared chatData otherwise. + */ + async toMessage(messageData = {}, { messageMode, create = true } = {}) { + const { title, subtitle, rollLabel, label, flavor } = messageData; + const resolvedLabel = label ?? flavor; + delete messageData.title; + delete messageData.subtitle; + delete messageData.rollLabel; + delete messageData.label; + + const mode = + messageMode ?? this.options.messageMode ?? this.options.rollMode; + + const chatParams = { + roll: this, + actor: this.actor, + token: this.options.token, + title, + subtitle, + rollLabel, + label: resolvedLabel, + content: messageData.content, + messageMode: mode, + flags: messageData.flags ?? {}, + }; + + if (create) { + return createDGRollChatMessage(chatParams); + } + + const { messageData: prepared, mappedMode } = + await prepareDGRollChatMessageData(chatParams); + + const cls = foundry.utils.getDocumentClass("ChatMessage"); + // eslint-disable-next-line new-cap -- Foundry document class resolved at runtime + const msg = new cls(prepared); + msg.applyMode(mappedMode); + return msg.toObject(); + } +} diff --git a/module/roll/classes/dg-sanity-damage-roll.js b/module/roll/classes/dg-sanity-damage-roll.js new file mode 100644 index 0000000..4f3d2d0 --- /dev/null +++ b/module/roll/classes/dg-sanity-damage-roll.js @@ -0,0 +1,60 @@ +/** @internal Import roll subclasses only from ../roll.js. */ +/* eslint-disable import/prefer-default-export */ +import DGUtils from "../../utils/utility-functions.js"; +import { DGRoll } from "./dg-roll.js"; + +const { renderTemplate } = foundry.applications.handlebars; + +export class DGSanityDamageRoll extends DGRoll { + /** + * Prepares data for a chat message and then passes that data + * to a method that actually creates a ChatMessage. + * + * @returns {Promise} - the created chat message. + * @override + */ + async toChat() { + const [lowDie, highDie] = this.terms[0].terms.map((formula) => { + return Roll.parse(formula)[0] || { faces: parseInt(formula), number: 1 }; + }); + + const [lowResult, highResult] = this.damageResults; + + const rollLabel = `${game.i18n.localize( + "DG.Roll.Rolling", + )} ${DGUtils.localizeWithFallback( + "DG.Generic.SanDamage", + "SAN DAMAGE", + )}: ${game.i18n.localize("DG.Roll.For")} ${lowDie.formula} / ${ + highDie.formula + }`; + + const html = await renderTemplate( + "systems/deltagreen/templates/roll/sanity-damage-roll.hbs", + { + lowFormula: lowDie.formula, + highFormula: highDie.formula, + lowFaces: lowDie.faces, + highFaces: highDie.faces, + lowResult, + highResult, + }, + ); + + return this.toMessage({ content: html, rollLabel }); + } + + /** + * Returns the two results for a sanity damage roll. + * + * Returns null if the roll has not been evaluated. + * + * @returns {null|Array} - Array of result numbers + */ + get damageResults() { + if (!this.total) return null; + + const [lowResult, highResult] = this.terms[0].results; + return [lowResult?.result, highResult?.result]; + } +} diff --git a/module/roll/melee-damage.js b/module/roll/melee-damage.js new file mode 100644 index 0000000..46b357e --- /dev/null +++ b/module/roll/melee-damage.js @@ -0,0 +1,21 @@ +/** + * Append STR melee damage bonus to a weapon damage formula when applicable. + * + * @param {string} diceFormula + * @param {Actor|null} actor + * @param {string|null|undefined} skill Weapon skill key from item system + * @returns {string} + */ +export 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}`; +} diff --git a/module/roll/roll-dialogs.js b/module/roll/roll-dialogs.js new file mode 100644 index 0000000..c45c4fa --- /dev/null +++ b/module/roll/roll-dialogs.js @@ -0,0 +1,257 @@ +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 DAMAGE_OR_LETHALITY_ROLL_DIALOG_TEMPLATE = + "systems/deltagreen/templates/dialog/damage-or-lethality-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 }; + }, + }, + ], + }); +} + +/** + * @param {object} options + * @param {string} options.itemName + * @returns {Promise<"damage"|"lethality"|void>} + */ +export async function showDamageOrLethalityChoiceDialog({ itemName }) { + const content = await renderTemplate( + DAMAGE_OR_LETHALITY_ROLL_DIALOG_TEMPLATE, + { + itemName, + }, + ); + + return showDgDialog({ + modifier: "damage-or-lethality", + content, + window: { + title: game.i18n.localize("DG.DamageOrLethalityRoll.Title"), + }, + close: () => null, + buttons: [ + { + default: true, + label: game.i18n.localize("DG.DamageOrLethalityRoll.RollDamage"), + action: "damage", + icon: "fas fa-dice", + callback: () => "damage", + }, + { + label: game.i18n.localize("DG.DamageOrLethalityRoll.RollLethality"), + action: "lethality", + icon: "fas fa-skull-crossbones", + callback: () => "lethality", + }, + ], + }); +} diff --git a/module/roll/roll.js b/module/roll/roll.js index db002e4..e1ff5c5 100644 --- a/module/roll/roll.js +++ b/module/roll/roll.js @@ -1,847 +1,123 @@ -/* 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} 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} - 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} - 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")} ${ - this.localizedKey - }`; - const endOfLabel = `${game.i18n.localize("DG.Roll.Target")} ${ - this.effectiveTarget - }`; - - let label = this.isInhuman - ? // "Inhuman" stat being rolled. See function for details. - `${startOfLabel} [${game.i18n - .localize("DG.Roll.Inhuman") - .toUpperCase()}] ${endOfLabel}` - : `${startOfLabel}
${endOfLabel}%`; - - const { isExhausted, exhaustedCheckPenalty } = this.exhausted; - - if (this.modifier || isExhausted) { - label += ` (${this.target}%`; - - if (this.modifier) { - label += `${DGUtils.formatStringWithLeadingPlus(this.modifier)}%`; - } - - if (isExhausted) { - label += `${DGUtils.formatStringWithLeadingPlus( - exhaustedCheckPenalty, - )}%`; - } - - label += `)`; - } - - return label; - } - - get exhausted() { - let isExhausted = false; - let exhaustedCheckPenalty = -20; - - try { - // I suspect (but am not entirely certain) that being tired doesn't make you less lucky) - if (this.type !== "luck") { - isExhausted = this.actor.system.physical.exhausted; - exhaustedCheckPenalty = this.actor.system.physical.exhaustedPenalty; - exhaustedCheckPenalty = -1 * Math.abs(exhaustedCheckPenalty); - } - } catch { - isExhausted = false; - exhaustedCheckPenalty = -20; - } - - return { isExhausted, exhaustedCheckPenalty }; - } - - /** - * "Inhuman" stat being rolled, logic is different per page 188 of the Handler's Guide. - * Note - originally implemented by Uriele, but my attempt at merging conficts went poorly, so re-implementing. - * For an inhuman check, the roll succeeds except on a roll of 100 which fails AND fumbles. - * If the roll is a matching digit roll, it is a critical as normal. - * Also, if the roll is below the regular (non-x5) value of the stat, it is a critical. E.g. a CON of 25, a d100 roll of 21 would be a critical. - * - * @returns {Boolean} - */ - get isInhuman() { - /* - Changing this to only consider the base x5 stat target for whether something is 'inhuman' - because I do not think the intent was an Agent with a high strength getting a +40% bonus to be considered 'inhuman' - and therefore benefit from the increased crit threshold, although could be wrong about this. - */ - if (this.target > 99 && this.type === "stat") { - return true; - } - return false; - } - - /** - * Determines if a roll result is critical. - * If roll has not been evaluated, return null. - * - * @returns {null|Boolean} - */ - get isCritical() { - // If roll isn't evaluated, return null. - if (!this.total) { - return null; - } - let isCritical = false; - - // 1, 100, or any matching dice are a crit, i.e. 11, 22, 33...99. - if (this.total === 1 || this.total === 100 || this.total % 11 === 0) { - // really good, or reeaaaally bad - isCritical = true; - } - - // If inhuman and the roll is below the regular (non-x5) value of the stat, it is a critical. - // E.g. a CON of 25, a d100 roll of 21 would be a critical. - if (this.isInhuman && this.total <= this.target / 5) { - isCritical = true; - } - - return isCritical; - } - - /** - * Determines if a roll succeeded. - * If roll has not been evaluated, return null. - * - * @returns {null|Boolean} - */ - get isSuccess() { - // If roll isn't evaluated, return null. - if (!this.total) { - return null; - } - - // A roll of 100 always (critically) fails, even for inhuman rolls. - if (this.total === 100) return false; - return this.total <= this.effectiveTarget; - } - - /** - * Actual target for the roll accounting for modifier if present. - * Floored to 1 if a negative modifier would bring it below 1. - * Capped at 99 unless it is an inhuman stat test. - * Also worth noting, per page 47 of the Agent's Handbook, Exhaustion penalties - * affect not only skill and stat tests, but SAN tests as well... - * - * @returns {null|integer} - */ - get effectiveTarget() { - let target = 1; - - const { isExhausted, exhaustedCheckPenalty } = this.exhausted; - - if (!this.target || Number.isNaN(this.target)) { - return null; - } - - target = parseInt(this.target); - - if (isExhausted) { - target += exhaustedCheckPenalty; - } - - if (this.modifier && !Number.isNaN(this.modifier)) { - const modifier = parseInt(this.modifier); - - target += modifier; - - // per agent's handbook (pg.43), a negative modifier can't lower a target below 1% - target = Math.max(target, 1); - } - - // an 'inhuman' stat test can exceed 99% as a target, but skill tests otherwise cannot (agents handbook pg.43) - if (!this.isInhuman) { - target = Math.min(target, 99); - } - - return target; + 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} + */ +export async function processDGRoll(event, roll) { + const shiftKey = event?.shiftKey ?? false; + const which = event?.which ?? 0; + + if (roll instanceof DGPercentileRoll && roll.blockedRollMessage) { + ui.notifications.warn(roll.blockedRollMessage, { localize: true }); + return; } - /** - * Prepares data for a chat message and then passes that data - * to a method that actually creates a ChatMessage. - * - * Lays out and styles message based on outcome of the roll. - * - * Overrides `DGPercentileRoll.toChat()` - * - * @returns {Promise} - the created chat message. - * @override - */ - async toChat() { - let resultString = ""; - let styleOverride = ""; - if (this.total <= this.target) { - resultString = `${game.i18n.localize("DG.Roll.Lethal").toUpperCase()}`; - styleOverride = "color: red"; - } else { - resultString = `${game.i18n.localize("DG.Roll.Failure")}`; + 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")} ${game.i18n - .localize("DG.Roll.Lethality") - .toUpperCase()} ${game.i18n.localize( - "DG.Roll.For", - )} ${this.item.name.toUpperCase()} ${game.i18n.localize( - "DG.Roll.Target", - )} ${this.target + this.modifier}`; - if (this.modifier) { - label += ` (${DGUtils.formatStringWithLeadingPlus(this.modifier)}%)`; - } - - let html = ""; - html += `
`; - html += `
`; - html += `
${resultString}
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += ` `; - html += ` d100`; - html += ` `; - html += ` `; - html += ` ${this.total}`; - html += ` `; - html += `
`; - html += `
    `; - html += `
  1. ${this.total}`; - html += `
`; - html += `
`; - html += `
`; - html += ` `; - html += ` 2d10 (d10 + d10)`; - html += ` `; - html += ` `; - html += ` ${nonLethalDamage.total}`; - html += ` `; - html += `
`; - html += `
    `; - html += `
  1. ${nonLethalDamage.die1}
  2. `; - html += `
  3. ${nonLethalDamage.die2}
  4. `; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `
`; - html += `

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

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

${lowResult} / ${highResult}

`; - html += `
`; - html += `
`; - return this.toMessage({ content: html, flavor }); - } - - /** - * Returns the two results for a sanity damage roll. - * - * Returns null if the roll has not been evaluated. - * - * @returns {null|Array} - Array of result numbers - */ - get damageResults() { - if (!this.total) return null; - - const [lowResult, highResult] = this.terms[0].results; - return [lowResult?.result, highResult?.result]; } + await roll.evaluate(); + await roll.toChat(); } diff --git a/module/roll/skill-improvement-roll.js b/module/roll/skill-improvement-roll.js new file mode 100644 index 0000000..aeca254 --- /dev/null +++ b/module/roll/skill-improvement-roll.js @@ -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>} + */ +export async function evaluateSkillImprovementRolls( + actor, + baseFormula, + failedSkills, + failedTypedSkills, +) { + const totalFailures = failedSkills.length + failedTypedSkills.length; + + if (!Object.keys(DG.skillImprovementFormulas).includes(baseFormula)) { + throw new Error(`Unknown roll formula: ${baseFormula}`); + } + + const resultObj = {}; + if (baseFormula === "1") { + return resultObj; + } + + const rollPromises = []; + for (let i = 0; i < totalFailures; i++) { + rollPromises.push(new Roll(baseFormula, actor.system).evaluate()); + } + const rolls = await Promise.all(rollPromises); + + [...failedSkills, ...failedTypedSkills].forEach((skill, index) => { + resultObj[skill.key] = rolls[index].total; + }); + + return resultObj; +} + +/** + * @param {object} params + * @param {Actor} params.actor + * @param {TokenDocument|null} [params.token] + * @param {string} params.baseFormula + * @param {object[]} params.failedSkills + * @param {object[]} params.failedTypedSkills + * @param {Record} params.resultObj + * @returns {Promise} + */ +export async function createSkillImprovementChatMessage({ + actor, + token = null, + baseFormula, + failedSkills, + failedTypedSkills, + resultObj, +}) { + const localizeFailedSkills = (skillsArray, 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}: +${increment}%`; + }); + }; + + const failedSkillNames = localizeFailedSkills(failedSkills, "fixed"); + const failedTypedSkillNames = localizeFailedSkills( + failedTypedSkills, + "typed", + ); + + const content = [...failedSkillNames, ...failedTypedSkillNames].join(", "); + const title = game.i18n.format( + "DG.Skills.ApplySkillImprovements.ChatFlavor", + { + formula: getSkillImprovementFormulaAsPercent(baseFormula), + }, + ); + + return createDGChatMessage({ + actor, + token, + title, + content, + messageMode: game.settings.get("core", "messageMode"), + }); +} diff --git a/module/settings.js b/module/settings.js index 2e3ad25..801ab19 100644 --- a/module/settings.js +++ b/module/settings.js @@ -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, }); } diff --git a/module/sheets/agent-sheet-v2.js b/module/sheets/agent-sheet-v2.js deleted file mode 100644 index f013bee..0000000 --- a/module/sheets/agent-sheet-v2.js +++ /dev/null @@ -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); - } -} diff --git a/module/sheets/agent-sheet.js b/module/sheets/agent-sheet.js index cd406dd..b810d36 100644 --- a/module/sheets/agent-sheet.js +++ b/module/sheets/agent-sheet.js @@ -1,21 +1,69 @@ -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, { + buildWillpowerChangeSpan, +} 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 +71,244 @@ export default class DGAgentSheet extends DGActorSheet { labelPrefix: "DG.Navigation.Agent", tabs: [ { id: "skills" }, - { id: "physical" }, - { id: "motivations" }, + { id: "combat" }, { id: "gear" }, - { id: "bio" }, - { id: "bonds" }, + { id: "motivations" }, + { id: "personal" }, + { id: "effects" }, { id: "about", icon: "fas fa-question-circle", label: "" }, ], }, + leftBarRestSanity: { + initial: "sanity", + labelPrefix: "DG.Navigation.Agent.LeftBar", + tabs: [{ id: "sanity" }, { id: "rest" }], + }, }); /** @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/gear-tab-agent.html`, + `${this.TEMPLATE_PATH}/parts/motivations-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.GEAR_SECTION_PARTIALS, + `${this.TEMPLATE_PATH}/partials/injuries-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,218 @@ 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, + rollLabelKey: "DG.Physical.Chat.ExhaustedRollLabel", + i18nData: { + willpowerChange: buildWillpowerChangeSpan({ + amount: 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, + rollLabelKey: wasExhausted + ? "DG.Physical.Chat.RestedRollLabelExhausted" + : "DG.Physical.Chat.RestedRollLabel", + i18nData: { + willpowerChange: buildWillpowerChangeSpan({ + amount: 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 rollLabelKey = + choice === "hard" + ? "DG.Physical.Chat.StimulantsHardRollLabel" + : "DG.Physical.Chat.StimulantsRegularRollLabel"; + + const appliedHours = await applyStimulantEffect(actor, hours); + + await createAgentResourceChatMessage({ + actor, + token: this.token, + roll: hoursRoll, + rollLabelKey, + i18nData: { + hours: appliedHours, + }, + }); + } + + /** + * @param {Record} 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 +564,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 +584,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 +641,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 +661,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 +683,56 @@ 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} */ - 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} + * @param {string} type + * @returns {Promise} */ - _createSkillImprovementChatCard( - baseFormula, - failedSkills, - failedTypedSkills, - resultObj, - ) { - const localizeFailedSkills = (skillsArray) => { - return skillsArray.map((skill) => { - const increment = resultObj[skill.key] ?? 1; - const label = - skill.label ?? game.i18n.localize(`DG.Skills.${skill.key}`); // fallback for regular skills - const groupLabel = skill.group - ? `${game.i18n.localize( - `DG.TypeSkills.${skill.group.replace(/\s+/g, "")}`, - )} (${label})` - : label; + _onItemCreate(type) { + if (type !== "profession") { + return super._onItemCreate(type); + } - return `${groupLabel}: +${increment}%`; - }); - }; + const name = game.i18n.format("DG.FallbackText.newItem", { + type: game.i18n.localize(`TYPES.Item.${type}`), + }); - const failedSkillNames = localizeFailedSkills(failedSkills); - const failedTypedSkillNames = localizeFailedSkills(failedTypedSkills); - - // Prepare chat data - const content = [...failedSkillNames, ...failedTypedSkillNames].join(", "); - const flavor = game.i18n.format( - "DG.Skills.ApplySkillImprovements.ChatFlavor", - { - formula: DGAgentSheet._getSkillImprovementFormulaAsPercent(baseFormula), + const itemData = { + name, + type: "profession", + system: { + bonds: 0, + automaticSkills: {}, + automaticSkillMeta: {}, + optionSkills: { [PROFESSION_OPTION_PICKS_KEY]: 0 }, + optionSkillMeta: {}, }, - ); - - const chatData = { - speaker: ChatMessage.getSpeaker({ - actor: this.actor, - token: this.token, - alias: this.actor.name, - }), - content, - flavor, - rollMode: game.settings.get("core", "rollMode"), }; - 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} - the update promise - */ _applySkillImprovements(failedSkills, failedTypedSkills, resultObj) { const updateSkills = (skillsArray, updatedTarget) => { skillsArray.forEach((skill) => { @@ -344,17 +754,4 @@ export default class DGAgentSheet extends DGActorSheet { system: { skills: updatedSkills, typedSkills: updatedTypedSkills }, }); } - - /** - * Takes a base formula and returns a string representing the skill improvement formula as a percentage. - * TODO: Move to the roll class itself, probably even a "SkillImprovementRoll". - * - * @param {SkillImprovementFormula} baseFormula - the base formula to convert - * @returns {string} the skill improvement formula as a percentage (e.g. "1D3%" or "1%") - */ - static _getSkillImprovementFormulaAsPercent(baseFormula) { - const flat = baseFormula === "1"; - const formula = flat ? "1" : `1${baseFormula}`; - return `${formula}%`.toUpperCase(); - } } diff --git a/module/sheets/base-actor-sheet.js b/module/sheets/base-actor-sheet.js index 72ea9b9..27a355e 100644 --- a/module/sheets/base-actor-sheet.js +++ b/module/sheets/base-actor-sheet.js @@ -1,30 +1,37 @@ -import DG from "../config.js"; -import { - DGDamageRoll, - DGLethalityRoll, - DGPercentileRoll, - DGSanityDamageRoll, -} from "../roll/roll.js"; import DGSheetMixin from "./base-sheet.js"; +import composeMixins from "./mixins/compose-mixins.js"; +import ItemListMixin from "./mixins/item-list-mixin.js"; +import RollSheetMixin from "./mixins/roll-sheet-mixin.js"; +import SkillPrepMixin from "./mixins/skill-prep-mixin.js"; +import SpecialTrainingMixin from "./mixins/special-training-mixin.js"; +import TypedSkillMixin from "./mixins/typed-skill-mixin.js"; +import AeInputMixin from "./mixins/ae-input-mixin.js"; const { ActorSheetV2 } = foundry.applications.sheets; -const { DialogV2 } = foundry.applications.api; -export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { +const ComposedActorSheetBase = composeMixins( + AeInputMixin, + SpecialTrainingMixin, + TypedSkillMixin, + ItemListMixin, + SkillPrepMixin, + RollSheetMixin, + DGSheetMixin, +)(ActorSheetV2); + +/** @extends {ComposedActorSheetBase} */ +export default class DGActorSheet extends ComposedActorSheetBase { /** @override */ static DEFAULT_OPTIONS = /** @type {const} */ ({ position: { width: 750, height: 770 }, actions: { - // Skill/Item actions. itemAction: this._onItemAction, typedSkillAction: this._onTypedSkillAction, specialTrainingAction: this._onSpecialTrainingAction, roll: this._onRoll, rollLuck: this._onRollLuck, - // Toggles. toggle: this._toggleGeneric, toggleItemSortMode: this._toggleItemSortMode, - // Other actions. browsePack: this._browsePack, }, }); @@ -32,7 +39,20 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { /** @override - Singular reference to the actor templates path */ static TEMPLATE_PATH = /** @type {const} */ (`${super.TEMPLATE_PATH}/actor`); - /** Holds base/shared parts of sheets for subclasses to reference. */ + /** Gear tab section partials shared by NPC/unnatural/vehicle gear tabs and agent combat tab. */ + static get GEAR_SECTION_PARTIALS() { + return [ + `${this.TEMPLATE_PATH}/partials/weapons-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/armor-section-partial.html`, + `${this.TEMPLATE_PATH}/partials/other-gear-section-partial.html`, + ]; + } + + /** + * Base/shared sheet parts for subclasses. + * ApplicationV2: every template referenced via {@code {{> "path"}}} in a PART's render + * tree must be listed in that PART's {@code templates} array. + */ static BASE_PARTS = /** @type {const} */ ({ header: { template: `${this.TEMPLATE_PATH}/parts/header.html`, @@ -43,7 +63,7 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { ], }, tabs: { - template: `templates/generic/tab-navigation.hbs`, // From FoundryVTT + template: `templates/generic/tab-navigation.hbs`, }, skills: { template: `${this.TEMPLATE_PATH}/parts/skills-tab.html`, @@ -55,6 +75,7 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { }, gear: { template: `${this.TEMPLATE_PATH}/parts/gear-tab.html`, + templates: this.GEAR_SECTION_PARTIALS, scrollable: [""], }, about: { @@ -63,64 +84,34 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { }, }); - /** @override */ - async _processSubmitData(event, form, submitData, options) { - const submittedData = foundry.utils.expandObject(submitData); - - if (this.actor.type !== "unnatural") - return super._processSubmitData(event, form, submittedData, options); - - // we need to update max WP if POW has changed (only for unnatural, but doesn't hurt others) - submittedData.system.wp.maxNeedsUpdate = - submittedData.system?.statistics.pow.value !== - this.actor.system.statistics.pow.value; - - // we need to update max HP if STR or CON have changed (only for unnatural, but doesn't hurt others) - submittedData.system.health.maxNeedsUpdate = - submittedData.system?.statistics.str.value !== - this.actor.system.statistics.str.value || - submittedData.system?.statistics.con.value !== - this.actor.system.statistics.con.value; - - return super._processSubmitData(event, form, submittedData, options); - } - /** @override */ async _prepareContext(options) { const context = await super._prepareContext(options); - // Prepare items. this._prepareCharacterItems(context); context.showHyperGeometrySection = this.shouldShowHyperGeometrySection( this.actor, ); - // Prepare subname info placeholder. context.subnameInfoPlaceholder = this._prepareSubnameInfoPlaceholder(); - // Prepare descriptions for each sheet. - context.enrichedDescription = await this._prepareDescriptions(); - - // Early return if this is a vehicle. if (this.actor.type === "vehicle") return context; - // Provide the sheet context sorted skills. this._sortSkills(); - this._sortCustomSkills(); + if (this.actor.type !== "agent") { + this._sortCustomSkills(); + } - // Skill tooltip display setting context.skillTooltipDisplay = game.settings.get( "deltagreen", "skillTooltipDisplay", ); if (context.skillTooltipDisplay !== "never") { - // Setup tooltips this._prepareSkillTooltips(); } - // Handle private sanity setting, override for GMs. const keepSanityPrivate = game.settings.get( "deltagreen", "keepSanityPrivate", @@ -131,1345 +122,47 @@ export default class DGActorSheet extends DGSheetMixin(ActorSheetV2) { context.currentSan = hideSan ? "???" : this.actor.system.sanity.value; context.keepSanityPrivate = keepSanityPrivate; - // Set sanity block per actor type. context.sanityInputs = await foundry.applications.handlebars.renderTemplate( `${DGActorSheet.TEMPLATE_PATH}/partials/sanity-${this.actor.type}.html`, context, ); - // Set title for Physical Attributes. context.physicalAttributesTitle = game.i18n.localize( "DG.Sheet.BlockHeaders.Statistics", ); - // Whether to append the notes section to the skills. context.showNotesInSkills = this.actor.type !== "agent"; return context; } - /** @override - Manipulate which PARTS are rendered. */ + /** @override */ _configureRenderOptions(options) { super._configureRenderOptions(options); if (!this.actor.limited) return; - // Hide most PARTS on limited sheets. options.parts = ["header"]; - - if (this.actor.type === "agent") options.parts.push("tabs", "bio"); } /** @override */ async _onFirstRender(context, options) { await super._onFirstRender(context, options); - this._setRightClickListeners(); } - /** @override - Manipulate which TABS are rendered. */ - _prepareTabs(group) { - const tabs = super._prepareTabs(group); - - // Hide most TABS on limited sheets. - if (!this.actor.limited || this.actor.type !== "agent") return tabs; - - // Override settings to activate bio tab (only tab shown). - return { bio: { ...tabs.bio, active: true, cssClass: "active" } }; - } - /** @override */ async _onRender(context, options) { await super._onRender(context, options); const { element } = this; - // Reset height if this is a limited sheet. if (this.actor.limited) { this.setPosition({ height: "auto" }); this.setPosition({ height: Number.parseInt(this.element.style.height) }); } - const handler = (ev) => this._onDragStart(ev); - element.querySelectorAll("li.item").forEach((li) => { - if (li.classList.contains("inventory-header")) return; - li.setAttribute("draggable", true); - li.addEventListener("dragstart", handler, false); - }); - - this._tooltipsSettings(this.element); - } - - /** @override - Add buttons to the header controls. */ - _getHeaderControls() { - const controls = super._getHeaderControls(); - controls.push({ - action: "rollLuck", - label: "DG.RollLuck", - icon: "fas fa-dice", - }); - return controls; - } - - /** @override */ - _onDragStart(event) { - // Most of this is the standard Foundry implementation of _onDragStart - const li = event.currentTarget; - if (event.target.classList.contains("content-link")) return; - - // Create drag data - let dragData; - - // Owned Items - if (li.dataset.itemId) { - const item = this.actor.items.get(li.dataset.itemId); - dragData = item.toDragData(); - } - - // Active Effect - if (li.dataset.effectId) { - const effect = this.actor.effects.get(li.dataset.effectId); - dragData = effect.toDragData(); - } - - if (!dragData) return; - - // this is custom, grab item data for creating item macros on the hotbar - if (li.dataset.itemId) { - const item = this.actor.items.get(li.dataset.itemId); - dragData.itemData = item; - } - - // Set data transfer - event.dataTransfer.setData("text/plain", JSON.stringify(dragData)); - } - - /** @override */ - async _onDrop(event) { - super._onDrop(event); - // If alt key is held down, we will delete the original document. - if (event.altKey) { - // This is from Foundry. It will get the item data from the event. - const TextEditor = foundry.applications.ux.TextEditor.implementation; - const dragData = TextEditor.getDragEventData(event); - // Make sure that we are dragging an item, otherwise this doesn't make sense. - if (dragData.type === "Item") { - const item = fromUuidSync(dragData.uuid); - await item.delete(); - } - } - } - - /** @override */ - activateEditor(target, editorOptions, initialContent) { - editorOptions.content_css = "./systems/deltagreen/css/editor.css"; - return super.activateEditor(target, editorOptions, initialContent); - } - - /* --------- Context Preparation Functions --------- */ - - /** - * Prepares the subname info placeholder based on the actor type. - * Returns a localized string that acts as a placeholder for different - * types of actors, such as "unnatural", "vehicle", or defaulting to - * "agent" type. - * - * @returns {string} The localized placeholder string for the actor's subname info. - */ - _prepareSubnameInfoPlaceholder() { - let subnameInfoPlaceholder = ""; - switch (this.actor.type) { - case "unnatural": - subnameInfoPlaceholder = - "DG.UnnaturalSheet.ShortDescriptionPlaceholder"; - break; - case "vehicle": - subnameInfoPlaceholder = "DG.VehicleSheet.DescriptionPlaceHolder"; - break; - default: - subnameInfoPlaceholder = "DG.AgentSheet.ProfessionPlaceholder"; - break; - } - return game.i18n.localize(subnameInfoPlaceholder); - } - - /** - * Prepares and enriches the description for an actor based on its type. - * - * @returns {Promise} The outer HTML of the enriched description. - */ - async _prepareDescriptions() { - let descriptionPath; - - switch (this.actor.type) { - case "agent": - descriptionPath = "system.physical.description"; - break; - case "npc": - case "unnatural": - descriptionPath = "system.notes"; - break; - case "vehicle": - descriptionPath = "system.description"; - break; - default: - break; - } - - const descriptionValue = foundry.utils.getProperty( - this.actor, - descriptionPath, - ); - - const enrichedDescription = - await foundry.applications.ux.TextEditor.implementation.enrichHTML( - descriptionValue, - { - rollData: this.document.getRollData(), - relativeTo: this.document, - }, - ); - const { HTMLProseMirrorElement } = foundry.applications.elements; - return HTMLProseMirrorElement.create({ - name: descriptionPath, - value: descriptionValue, - enriched: enrichedDescription, - toggled: true, - }).outerHTML; - } - - /** - * Sorts the skills on the actor sheet based on the appropriate localized entry. - * If the localized entry is not found, the sort label is the skill key. - * - * @returns {void} - */ - _sortSkills() { - // fill an array that is sorted based on the appropriate localized entry - const sortedSkills = []; - for (const [key, skill] of Object.entries(this.actor.system.skills)) { - skill.key = key; - - if (game.i18n.lang === "ja") { - skill.sortLabel = game.i18n.localize(`DG.Skills.ruby.${key}`); - } else { - skill.sortLabel = game.i18n.localize(`DG.Skills.${key}`); - } - - if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) { - skill.sortLabel = skill.key; - } - - // if the actor is an NPC or Unnatural, and they have 'hide untrained skills' active, - // it will break the sorting logic, so we have to skip over these - if ( - !( - (this.actor.type === "npc" || this.actor.type === "unnatural") && - this.actor.system.showUntrainedSkills && - skill.proficiency < 1 - ) - ) { - sortedSkills.push(skill); - } - } - - sortedSkills.sort((a, b) => { - return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang); - }); - - // if sorting by columns, re-arrange the array to be columns first, then rows - if (game.settings.get("deltagreen", "sortSkills")) { - const columnSortedSkills = this.reorderForColumnSorting(sortedSkills, 3); - this.actor.system.sortedSkills = columnSortedSkills; - } else { - this.actor.system.sortedSkills = sortedSkills; - } - } - - /** - * Sorts the custom skills on the actor sheet based on the appropriate localized entry. - * If the localized entry is not found, the sort label is the skill key. - * If sorting by columns, re-arranges the array to be columns first, then rows. - * - * @returns {void} - */ - _sortCustomSkills() { - // Prepare a simplified version of the special training for display on sheet. - const specialTrainings = this._prepareSpecialTrainings(); - - // try to make a combined array of both typed skills and special trainings, - // so that it can be sorted together neatly on the sheet - const sortedCustomSkills = []; - - for (const [key, skill] of Object.entries(this.actor.system.typedSkills)) { - skill.type = "typeSkill"; - skill.key = key; - skill.sortLabel = `${skill.group}.${skill.label}`; - skill.sortLabel = skill.sortLabel.toUpperCase(); - skill.actorType = this.actor.type; - - if (skill.sortLabel === "" || skill.sortLabel === `DG.Skills.${key}`) { - skill.sortLabel = skill.key; - } - - sortedCustomSkills.push(skill); - } - - for (let i = 0; i < specialTrainings.length; i++) { - const training = specialTrainings[i]; - - training.type = "training"; - training.sortLabel = training.name.toUpperCase(); - training.actorType = this.actor.type; - - sortedCustomSkills.push(training); - } - - sortedCustomSkills.sort((a, b) => { - return a.sortLabel.localeCompare(b.sortLabel, game.i18n.lang); - }); - - if (game.settings.get("deltagreen", "sortSkills")) { - const columnSortedSkills = this.reorderForColumnSorting( - sortedCustomSkills, - 2, - ); - - this.actor.system.sortedCustomSkills = columnSortedSkills; - } else { - this.actor.system.sortedCustomSkills = sortedCustomSkills; - } - } - - /** - * Sets up the list of sorted skills with their respective tooltips. - * - * @returns {void} - */ - _prepareSkillTooltips() { - for (const skill of Object.values(this.actor.system.sortedSkills)) { - skill.tooltip = game.i18n.localize(`DG.Skills.Tooltip.${skill.key}`); - if (!skill.proficiency) { - skill.tooltip = skill.tooltip.concat( - "

", - game.i18n.localize("DG.Tooltip.CannotRollSkillLabel"), - ); - } - } - } - - /** - * Prepares a simplified version of the special training for display on sheet. - * - * It takes the special training from the actor's system and converts the - * machine-readable name to a human-readable one. It also adds the proficiency - * of the special training to the object. - * - * @returns {object[]} An array of simplified special training objects. - */ - _prepareSpecialTrainings() { - return this.actor.system.specialTraining.map((training) => { - const simplifiedTraining = { - name: training.name, - id: training.id, - key: training.attribute, - }; - // Convert the machine-readable name to a human-readable one. - switch (true) { - // Stats - case DG.statistics.includes(training.attribute): - simplifiedTraining.attribute = `${training.attribute.toUpperCase()}x5`; - simplifiedTraining.targetNumber = - this.actor.system.statistics[training.attribute].x5; - break; - // Skills - case DG.skills.includes(training.attribute): - simplifiedTraining.attribute = - this.actor.system.skills[training.attribute].label; - simplifiedTraining.targetNumber = - this.actor.system.skills[training.attribute].proficiency; - break; - // Typed Skills - default: - simplifiedTraining.attribute = - this.actor.system.typedSkills[training.attribute].label; - simplifiedTraining.targetNumber = - this.actor.system.typedSkills[training.attribute].proficiency; - break; - } - return simplifiedTraining; - }); - } - - /** - * Listens for right click events on the actor sheet and executes a regular roll, - * or luck roll, depending on the action. - * - * @returns {void} - */ - _setRightClickListeners() { - const { element } = this; - element.addEventListener("contextmenu", (event) => { - event.preventDefault(); - const target = event.target.closest( - "[data-action='roll'],[data-action='rollLuck']", - ); - if (!target) return; - - // Call _onRollLuck if luckRoll is the dispatched action. - if (target.dataset.action === "rollLuck") { - DGActorSheet._onRollLuck.call(this, event, target); - return; - } - - // Otherwise, call _onRoll function. - DGActorSheet._onRoll.call(this, event, target); - }); - } - - // some handlers may wish to avoid leading players to think they should be seeking out magic - // so control whether an actor sheet shows the hypergeometry (rituals and tomes) section - shouldShowHyperGeometrySection(actor) { - // always show for GM - if (game.user.isGM) { - return true; - } - - // check system setting to see if it should always be shown - if ( - game.settings.get( - "deltagreen", - "alwaysShowHypergeometrySectionForPlayers", - ) - ) { - return true; - } - - // otherwise only show if an actor has an item of that type added to their sheet. - for (const i of actor.items) { - if (i.type === "tome" || i.type === "ritual") { - return true; - } - } - - return false; - } - - /** - * Organize and classify Items for Character sheets. - * - * @param {Object} actorData The actor to prepare. - * - * @return {undefined} - */ - _prepareCharacterItems() { - const { actor } = this; - - // Initialize containers. - const armor = []; - const weapons = []; - const gear = []; - const tomes = []; - const rituals = []; - - // Iterate through items, allocating to containers - // let totalWeight = 0; - for (const i of actor.items) { - // Append to armor. - if (i.type === "armor") { - armor.push(i); - } - // Append to weapons. - else if (i.type === "weapon") { - weapons.push(i); - } else if (i.type === "gear") { - gear.push(i); - } else if (i.type === "tome") { - tomes.push(i); - } else if (i.type === "ritual") { - rituals.push(i); - } - } - - if (actor.system.settings.sorting.armorSortAlphabetical) { - armor.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - armor.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.weaponSortAlphabetical) { - weapons.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - weapons.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.gearSortAlphabetical) { - gear.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - gear.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.tomeSortAlphabetical) { - tomes.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - tomes.sort((a, b) => { - return a.sort - b.sort; - }); - } - - if (actor.system.settings.sorting.ritualSortAlphabetical) { - rituals.sort((a, b) => { - const x = a.name.toLowerCase(); - const y = b.name.toLowerCase(); - if (x < y) { - return -1; - } - if (x > y) { - return 1; - } - return 0; - }); - } else { - rituals.sort((a, b) => { - return a.sort - b.sort; - }); - } - - // Assign and return - actor.armor = armor; - actor.weapons = weapons; - actor.gear = gear; - actor.rituals = rituals; - actor.tomes = tomes; - } - - reorderForColumnSorting(arr, numCols) { - const numRows = Math.ceil(arr.length / numCols); // Compute required rows - const reordered = new Array(arr.length); - - // Determine how many elements each column gets - const baseRowCount = Math.floor(arr.length / numCols); // Minimum rows per column - const extraColumns = arr.length % numCols; // Some columns get an extra row - - const colHeights = new Array(numCols).fill(baseRowCount); - for (let i = 0; i < extraColumns; i++) { - colHeights[i] += 1; // Give extra elements to the first N columns - } - - let index = 0; // move through alphabetical array, keeping track of what we've resorted already - - for (let col = 0; col < numCols; col++) { - // need to check if this is a column that has more rows than the others or not - const rowCount = colHeights[col]; - - // loop down the column, filling out it's values from the alphabetical array - for (let row = 0; row < rowCount; row++) { - // calculate the new position for this value by column - const newIndex = numCols * row + col; - - if (newIndex < arr.length) { - reordered[newIndex] = arr[index]; - index += 1; - } - } - } - - return reordered; - } - - static _onItemAction(event, target) { - const li = target.closest(".item"); - const { itemId } = li.dataset; - const { actionType, itemType } = target.dataset; - - switch (actionType) { - case "create": - this._onItemCreate(itemType); - break; - case "edit": { - const item = this.actor.items.get(itemId); - item.sheet.render(true); - break; - } - case "delete": { - this.actor.deleteEmbeddedDocuments("Item", [itemId]); - break; - } - default: - break; - } - } - - /** - * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset - * @param {String} type The originating click event - * @private - */ - _onItemCreate(type) { - // Initialize a default name. - const name = game.i18n.format("DG.FallbackText.newItem", { - type: game.i18n.localize(`TYPES.Item.${type}`), - }); - - // Prepare the item object. - const itemData = { - name, - type, - system: {}, - }; - - if (type === "weapon") { - // itemData.system.skill = "firearms"; //default skill to firearms, since that will be most common - // itemData.system.expense = "Standard"; - } else if (type === "armor") { - // itemData.system.armor = 3; - // itemData.system.expense = "Standard"; - } else if (type === "bond") { - // try to default bonds for an agent to their current CHA - itemData.system.score = this.actor.system.statistics.cha.value; // Can vary, but at character creation starting bond score is usually agent's charisma - // itemData.img = "icons/svg/mystery-man.svg" - } - - // create the item - return this.actor.createEmbeddedDocuments("Item", [itemData]); - } - - static _onTypedSkillAction(event, target) { - const { actionType, typedskill } = target.dataset; - switch (actionType) { - case "create": - this._showNewTypeSkillDialog(); - break; - case "edit": - this._showNewEditTypeSkillDialog(typedskill); - break; - case "delete": - this.actor.update({ [`system.typedSkills.-=${typedskill}`]: null }); - break; - default: - break; - } - } - - _showNewEditTypeSkillDialog(targetSkill) { - // TO DO: BUILD DIALOG TO CAPTURE UPDATED DATA - - const { typedSkills } = this.actor.system; - const currentLabel = typedSkills[targetSkill].label; - const currentGroup = typedSkills[targetSkill].group; - - let htmlContent = `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - new DialogV2({ - content: htmlContent, - window: { - title: - game.i18n.translations.DG?.Skills?.EditTypedOrCustomSkill ?? - "Edit Typed or Custom Skill", - }, - buttons: [ - { - default: true, - action: "add", - label: game.i18n.translations.DG?.Skills?.EditSkill ?? "Edit Skill", - callback: (event, button, dialog) => { - const newTypeSkillLabel = dialog.element.querySelector( - "[name='new-type-skill-label']", - )?.value; - const newTypeSkillGroup = dialog.element.querySelector( - "[name='new-type-skill-group']", - )?.value; - this._updateTypedSkill( - targetSkill, - newTypeSkillLabel, - newTypeSkillGroup, - ); - }, - }, - ], - }).render(true); - } - - _showNewTypeSkillDialog() { - let htmlContent = ""; - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - htmlContent += `
`; - htmlContent += ` `; - htmlContent += ` `; - htmlContent += `
`; - - new DialogV2({ - content: htmlContent, - window: { - title: - game.i18n.translations.DG?.Skills?.AddTypedOrCustomSkill ?? - "Add Typed or Custom Skill", - }, - default: "add", - buttons: [ - { - default: true, - action: "add", - label: game.i18n.translations.DG?.Skills?.AddSkill ?? "Add Skill", - callback: (event, button, dialog) => { - const newTypeSkillLabel = dialog.element.querySelector( - "[name='new-type-skill-label']", - )?.value; - const newTypeSkillGroup = dialog.element.querySelector( - "[name='new-type-skill-group']", - )?.value; - this._addNewTypedSkill(newTypeSkillLabel, newTypeSkillGroup); - }, - }, - ], - }).render(true); - } - - _addNewTypedSkill(newSkillLabel, newSkillGroup) { - const updatedData = foundry.utils.duplicate(this.actor.system); - const { typedSkills } = updatedData; - - const d = new Date(); - - const newSkillPropertyName = - d.getFullYear().toString() + - (d.getMonth() + 1).toString() + - d.getDate().toString() + - d.getHours().toString() + - d.getMinutes().toString() + - d.getSeconds().toString(); - // console.log(newSkillPropertyName); - typedSkills[newSkillPropertyName] = { - label: newSkillLabel, - group: newSkillGroup, - proficiency: 10, - failure: false, - }; - - updatedData.typedSkills = typedSkills; - - this.actor.update({ system: updatedData }); - } - - _updateTypedSkill(targetSkill, newSkillLabel, newSkillGroup) { - if ( - newSkillLabel !== null && - newSkillLabel !== "" && - newSkillGroup !== null && - newSkillGroup !== "" - ) { - const updatedData = foundry.utils.duplicate(this.actor.system); - - updatedData.typedSkills[targetSkill].label = newSkillLabel; - updatedData.typedSkills[targetSkill].group = newSkillGroup; - - this.actor.update({ system: updatedData }); - } - } - - static _onSpecialTrainingAction(event, target) { - const { actionType, id } = target.dataset; - switch (actionType) { - case "delete": - { - const specialTrainingArray = foundry.utils.duplicate( - this.actor.system.specialTraining, - ); - - // Get the index of the training to be deleted - const index = specialTrainingArray.findIndex( - (training) => training.id === id, - ); - - specialTrainingArray.splice(index, 1); - this.actor.update({ "system.specialTraining": specialTrainingArray }); - } - break; - default: - this._showSpecialTrainingDialog(actionType, id); - break; - } - } - - async _showSpecialTrainingDialog(action, targetID) { - const specialTraining = this.actor.system.specialTraining.find( - (training) => training.id === targetID, - ); - - // Define the option groups for our drop-down menu. - const optionGroups = { - stats: game.i18n.localize( - "DG.SpecialTraining.Dialog.DropDown.Statistics", - ), - skills: game.i18n.localize("DG.SpecialTraining.Dialog.DropDown.Skills"), - typedSkills: game.i18n.localize( - "DG.SpecialTraining.Dialog.DropDown.CustomSkills", - ), - }; - - // Prepare simplified stat list - const statList = Object.entries(this.actor.system.statistics).map( - ([key, stat]) => ({ - value: key, - group: optionGroups.stats, - label: game.i18n.localize(`DG.Attributes.${key}`), - targetNumber: stat.value * 5, - }), - ); - - // Prepare simplified skill list - const skillList = Object.entries(this.actor.system.skills).map( - ([key, skill]) => ({ - value: key, - group: optionGroups.skills, - label: game.i18n.localize(`DG.Skills.${key}`), - targetNumber: skill.proficiency, - }), - ); - - // Prepare simplified typed/custom skill list - const typedSkillList = Object.entries(this.actor.system.typedSkills).map( - ([key, skill]) => ({ - value: key, - group: optionGroups.typedSkills, - label: `${game.i18n.localize(`DG.TypeSkills.${skill.group}`)} (${ - skill.label - })`, - targetNumber: skill.proficiency, - }), - ); - - // Prepare the Select element - const selectElement = foundry.applications.fields.createSelectInput({ - name: "special-training-skill", - options: [...statList, ...skillList, ...typedSkillList], - groups: Object.values(optionGroups), - }).outerHTML; - - // Prepare the template to feed to Dialog. - const { renderTemplate } = foundry.applications.handlebars; - const content = await renderTemplate( - "systems/deltagreen/templates/dialog/special-training.html", - { - name: specialTraining?.name || "", - selectElement, - currentAttribute: specialTraining?.attribute || "", - statList, - skillList, - typedSkillList, - }, - ); - - const buttonLabel = game.i18n.localize( - `DG.SpecialTraining.Dialog.${action.capitalize()}SpecialTraining`, - ); - - // Prepare and render dialog with above template. - new DialogV2({ - content, - window: { - title: game.i18n.localize("DG.SpecialTraining.Dialog.Title"), - }, - default: "confirm", - buttons: [ - { - default: true, - action: "confirm", - label: buttonLabel, - callback: (event, button, dialog) => { - const specialTrainingLabel = dialog.element.querySelector( - "[name='special-training-label']", - )?.value; - const specialTrainingAttribute = dialog.element.querySelector( - "[name='special-training-skill']", - )?.value; - if (action === "create") - this._createSpecialTraining( - specialTrainingLabel, - specialTrainingAttribute, - ); - if (action === "edit") - this._editSpecialTraining( - specialTrainingLabel, - specialTrainingAttribute, - targetID, - ); - }, - }, - ], - }).render(true); - } - - _createSpecialTraining(label, attribute) { - const specialTrainingArray = foundry.utils.duplicate( - this.actor.system.specialTraining, - ); - specialTrainingArray.push({ - name: label, - attribute, - id: foundry.utils.randomID(), - }); - this.actor.update({ "system.specialTraining": specialTrainingArray }); - } - - _editSpecialTraining(label, attribute, id) { - const specialTrainingArray = foundry.utils.duplicate( - this.actor.system.specialTraining, - ); - const specialTraining = specialTrainingArray.find( - (training) => training.id === id, - ); - specialTraining.name = label; - specialTraining.attribute = attribute; - this.actor.update({ "system.specialTraining": specialTrainingArray }); - } - - /** - * Handle clickable rolls. - * - * @param {Event} event The originating click event - * @async - * @private - */ - static async _onRoll(event, target) { - if (target.classList.contains("not-rollable") || event.which === 2) return; - - const { dataset } = target; - const item = this.actor.items.get(dataset.iid); - const rollOptions = { - rollType: dataset.rolltype, - key: dataset.key, - actor: this.actor, - specialTrainingName: dataset?.name || null, // Only applies to Special Training Rolls - 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 = item.system.damage; - const { skill } = 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": { - const { successLoss, failedLoss } = this.actor.system.sanity; - const combinedFormula = `{${successLoss}, ${failedLoss}}`; - roll = new DGSanityDamageRoll(combinedFormula, {}, rollOptions); - break; - } - default: - break; - } - this.processRoll(event, roll, rollOptions); - } - - // This only exists to give a chance to activate the modifier dialogue if desired - // Cannot seem to trigger the event on a right-click, so unfortunately only applies to a shift-click currently. - static async _onRollLuck(event) { - // probably don't want rolls to trigger from a middle mouse click so just kill it here - if (event.which === 2) return; - - const rollOptions = { - rollType: "luck", - key: "luck", - actor: this.actor, - }; - - // Create a default 1d100 roll just in case. - const roll = new DGPercentileRoll("1D100", {}, rollOptions); - // Open dialog if user requests it. - if (event.shiftKey || event.which === 3) { - const dialogData = await roll.showDialog(); - if (!dialogData) return; - roll.modifier += dialogData.targetModifier; - roll.options.rollMode = dialogData.rollMode; - } - // Evaluate the roll. - await roll.evaluate(); - // Send the roll to chat. - roll.toChat(); - } - - /** - * Show a dialog for the roll and then send to chat. - * Broke this logic out from `_onRoll()` so that other files can call it, - * namely the macro logic. - * - * TODO: Move this logic to the roll.js. - * - * @param {Event} event The originating click event - * @param {Event} roll The roll to show a dialog for and then send to chat. - * @async - */ - async processRoll(event, roll) { - // 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(); - } - - /** Handler for generic toggle events */ - static _toggleGeneric(event, target) { - const { prop } = target.dataset; - const itemId = target.closest("[data-item-id]")?.dataset.itemId; - this.toggle(prop, itemId); - } - - /** Handler for item-sort-mode toggle events */ - static _toggleItemSortMode(event, target) { - const itemType = target.dataset.gearType; - const propString = `${itemType}SortAlphabetical`; - const targetProp = `system.settings.sorting.${propString}`; - this.toggle(targetProp); - } - - static _browsePack(event, target) { - const { packType } = target.dataset; - switch (packType) { - case "weapon": { - new DialogV2({ - window: { title: "Select Compendium" }, - buttons: [ - { - action: "firearms", - label: game.i18n.localize("DG.Gear.WeaponTypes.Firearms"), - icon: '', - callback: () => - game.packs - .find((k) => k.collection === "deltagreen.firearms") - .render(true), - }, - { - action: "melee", - label: game.i18n.localize("DG.Gear.WeaponTypes.Melee"), - icon: '', - callback: () => - game.packs - .find( - (k) => k.collection === "deltagreen.hand-to-hand-weapons", - ) - .render(true), - }, - ], - }).render(true); - break; - } - default: - game.packs - .find((k) => k.collection === `deltagreen.${packType}`) - .render(true); - break; - } - } - - /** - * Toggle a boolean property on an item or actor. - * - * @param {string} prop The property to toggle. - * @param {string} [itemId] The ID of item on which to apply toggle, if it exists. - */ - toggle(prop, itemId) { - const item = this.actor.items.get(itemId); - const targetDoc = item ?? this.actor; - - const currentVal = foundry.utils.getProperty(targetDoc, prop); - targetDoc.update({ [prop]: !currentVal }); - } - - /** - * Require Shift while hovering to show tooltips. - * Supports either data-tooltip (preferred, may contain HTML) or title (plain text). - */ - _tooltipsSettings(root) { - const mode = game.settings.get("deltagreen", "skillTooltipDisplay"); - - // If not explicitly hoverShift or never, do nothing. - if (mode !== "hoverShift" && mode !== "never") return; - - // Query elements with either data-tooltip OR title - const nodes = root.querySelectorAll("[data-tooltip],[title]"); - - // If mode is "never": strip native attributes so no tooltips (native or custom) can appear. - if (mode === "never") { - nodes.forEach((el) => { - if (el.dataset.shiftTooltipInstalled === "true") return; - el.removeAttribute("data-tooltip"); - el.removeAttribute("title"); - el.dataset.shiftTooltipInstalled = "true"; - }); - return; - } - - // mode === "hoverShift": install Shift-to-show behavior using Foundry's tooltip - nodes.forEach((el) => { - if (el.dataset.shiftTooltipInstalled === "true") return; - - // Prefer data-tooltip, else use title - let html = el.getAttribute("data-tooltip"); - let isHtml = true; - - if (!html) { - const title = el.getAttribute("title"); - if (title) { - html = foundry.utils.escapeHTML(title); // treat title as plain text - isHtml = false; - } - } - - if (!html) return; // nothing usable - - // Remove native attributes so default tooltips don’t trigger - el.removeAttribute("data-tooltip"); - el.removeAttribute("title"); - el.dataset.shiftTooltipInstalled = "true"; - - // Always pass through { html } so
etc. render - const opts = isHtml ? { html } : { text: html }; - - const show = () => game.tooltip.activate(el, opts); - const hide = () => game.tooltip.deactivate(); - - const onKey = (ev) => { - if (ev.key !== "Shift") return; - if (!document.body.contains(el)) { - window.removeEventListener("keydown", onKey); - window.removeEventListener("keyup", onKey); - return; - } - if (ev.type === "keydown") show(); - else hide(); - }; - - const onEnter = (ev) => { - if (ev.shiftKey) show(); - window.addEventListener("keydown", onKey); - window.addEventListener("keyup", onKey); - }; - - const onLeave = () => { - hide(); - window.removeEventListener("keydown", onKey); - window.removeEventListener("keyup", onKey); - }; - - el.addEventListener("pointerenter", onEnter, { passive: true }); - el.addEventListener("pointerleave", onLeave, { passive: true }); - }); + this._setupItemDragListeners(); + this._tooltipsSettings(element); + this._bindAeBackedInputs(element); } } diff --git a/module/sheets/base-item-sheet.js b/module/sheets/base-item-sheet.js new file mode 100644 index 0000000..3aafe2f --- /dev/null +++ b/module/sheets/base-item-sheet.js @@ -0,0 +1,311 @@ +import { BASE_TEMPLATE_PATH } from "../config/index.js"; +import { + getDisorderLabel, + isKnownDisorderId, +} from "../profession/disorders.js"; +import DGSheetMixin from "./base-sheet.js"; +import EffectsTabMixin from "./mixins/effects-tab-mixin.js"; +import ProfessionItemMixin from "./mixins/profession-item-mixin.js"; +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; + + if (this.item.type === "motivation" && this.item.system.disorder) { + context.disorderUsesKnownId = isKnownDisorderId( + this.item.system.disorder, + ); + context.disorderLabel = getDisorderLabel(this.item.system.disorder); + } + + return context; + } + + /** @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 }); + } +} diff --git a/module/sheets/base-sheet.js b/module/sheets/base-sheet.js index f4de812..5b773fa 100644 --- a/module/sheets/base-sheet.js +++ b/module/sheets/base-sheet.js @@ -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) { diff --git a/module/sheets/mixins/ae-input-mixin.js b/module/sheets/mixins/ae-input-mixin.js new file mode 100644 index 0000000..2a06e25 --- /dev/null +++ b/module/sheets/mixins/ae-input-mixin.js @@ -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} */ +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); + } + } + } + }; +} diff --git a/module/sheets/mixins/compose-mixins.js b/module/sheets/mixins/compose-mixins.js new file mode 100644 index 0000000..5c8a686 --- /dev/null +++ b/module/sheets/mixins/compose-mixins.js @@ -0,0 +1,7 @@ +/** + * @param {...Function} mixins + * @returns {Function} + */ +export default function composeMixins(...mixins) { + return (Base) => mixins.reduceRight((acc, mixin) => mixin(acc), Base); +} diff --git a/module/sheets/mixins/effects-tab-mixin.js b/module/sheets/mixins/effects-tab-mixin.js new file mode 100644 index 0000000..4aece5a --- /dev/null +++ b/module/sheets/mixins/effects-tab-mixin.js @@ -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 }); + } + }; +} diff --git a/module/sheets/mixins/item-list-mixin.js b/module/sheets/mixins/item-list-mixin.js new file mode 100644 index 0000000..88b9e5a --- /dev/null +++ b/module/sheets/mixins/item-list-mixin.js @@ -0,0 +1,242 @@ +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("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: '', + callback: () => + game.packs + .find((k) => k.collection === "deltagreen.firearms") + .render(true), + }, + { + action: "melee", + label: game.i18n.localize("DG.Gear.WeaponTypes.Melee"), + icon: '', + callback: () => + game.packs + .find((k) => k.collection === "deltagreen.hand-to-hand-weapons") + .render(true), + }, + ], + }); + } + + /** + * @param {string} prop + * @param {string} [itemId] + */ + toggle(prop, itemId) { + const item = this.actor.items.get(itemId); + const targetDoc = item ?? this.actor; + + const currentVal = foundry.utils.getProperty(targetDoc, prop); + targetDoc.update({ [prop]: !currentVal }); + } + }; +} diff --git a/module/sheets/mixins/profession-item-mixin.js b/module/sheets/mixins/profession-item-mixin.js new file mode 100644 index 0000000..e6fcb18 --- /dev/null +++ b/module/sheets/mixins/profession-item-mixin.js @@ -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); + } + } + }; +} diff --git a/module/sheets/mixins/roll-sheet-mixin.js b/module/sheets/mixins/roll-sheet-mixin.js new file mode 100644 index 0000000..cb91e39 --- /dev/null +++ b/module/sheets/mixins/roll-sheet-mixin.js @@ -0,0 +1,94 @@ +import { getDGRollToken } from "../../chat/dg-chat-card.js"; +import { showDamageOrLethalityChoiceDialog } from "../../roll/roll-dialogs.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 (event.which === 2) return; + + if (target.classList.contains("not-rollable")) { + if (target.dataset.rolltype === "stat") { + ui.notifications.warn("DG.Roll.CannotRollStat", { localize: true }); + } + return; + } + + const item = this.actor.items.get(target.dataset.iid); + const rollDataset = { ...target.dataset }; + + if (rollDataset.rolltype === "damage-or-lethality") { + const choice = await showDamageOrLethalityChoiceDialog({ + itemName: item?.name ?? "", + }); + if (!choice) return; + rollDataset.rolltype = choice; + } + + const roll = createDGRollFromDataset(rollDataset, { + 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); + } + }; +} diff --git a/module/sheets/mixins/skill-prep-mixin.js b/module/sheets/mixins/skill-prep-mixin.js new file mode 100644 index 0000000..4aa0713 --- /dev/null +++ b/module/sheets/mixins/skill-prep-mixin.js @@ -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); + } + }; +} diff --git a/module/sheets/mixins/special-training-mixin.js b/module/sheets/mixins/special-training-mixin.js new file mode 100644 index 0000000..3a95062 --- /dev/null +++ b/module/sheets/mixins/special-training-mixin.js @@ -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 }); + } + }; +} diff --git a/module/sheets/mixins/typed-skill-mixin.js b/module/sheets/mixins/typed-skill-mixin.js new file mode 100644 index 0000000..aecf3b3 --- /dev/null +++ b/module/sheets/mixins/typed-skill-mixin.js @@ -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 }); + } + } + }; +} diff --git a/module/sheets/unnatural-sheet.js b/module/sheets/unnatural-sheet.js index 7ad7ce1..307298f 100644 --- a/module/sheets/unnatural-sheet.js +++ b/module/sheets/unnatural-sheet.js @@ -1,3 +1,4 @@ +import flagUnnaturalResourceMaxUpdates from "../utils/actor-submit.js"; import DGNPCSheet from "./npc-sheet.js"; /** @extends {DGNPCSheet} */ @@ -5,19 +6,7 @@ export default class DGUnnaturalSheet extends DGNPCSheet { /** @override */ async _processSubmitData(event, form, submitData, options) { const submittedData = foundry.utils.expandObject(submitData); - - // we need to update max WP if POW has changed (only for unnatural, but doesn't hurt others) - submittedData.system.wp.maxNeedsUpdate = - submittedData.system?.statistics.pow.value !== - this.actor.system.statistics.pow.value; - - // we need to update max HP if STR or CON have changed (only for unnatural, but doesn't hurt others) - submittedData.system.health.maxNeedsUpdate = - submittedData.system?.statistics.str.value !== - this.actor.system.statistics.str.value || - submittedData.system?.statistics.con.value !== - this.actor.system.statistics.con.value; - + flagUnnaturalResourceMaxUpdates(submittedData, this.actor); return super._processSubmitData(event, form, submittedData, options); } } diff --git a/module/sheets/vehicle-sheet.js b/module/sheets/vehicle-sheet.js index 89e1b4b..d1d0d62 100644 --- a/module/sheets/vehicle-sheet.js +++ b/module/sheets/vehicle-sheet.js @@ -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: "" }, ], }, diff --git a/module/templates.js b/module/templates.js index dbc6613..d25ea0c 100644 --- a/module/templates.js +++ b/module/templates.js @@ -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", ]); } diff --git a/module/utils/actor-submit.js b/module/utils/actor-submit.js new file mode 100644 index 0000000..71d9da9 --- /dev/null +++ b/module/utils/actor-submit.js @@ -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; +} diff --git a/module/utils/register-helpers.js b/module/utils/register-helpers.js new file mode 100644 index 0000000..6a44602 --- /dev/null +++ b/module/utils/register-helpers.js @@ -0,0 +1,145 @@ +import { getDisorderLabel } from "../profession/disorders.js"; +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("dgDisorderLabel", (value) => { + try { + return getDisorderLabel(value); + } catch { + return value ?? ""; + } + }); + + Handlebars.registerHelper("showImpossibleLandscapesContent", () => { + let result = false; + const setting = game.settings.get( + "deltagreen", + "showImpossibleLandscapesContent", + ); + + if (game.user.isGM === true && setting === true) { + result = true; + } + + return result; + }); + + Handlebars.registerHelper("hasWeaponDamage", (damage) => { + const trimmed = String(damage ?? "").trim(); + return trimmed !== "" && trimmed !== "0"; + }); + + Handlebars.registerHelper("hasWeaponLethality", (lethality) => { + const value = Number(lethality); + return Number.isFinite(value) && value > 0; + }); + + Handlebars.registerHelper("hasWeaponDamageAndLethality", (item) => { + const damage = item?.system?.damage; + const lethality = item?.system?.lethality; + const trimmed = String(damage ?? "").trim(); + const hasDamage = trimmed !== "" && trimmed !== "0"; + const hasLethality = + Number.isFinite(Number(lethality)) && Number(lethality) > 0; + return hasDamage && hasLethality; + }); +} diff --git a/module/utils/rich-text.js b/module/utils/rich-text.js new file mode 100644 index 0000000..a9343de --- /dev/null +++ b/module/utils/rich-text.js @@ -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} + */ +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>} + */ +export async function prepareRichTextContext(document, fieldSpecs) { + const richText = {}; + for (const { path, key } of fieldSpecs) { + richText[key] = await prepareProseMirrorInput(document, path); + } + return richText; +} diff --git a/module/utils/roll-target-tooltip.js b/module/utils/roll-target-tooltip.js new file mode 100644 index 0000000..e219c1f --- /dev/null +++ b/module/utils/roll-target-tooltip.js @@ -0,0 +1,383 @@ +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 {Actor|null} actor + * @param {RollTargetFieldKey|null} rollTargetFieldKey + * @param {number} [manualModifier] + * @returns {Array<{ name: string, modifier: number }>} + */ +function collectChatRollTargetContributions( + actor, + rollTargetFieldKey, + manualModifier = 0, +) { + const contributions = + actor && rollTargetFieldKey + ? collectRollTargetContributions(actor, rollTargetFieldKey) + : []; + + const numericManual = Number(manualModifier) || 0; + if (numericManual) { + contributions.push({ + name: game.i18n.localize("DG.Tooltip.RollTarget.RollModifier"), + modifier: numericManual, + }); + } + + return contributions; +} + +/** + * @param {Array<{ name: string, modifier: number }>} contributions + * @returns {string} + */ +function buildRollTargetModifierIndicators(contributions) { + const hasPositive = contributions.some((entry) => entry.modifier > 0); + const hasNegative = contributions.some((entry) => entry.modifier < 0); + const indicators = []; + + if (hasPositive) { + indicators.push( + ``, + ); + } + if (hasNegative) { + indicators.push( + ``, + ); + } + + return indicators.join(""); +} + +/** + * @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, finalTarget = null } = {}, +) { + 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 resolvedFinal = + finalTarget != null + ? finalTarget + : clampPercentileRollTarget(base, totalModifier, { + allowOver99, + }); + + rows.push([ + game.i18n.localize("DG.Tooltip.RollTarget.FinalTarget"), + formatTargetPercent(resolvedFinal), + ]); + + const body = rows + .map( + ([label, value]) => + `${foundry.utils.escapeHTML( + label, + )}${foundry.utils.escapeHTML(value)}`, + ) + .join(""); + + return `${body}
`; +} + +/** + * Roll-target breakdown for chat cards, including Active Effects and manual roll modifiers. + * + * @param {Actor|null} actor + * @param {RollTargetFieldKey|null} rollTargetFieldKey + * @param {number} base + * @param {{ manualModifier?: number, finalTarget?: number|null, allowOver99?: boolean }} [options] + * @returns {string} + */ +export function buildChatRollTargetBreakdownTooltipHtml( + actor, + rollTargetFieldKey, + base, + { manualModifier = 0, finalTarget = null, allowOver99 = false } = {}, +) { + const contributions = collectChatRollTargetContributions( + actor, + rollTargetFieldKey, + manualModifier, + ); + + if (!contributions.length) return ""; + + return buildRollTargetBreakdownTable(base, contributions, { + allowOver99, + finalTarget, + }); +} + +/** + * Roll-target breakdown table for tooltips when active effects modify the target. + * + * @param {Actor} actor + * @param {RollTargetFieldKey} rollTargetFieldKey + * @param {number} base + * @param {{ allowOver99?: boolean }} [options] + * @returns {string} + */ +export function buildRollTargetBreakdownTooltipHtml( + actor, + rollTargetFieldKey, + base, + options = {}, +) { + return buildChatRollTargetBreakdownTooltipHtml( + actor, + rollTargetFieldKey, + base, + options, + ); +} + +/** + * AE-style target display for chat roll labels: effective value, optional arrow, breakdown tooltip. + * + * @param {object} params + * @param {string} params.targetValue Pre-formatted target string (e.g. "65%" or "12") + * @param {Actor|null} [params.actor] + * @param {RollTargetFieldKey|null} [params.rollTargetFieldKey] + * @param {number} [params.base] + * @param {number} [params.manualModifier] + * @param {number|null} [params.finalTarget] + * @param {boolean} [params.allowOver99] + * @returns {string} + */ +export function buildRollTargetDisplayHtml({ + targetValue, + actor = null, + rollTargetFieldKey = null, + base = 0, + manualModifier = 0, + finalTarget = null, + allowOver99 = false, +} = {}) { + const contributions = collectChatRollTargetContributions( + actor, + rollTargetFieldKey, + manualModifier, + ); + + if (!contributions.length) return targetValue; + + const breakdownTooltip = buildRollTargetBreakdownTable(base, contributions, { + allowOver99, + finalTarget, + }); + + const aeIndicator = buildRollTargetModifierIndicators(contributions); + + return `${targetValue}${aeIndicator}`; +} + +/** + * @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}

${appendix}`; +} + +/** + * @param {string} existingTooltip + * @param {string} [feature] + * @returns {string} + */ +export function appendDistinguishingFeatureSection(existingTooltip, feature) { + const trimmed = String(feature ?? "").trim(); + if (!trimmed) return existingTooltip; + + const label = game.i18n.localize("DG.EditStats.DistinguishingFeature"); + const escaped = foundry.utils.escapeHTML(trimmed); + const section = `

${foundry.utils.escapeHTML(label)}: ${escaped}`; + if (!existingTooltip) return section; + return `${existingTooltip}${section}`; +} + +/** + * 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}`); + const withFeature = appendDistinguishingFeatureSection( + existing, + stat.distinguishing_feature, + ); + stat.tooltip = appendRollTargetTooltipSection( + withFeature, + 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, + ); + } +} diff --git a/module/utils/skill-layout.js b/module/utils/skill-layout.js new file mode 100644 index 0000000..4af66d2 --- /dev/null +++ b/module/utils/skill-layout.js @@ -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; +} diff --git a/module/utils/skill-tooltip.js b/module/utils/skill-tooltip.js new file mode 100644 index 0000000..6531b5d --- /dev/null +++ b/module/utils/skill-tooltip.js @@ -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( + "

", + 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 }); + }); +} diff --git a/module/other/utility-functions.js b/module/utils/utility-functions.js similarity index 100% rename from module/other/utility-functions.js rename to module/utils/utility-functions.js diff --git a/module/utils/world-migration.js b/module/utils/world-migration.js new file mode 100644 index 0000000..4b77c00 --- /dev/null +++ b/module/utils/world-migration.js @@ -0,0 +1,64 @@ +import DG from "../config/index.js"; + +const { ForcedDeletion } = foundry.data.operators; + +const MIGRATION_VERSION = 2; +const ACTOR_TYPES_WITH_SKILLS = ["agent", "npc", "unnatural"]; +const OBSOLETE_WORLD_SETTINGS = [ + "characterSheetFont", + "characterSheetBackgroundImageSetting", +]; + +/** + * @returns {void} + */ +function removeObsoleteWorldSettings() { + const worldConfig = game.settings.storage.get("world")?.document?.config; + const dgConfig = worldConfig?.[DG.ID]; + if (!dgConfig) return; + + for (const key of OBSOLETE_WORLD_SETTINGS) { + delete dgConfig[key]; + } +} + +/** + * Run one-time world migrations for the Delta Green system. + * + * @returns {Promise} + */ +export default async function runWorldMigration() { + if (!game.user.isGM) return; + + const currentVersion = + game.settings.get(DG.ID, "schemaMigrationVersion") ?? 0; + if (currentVersion >= MIGRATION_VERSION) return; + + let migratedActors = 0; + + const actors = game.actors.filter((actor) => + ACTOR_TYPES_WITH_SKILLS.includes(actor.type), + ); + + for (const actor of actors) { + if (actor.system.skills?.ritual) { + await actor.update({ + system: { + skills: { + ritual: new ForcedDeletion(), + }, + schemaVersion: MIGRATION_VERSION, + }, + }); + migratedActors += 1; + } + } + + removeObsoleteWorldSettings(); + + await game.settings.set(DG.ID, "schemaMigrationVersion", MIGRATION_VERSION); + + console.log( + `Delta Green | World migration v${MIGRATION_VERSION} complete. Removed legacy ritual skill from ${migratedActors} actor(s).`, + ); +} diff --git a/package-lock.json b/package-lock.json index cb4ba25..5252134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "eslint-plugin-import": "^2.28.1", "eslint-plugin-prettier": "^5.0.0" }, - "devDependencies": {} + "devDependencies": { + "sass": "^1.89.0" + } }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", @@ -173,6 +175,348 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@pkgr/utils": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", @@ -580,6 +924,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/classic-level": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.4.1.tgz", @@ -743,6 +1103,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1658,6 +2029,13 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -2297,6 +2675,14 @@ "@seald-io/nedb": "^4.0.2" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -2622,6 +3008,20 @@ } ] }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -2846,6 +3246,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sass": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", + "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2904,6 +3325,16 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3428,6 +3859,132 @@ "fastq": "^1.6.0" } }, + "@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "optional": true, + "requires": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6", + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "dependencies": { + "picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "optional": true + } + } + }, + "@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "dev": true, + "optional": true + }, + "@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "dev": true, + "optional": true + }, + "@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "dev": true, + "optional": true + }, + "@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "dev": true, + "optional": true + }, + "@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "dev": true, + "optional": true + }, + "@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "dev": true, + "optional": true + }, + "@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "dev": true, + "optional": true + }, + "@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "dev": true, + "optional": true + }, + "@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "dev": true, + "optional": true + }, + "@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "dev": true, + "optional": true + }, + "@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "dev": true, + "optional": true + }, + "@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "dev": true, + "optional": true + }, + "@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "dev": true, + "optional": true + }, "@pkgr/utils": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.2.tgz", @@ -3692,6 +4249,15 @@ "supports-color": "^7.1.0" } }, + "chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "requires": { + "readdirp": "^5.0.0" + } + }, "classic-level": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-1.4.1.tgz", @@ -3805,6 +4371,13 @@ "object-keys": "^1.1.1" } }, + "detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "optional": true + }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -4439,6 +5012,12 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, + "immutable": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz", + "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ==", + "dev": true + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4851,6 +5430,13 @@ "@seald-io/nedb": "^4.0.2" } }, + "node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "optional": true + }, "node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -5056,6 +5642,12 @@ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, + "readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true + }, "regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -5190,6 +5782,18 @@ "is-regex": "^1.2.1" } }, + "sass": { + "version": "1.100.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz", + "integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==", + "dev": true, + "requires": { + "@parcel/watcher": "^2.4.1", + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + } + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5233,6 +5837,12 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true + }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", diff --git a/package.json b/package.json index 04ba298..5149b49 100644 --- a/package.json +++ b/package.json @@ -2,10 +2,13 @@ "name": "deltagreen", "version": "1.3.1", "description": "This is a fan made but now officially supported rules implementation (system) for [Foundry Virtual Tabletop](https://foundryvtt.com/) system for Delta Green: The RPG, based on the _Agent's Handbook_ rule set from Arc Dream Publishing (_**not** the Call of Cthulu rules_).", - "main": "deltagreen.js", + "main": "module/deltagreen.js", "scripts": { "lint": "eslint .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "format:fix": "prettier --write .", + "build:css": "sass scss:css --style=expanded --no-source-map", + "watch:css": "sass scss:css --watch --style=expanded --source-map" }, "repository": { "type": "git", @@ -25,5 +28,7 @@ "eslint-config-prettier": "^9.0.0", "@foundryvtt/foundryvtt-cli": "^1.1.0" }, - "devDependencies": {} + "devDependencies": { + "sass": "^1.89.0" + } } diff --git a/scss/_editor.scss b/scss/_editor.scss new file mode 100644 index 0000000..914102b --- /dev/null +++ b/scss/_editor.scss @@ -0,0 +1,28 @@ +/* TinyMCE iframe (legacy); theme is not available inside the iframe — keep Special Elite. */ +.tox .tox-editor-container, +.tox .tox-edit-area { + font-family: "SpecialElite", sans-serif !important; +} + +.cowboy-style { + .editor-host .editor .editor-content, + .editor-host prose-mirror .editor-content, + .editor-host prose-mirror .editor-content .ProseMirror, + .editor-host .tox, + .editor-host .tox-tinymce, + .editor-host .tox .tox-edit-area { + font-family: "SpecialElite", sans-serif !important; + } +} + +.outlaw-style, +.program-style { + .editor-host .editor .editor-content, + .editor-host prose-mirror .editor-content, + .editor-host prose-mirror .editor-content .ProseMirror, + .editor-host .tox, + .editor-host .tox-tinymce, + .editor-host .tox .tox-edit-area { + font-family: "PublicSans", sans-serif !important; + } +} diff --git a/scss/_fonts.scss b/scss/_fonts.scss new file mode 100644 index 0000000..f79ddd9 --- /dev/null +++ b/scss/_fonts.scss @@ -0,0 +1,39 @@ +@import url("https://fonts.googleapis.com/css2?family=Martel:wght@400;800&family=Roboto:wght@300;400;500&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=Didact+Gothic"); + +@font-face { + font-family: "atwriter"; + src: url("../assets/font/another-typewriter/atwriter.ttf") format("truetype"); +} + +@font-face { + font-family: TypeWriterCondensed; + src: url("../assets/font/typewriter-condensed/typewcond_regular.otf") + format("opentype"); +} + +@font-face { + font-family: TypeWriterCondensed; + font-weight: bold; + src: url("../assets/font/typewriter-condensed/typewcond_bold.otf") + format("opentype"); +} + +@font-face { + font-family: TypeWriterCondensed; + font-weight: 600; + src: url("../assets/font/typewriter-condensed/typewcond_demi.otf") + format("opentype"); +} + +@font-face { + font-family: "SpecialElite"; + src: url("../assets/font/special_elite/SpecialElite-Regular.ttf") + format("truetype"); +} + +@font-face { + font-family: "PublicSans"; + src: url("../assets/font/public-sans/PublicSans-VariableFont_wght.ttf") + format("truetype"); +} diff --git a/scss/_mixins.scss b/scss/_mixins.scss new file mode 100644 index 0000000..e5c88a9 --- /dev/null +++ b/scss/_mixins.scss @@ -0,0 +1,112 @@ +@mixin dg-window-content { + scrollbar-color: #215112 #111 !important; + padding: 0.5rem; + color: var(--dg-ink); +} + +@mixin dg-window-form-inputs { + input[type="checkbox"]:checked::before, + input[type="radio"]:checked::before { + color: var(--dg-ink); + } + + label.checkbox > input[type="radio"] { + --checkbox-background-color: var(--dg-ink); + } + + input[type="text"], + input[type="number"], + input[type="password"] { + height: unset; + line-height: 1.5rem; + } +} + +@mixin dg-window-form-inputs-with-select { + @include dg-window-form-inputs; + + select { + height: unset; + line-height: 1.5rem; + } +} + +/* Parchment/light sheet bodies: override Foundry v14 .application light text and heading typography. */ +@mixin dg-parchment-content-theme { + :is(h1, h2, h3, h4, h5, h6) { + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + } + + .resource-label, + .standard-form .form-group > label, + .form-group > label { + color: inherit; + } +} + +@mixin dg-parchment-form-fields { + input[type="text"], + input[type="number"], + input[type="password"], + select { + background-color: rgba(0, 0, 0, 0.05); + border: 1px solid rgb(122, 121, 113); + border-radius: 3px; + color: inherit; + } +} + +@mixin item-sheet-grid-2col { + display: grid; + grid-template-columns: 25% 75%; + align-items: center; + justify-content: center; + text-align: center; + grid-gap: 5px; +} + +@mixin item-sheet-horizontal-grid($label-width: 100px) { + display: grid; + grid-template-columns: $label-width auto; + align-items: center; + text-align: left; + column-gap: 10px; + row-gap: 10px; + padding-bottom: 10px; + padding-right: 5px; +} + +@mixin dg-dark-button { + background-color: var(--dg-surface-dark); + color: var(--dg-on-dark); + border: 1px solid var(--dg-surface-dark); + font-weight: bold; + + &:hover:not(:disabled) { + background-color: var(--dg-surface-dark-hover); + color: var(--dg-on-dark); + border-color: var(--dg-surface-dark-hover); + } +} + +/// Nudge SpecialElite text down in compact centered cells without shrinking the box. +@mixin cowboy-compact-input($height: null, $top-padding: 2px) { + @if $height { + box-sizing: border-box; + height: $height; + min-height: $height; + padding: $top-padding 1px 1px; + line-height: calc(#{$height} - #{$top-padding} - 2px); + } @else { + padding-top: $top-padding; + padding-bottom: 0; + } +} + +@mixin cowboy-compact-label { + transform: translateY(1px); +} diff --git a/scss/_utilities.scss b/scss/_utilities.scss new file mode 100644 index 0000000..bede06a --- /dev/null +++ b/scss/_utilities.scss @@ -0,0 +1,218 @@ +/* Character Sheet Style Options */ + +.window-content.outlaw-style, +.window-content.cowboy-style { + background: url("../assets/img/StainedPlainPaper10b.webp") !important; + background-size: 100% !important; +} + +.window-content.program-style { + background: url("../assets/img/StainedPlainPaper10a.webp") !important; + background-size: 100% !important; +} + +.cowboy-style { + font-family: "SpecialElite", sans-serif !important; +} + +.outlaw-style, +.program-style { + font-family: "PublicSans", sans-serif !important; +} + +.outlaw-style .tabs [data-action="tab"], +.cowboy-style .tabs [data-action="tab"] { + background: rgba(173, 151, 101, 0.3); +} + +.program-style .tabs [data-action="tab"] { + background: rgba(138, 136, 133, 0.3); +} + +.old-paper-background { + background: url("../assets/img/StainedPlainPaper10.webp") !important; +} + +.default-parchment-background { + background: url("/ui/parchment.jpg") repeat !important; +} + +.typewriter-condensed-font { + font-family: "TypeWriterCondensed", sans-serif !important; +} + +.martel-font { + font-family: "Martel", sans-serif !important; +} + +.signika-font { + font-family: "Signika", sans-serif !important; +} + +.atwriter-font { + font-family: "atwriter", sans-serif !important; +} + +.special-elite-font { + font-family: "SpecialElite", sans-serif !important; +} + +.public-sans-font { + font-family: "PublicSans", sans-serif !important; +} + +.rollable:hover, +.rollable:focus { + color: var(--dg-ink); + text-shadow: 0 0 5px #374e13; + cursor: pointer; +} + +button.rollable.inline-roll { + display: inline-block; + width: max-content; + max-width: 100%; + margin: 0; + padding: 0; + border: none; + background: transparent; + font: inherit; + color: var(--dg-ink); + line-height: inherit; + vertical-align: baseline; + text-align: inherit; + box-sizing: border-box; + + > i { + color: inherit; + } +} + +.not-rollable { + color: #999999; +} + +input.breaking-point-hit { + color: rgb(255, 255, 255); + background-color: rgb(117, 0, 0); + font-weight: bold; +} + +.dg-red { + color: rgb(117, 0, 0); +} + +.bottom-margin-5px { + margin-bottom: 5px !important; +} + +.grid-1col { + display: grid; + grid-column: span 1 / span 1; + gap: 3px; +} + +.grid, +.grid-2col { + display: grid; + grid-column: span 2 / span 2; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + margin: 0px 0; + padding: 0; +} + +@for $i from 3 through 12 { + .grid-#{$i}col { + grid-column: span #{$i} / span #{$i}; + grid-template-columns: repeat(#{$i}, minmax(0, 1fr)); + } +} + +/* grid-6col historically used 5 columns — preserve behavior */ +.grid-6col { + grid-column: span 5 / span 5; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +/* If the user wishes to sort skills by column, we need to +explicitly define a number of rows, rather than columns. */ +.grid-13row { + grid-template-rows: repeat(13, minmax(0, auto)) !important; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)) !important; + grid-auto-flow: column !important; +} + +.flex-group-center, +.flex-group-left, +.flex-group-right { + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + text-align: center; + padding: 2px; +} + +.flex-thin-border { + border: 1px solid #999; + border-radius: 2px; + border-left-color: darkslategray; + border-left-width: 4px; +} + +/* Make sure Special Training boxes are the same height + as all other skill boxes */ +.flex-thin-border.special-training-box { + min-height: 27px; +} + +.flex-mid-border { + border: 1.5px solid rgb(0, 0, 0); + border-radius: 2px; +} + +.flex-group-left { + -webkit-box-pack: start; + -ms-flex-pack: start; + justify-content: flex-start; + text-align: left; + padding: 0; +} + +.flex-group-right { + -webkit-box-pack: end; + -ms-flex-pack: end; + justify-content: flex-end; + text-align: right; +} + +.flex-center { + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + text-align: center; +} + +.flex-between { + -webkit-box-pack: justify; + -ms-flex-pack: justify; + justify-content: space-between; +} + +.horizontal-flex-column { + display: flex; + flex-direction: column; +} + +.flex-left { + -webkit-box-pack: start; + -ms-flex-pack: start; + justify-content: flex-start; + text-align: left; +} diff --git a/scss/_variables.scss b/scss/_variables.scss new file mode 100644 index 0000000..0056d74 --- /dev/null +++ b/scss/_variables.scss @@ -0,0 +1,7 @@ +/* Color tokens — near-black ink and dark surfaces (avoid pure #000) */ +:root { + --dg-ink: #212121; + --dg-surface-dark: #1a1a1a; + --dg-surface-dark-hover: #2a2a2a; + --dg-on-dark: #f5f5f0; +} diff --git a/css/dialogue.css b/scss/damage-modify.scss similarity index 59% rename from css/dialogue.css rename to scss/damage-modify.scss index f654ff1..094fc96 100644 --- a/css/dialogue.css +++ b/scss/damage-modify.scss @@ -1,27 +1,3 @@ -.skill-modify-grid { - display: grid; - grid-template-columns: auto auto; - align-content: left; - justify-content: left; - align-items: left; - padding: 10px; - grid-gap: 10px 15px; -} - -.plusMinusNumberFieldBox { - display: flex; - gap: 10px; - - select[name="plusOrMinus"] { - flex: 1; - } - - .numeric-label-field { - flex: 1; - text-align: center !important; - } -} - .damage-modify-label-grid { display: grid; grid-template-columns: auto auto; diff --git a/scss/deltagreen.scss b/scss/deltagreen.scss new file mode 100644 index 0000000..c43ec5d --- /dev/null +++ b/scss/deltagreen.scss @@ -0,0 +1,17 @@ +@use "fonts"; +@use "variables"; +@use "utilities"; +@use "editor"; +@use "deltagreen/global"; +@use "deltagreen/character-sheet"; +@use "deltagreen/active-effects"; +@use "deltagreen/character-sheet-cont"; +@use "deltagreen/prose-mirror"; +@use "deltagreen/item-sheets"; +@use "deltagreen/foundry-theme"; +@use "deltagreen/cowboy-typography"; +@use "deltagreen/chat-cards"; +@use "deltagreen/agent-sheet"; +@use "deltagreen/stats-edit"; +@use "deltagreen/effects-tab"; +@use "deltagreen/profession"; diff --git a/scss/deltagreen/_active-effects.scss b/scss/deltagreen/_active-effects.scss new file mode 100644 index 0000000..78ed368 --- /dev/null +++ b/scss/deltagreen/_active-effects.scss @@ -0,0 +1,76 @@ +/* AE-backed inputs: overlay shows effective value; focus reveals base value for editing */ +.deltagreen .ae-input-host { + position: relative; + display: inline-flex; + flex: 0 0 40px; + margin: 0 5px; + height: auto; + align-self: center; + min-width: 0; + box-sizing: border-box; +} + +.deltagreen .ae-input-host input { + position: relative; + z-index: 0; + width: 100%; + margin: 0; +} + +/* Typography + inset come from the input (mirrored in JS); only layer + AE colors here */ +.deltagreen .ae-input-host .ae-input-display { + position: absolute; + z-index: 1; + display: block; + overflow: hidden; + box-sizing: border-box; + pointer-events: none; + border: none; + background: transparent; +} + +.deltagreen .stats .physical-stats-outer-grid-3col .ae-input-host--stat { + display: block; + width: 100%; + min-width: 0; + max-width: 100%; + flex: none; + margin: 1px; + height: auto; + align-self: auto; + box-sizing: border-box; +} + +.deltagreen .stats .ae-input-host--stat input { + width: 100%; + margin: 0; +} + +.deltagreen .left-bar .stats .agent-left-bar-stat-grid { + grid-template-columns: auto 1fr; +} + +/* Hide input text only when an AE overlay is showing the effective value */ +.deltagreen .ae-input-host:has(.ae-input-display):not(:focus-within) input { + color: transparent; + -webkit-text-fill-color: transparent; + caret-color: transparent; +} + +.deltagreen .ae-input-host:has(.ae-input-display):focus-within input { + color: var(--input-text-color); + -webkit-text-fill-color: var(--input-text-color); + caret-color: auto; +} + +.deltagreen .ae-input-host:focus-within .ae-input-display { + display: none; +} + +.deltagreen .ae-input-host .ae-input-display.ae-mod-increase { + color: #00a82d; +} + +.deltagreen .ae-input-host .ae-input-display.ae-mod-decrease { + color: #ff8a8a; +} diff --git a/scss/deltagreen/_agent-sheet.scss b/scss/deltagreen/_agent-sheet.scss new file mode 100644 index 0000000..ba2a69f --- /dev/null +++ b/scss/deltagreen/_agent-sheet.scss @@ -0,0 +1,1037 @@ +.agent-sheet { + --dg-agent-section-gap: 8px; + --dg-agent-list-row-border: 1px solid rgba(33, 33, 33, 0.25); + --weapons-col-damage-trail: 60px; + + .window-content { + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: auto 1fr; + column-gap: 8px; + width: 100%; + height: 100%; + } + + .left-bar { + grid-column: 1; + grid-row: 1 / -1; + padding: 3px; + } + + /* DG global form inputs use height: unset; restore Foundry sizing in the header. */ + .left-bar .header-name-fields > input { + height: var(--input-height); + line-height: var(--input-height); + } + + .left-bar .header-name-fields > input + input { + margin-top: 2px; + } + + .left-bar .header-name-fields > input.profession-subname-display[readonly] { + cursor: pointer; + --input-background-color: rgba(0, 0, 0, 0.1); + --input-border-color: var(--color-dark-6); + --input-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1); + --input-text-color: inherit; + + &:hover { + --input-background-color: rgba(0, 0, 0, 0.12); + } + } + + .stats-wrapper { + margin-top: 8px; + margin-bottom: 8px; + } + + .mb-8 { + margin-bottom: 8px; + } + + .luck { + display: flex; + justify-content: center; + } + + .luck .bolded-label { + text-align: center; + } + + /* Override core nav.tabs gap/space-evenly and v1-compat space-around so tabs can overlap */ + .window-content > nav.sheet-tabs { + grid-column: 2; + grid-row: 1; + align-self: start; + width: 100%; + min-width: 0; + box-sizing: border-box; + margin-bottom: 0; + position: relative; + z-index: 2; + display: flex; + flex-wrap: nowrap; + gap: 0; + justify-content: flex-start; + overflow-x: auto; + overflow-y: hidden; + } + + .tabs [data-action="tab"] { + flex: 1 1 0; + min-width: 0; + position: relative; + text-align: center; + margin-left: -5px; + padding-inline: 3px; + font-size: 11px; + overflow: visible; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + -1px 0 2px rgba(0, 0, 0, 0.1), + 1px 0 2px rgba(0, 0, 0, 0.1); + + > span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:first-child { + margin-left: 0; + } + + &:nth-child(1) { + z-index: 1; + } + &:nth-child(2) { + z-index: 2; + } + &:nth-child(3) { + z-index: 3; + } + &:nth-child(4) { + z-index: 4; + } + &:nth-child(5) { + z-index: 5; + } + &:nth-child(6) { + z-index: 6; + } + &:nth-child(7) { + z-index: 7; + } + } + + /* Opaque tab fills so stacked borders only show on the tab in front (all themes) */ + .window-content.program-style { + --dg-agent-tab-inactive-bg: rgb(186, 184, 181); + --dg-agent-tab-active-bg: var(--dg-surface-dark); + --dg-agent-tab-active-color: var(--dg-on-dark); + } + + .window-content.cowboy-style, + .window-content.outlaw-style { + --dg-agent-tab-inactive-bg: rgb(210, 190, 150); + --dg-agent-tab-active-bg: rgb(194, 168, 110); + --dg-agent-tab-active-color: var(--dg-ink); + } + + .window-content .tabs [data-action="tab"]:not(.active) { + background: var(--dg-agent-tab-inactive-bg); + } + + .window-content .tabs [data-action="tab"].active { + background: var(--dg-agent-tab-active-bg); + color: var(--dg-agent-tab-active-color); + } + + /* Pin the about tab to the right without opening a gap before it */ + .tabs [data-action="tab"][data-tab="effects"] { + margin-right: auto; + } + + .tabs [data-action="tab"][data-tab="about"] { + flex: 0 0 auto; + min-width: unset; + margin-left: -5px; + } + + .window-content > [data-application-part="rightBar"], + .window-content > .right-bar { + grid-column: 2; + grid-row: 2; + width: 100%; + min-height: 0; + box-sizing: border-box; + padding-right: 0; + margin-right: 0; + } + + .right-bar { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; + } + + .right-bar > .tab { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + box-sizing: border-box; + } + + /* Match V1: flat top edge on panel; active tab overlaps the panel top border */ + .right-bar > .tab.active { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .right-bar > .tab:not(.active) { + display: none; + } + + .right-bar > .tab.skills.active { + display: flex; + flex-direction: column; + overflow: hidden; + + .skills-tab-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + } + + .skills-tab-footer { + flex: 0 0 auto; + margin-top: auto; + padding-top: 8px; + } + + .skills-tab-footer .skill-util-buttons { + margin-bottom: 0; + } + } + + .tabs [data-action="tab"].active { + z-index: 10; + margin-bottom: -2px; + padding-bottom: 2px; + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.22), + -2px 0 5px rgba(0, 0, 0, 0.14), + 2px 0 5px rgba(0, 0, 0, 0.14); + } + + .profile-img { + width: 100%; + height: 194px; + object-fit: contain; + object-position: center top; + } + + .physical-stats-outer-grid-3col { + display: contents; + } + + .physical-stats-outer-grid-3col, + .resources { + font-size: 12px; + } + + .resources { + margin-bottom: 8px; + } + + .resources .resource-inner-grid-3col { + grid-template-columns: auto auto auto; + justify-content: center; + column-gap: 2px; + } + + .resources .resource-inner-grid-3col input { + box-sizing: border-box; + width: 1.75rem; + height: 1.75rem; + padding: 1px; + line-height: calc(1.75rem - 4px); + } + + .resources .resource-inner-grid-3col .max-resource-value { + box-sizing: border-box; + width: 1.7rem; + height: 1.75rem; + min-width: 1.75rem; + min-height: 1.75rem; + } + + .breaking-point-inline { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + column-gap: 6px; + border: none; + padding: 0; + background: none; + + .breaking-point-label { + margin: 0; + white-space: nowrap; + justify-self: start; + } + + .breaking-point-input { + width: 2.5rem; + min-width: 2.5rem; + max-width: 2.5rem; + padding: 1px; + text-align: center; + font-size: 12px; + justify-self: end; + } + + .fa-refresh { + text-decoration: none; + font-size: 12px; + justify-self: end; + } + } + + .exhausted-section.resource { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + column-gap: 8px; + row-gap: 4px; + border: none; + margin-bottom: 8px; + padding: 0; + background: none; + + .exhaustion-label { + justify-self: start; + } + + .labeled-resource-checkbox, + input[type="number"] { + justify-self: end; + } + } + + .stats { + input { + margin: 1px; + } + + .ae-input-host--stat input { + margin: 0; + } + } + + .skill-util-buttons { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin: 5px 0; + box-sizing: border-box; + + .skill-util-buttons-row { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + gap: 8px; + width: 100%; + + .action-pill { + flex: 1 1 0; + min-width: 0; + margin: 0; + text-align: center; + } + + .typed-skill-add { + margin-right: 0; + } + } + + .skill-util-buttons-row--full .action-pill { + flex: 1 1 100%; + width: 100%; + } + + a[data-action="applySkillImprovements"] { + float: none; + } + } + + .custom-skill-group-label { + display: flex; + align-items: center; + justify-content: center; + min-height: 1.35rem; + height: auto; + margin-top: 4px; + margin-bottom: 4px; + padding: 2px 4px; + font-size: 0.7rem; + font-weight: bold; + letter-spacing: 0.02em; + text-transform: uppercase; + line-height: 1.2; + } + + &.custom-skills-edit-mode .custom-skills-edit-toggle { + background-color: rgba(0, 0, 0, 0.45); + outline: 1px solid var(--dg-ink); + } + + &.custom-skills-edit-mode .program-style .custom-skills-edit-toggle { + background-color: var(--dg-surface-dark); + color: var(--dg-on-dark); + } + + .agent-skills-columns { + align-items: start; + --agent-skill-row-gap: 3px; + } + + .agent-skills-column { + display: flex; + flex-direction: column; + gap: var(--agent-skill-row-gap); + min-width: 0; + + /* Prevent flex column from shrinking skill rows below min-height */ + > .skill-row, + > .item.skill-row { + flex: 0 0 auto; + flex-shrink: 0; + align-self: stretch; + width: 100%; + } + } + + .tab.skills .skill-row, + .tab.skills .skill-row > .flexrow { + flex-wrap: nowrap; + align-items: center; + width: 100%; + min-width: 0; + box-sizing: border-box; + } + + /* Fixed skills: one flexrow row — stop Foundry .flexrow > * { flex: 1 } from widening inputs */ + .tab.skills .flexrow.skill-row > .skill-label { + flex: 1 1 0; + min-width: 0; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.2; + } + + .tab.skills .flexrow.skill-row > .percentile-skill-input { + flex: 0 0 40px; + width: 40px; + max-width: 40px; + } + + .tab.skills .flexrow.skill-row > .checkbox-skill-input { + flex: 0 0 20px; + width: 20px; + max-width: 20px; + } + + /* Typed skills: label grows, controls stay fixed width */ + .tab.skills .typed-skill-row > .flexrow > .skill-label { + flex: 1 1 0; + min-width: 0; + white-space: normal; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.2; + } + + .tab.skills .typed-skill-row > .flexrow > .custom-skill-controls-slot { + flex: 0 0 70px; + width: 70px; + max-width: 70px; + } + + .custom-skill-controls-slot { + position: relative; + display: flex; + flex: 0 0 70px; + min-width: 70px; + min-height: 1.2rem; + align-items: center; + justify-content: flex-end; + } + + .custom-skill-play-controls { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: flex-end; + min-height: 1.2rem; + } + + .custom-skill-controls-slot .custom-skill-edit-controls { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + margin: 0; + } + + .special-training-box .custom-skill-edit-controls { + position: static; + flex: 0 0 auto; + align-items: center; + gap: 2px; + margin: 0; + } + + &:not(.custom-skills-edit-mode) .custom-skill-edit-controls { + display: none; + } + + &.custom-skills-edit-mode .custom-skill-play-controls { + visibility: hidden; + pointer-events: none; + } + + .special-training-columns { + margin-bottom: 4px; + } + + .skill-and-training-controls { + display: flex; + flex-direction: row; + flex: 0 0 auto; + gap: 2px; + } + + .scenario-information-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 12px; + row-gap: 8px; + align-items: start; + width: 100%; + box-sizing: border-box; + padding-bottom: 10px; + } + + .scenario-information-left, + .scenario-information-right { + min-width: 0; + } + + .scenario-information-left.bio-grid-2col { + grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr); + padding-bottom: 0; + } + + .scenario-information-corruption { + display: block; + flex: none; + max-width: 100%; + box-sizing: border-box; + padding-bottom: 10px; + } + + .scenario-information-yellow-sign-row { + display: grid; + grid-template-columns: max-content max-content max-content; + column-gap: 8px; + align-items: center; + flex: none; + width: fit-content; + max-width: 100%; + } + + .scenario-information-corruption-row { + display: grid; + grid-template-columns: max-content max-content; + column-gap: 8px; + align-items: center; + flex: none; + width: fit-content; + max-width: 100%; + margin-top: 8px; + } + + .scenario-information-yellow-sign-row > *, + .scenario-information-corruption-row > * { + flex: none; + } + + .scenario-information-corruption-label { + display: inline-flex; + align-items: baseline; + gap: 0; + flex: none; + white-space: nowrap; + } + + .scenario-information-corruption-label > .yellow-king { + flex: none; + display: inline-block; + } + + .scenario-information-corruption-row input[type="number"] { + width: 2.5rem; + min-width: 2.5rem; + } + + .adaptation-grid-4col { + display: grid; + grid-template-columns: 1fr 25px 25px 25px; + align-content: center; + align-items: center; + row-gap: 0; + margin-bottom: 0; + + > label { + justify-self: start; + } + + > input.labeled-resource-checkbox { + justify-self: end; + } + } + + .adaptation-grid-4col .adapted-label-slot { + grid-column: 1 / -1; + min-height: 1.35em; + } + + .adaptation-grid-4col .adapted-label-slot .adapted-label { + display: block; + margin: 0; + } + + .adaptation-block { + border: 1px solid var(--dg-ink); + padding: 3px; + } + + .left-bar-tabs { + font-size: 11px; + margin-bottom: 4px; + } + + .left-bar-section-divider { + margin: 8px 0; + border: 0; + border-top: 1px solid rgba(0, 0, 0, 0.15); + height: 0; + } + + .left-bar-section-heading { + margin: 0 0 6px; + padding: 0; + border: 0; + background: none; + font-family: inherit; + font-size: 14px; + font-weight: bold; + font-style: normal; + line-height: 1.2; + text-align: left; + color: inherit; + } + + .left-bar-tab-panels .tab:not(.active) { + display: none; + } + + .left-bar-rest-sanity-panels { + display: grid; + + > .tab { + grid-area: 1 / 1; + min-width: 0; + box-sizing: border-box; + } + + > .tab:not(.active) { + display: block; + visibility: hidden; + pointer-events: none; + } + + > .tab.active { + visibility: visible; + pointer-events: auto; + z-index: 1; + } + } + + .left-bar-action-stack { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + } + + .left-bar-action-pill { + display: block; + width: 100%; + box-sizing: border-box; + margin: 0 0 8px; + text-align: center; + padding-block: 4px; /* +8px height vs default 2px .action-pill padding */ + } + + .left-bar-action-stack .left-bar-action-pill { + margin-bottom: 0; + } + + .left-bar-action-stack .left-bar-action-pill[data-action="takeStimulants"] { + margin-bottom: 5px; + } + + .exhausted-section.resource input.is-inactive { + color: #888; + background-color: rgba(0, 0, 0, 0.04); + border-color: #bbb; + } + + .suppress-exhaustion.resource { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + column-gap: 8px; + margin-bottom: 8px; + + label.is-crossed-out { + color: #888; + text-decoration: line-through; + } + + input:disabled { + opacity: 0.45; + } + } + + /* Shared tab helpers */ + .agent-tab-hint { + margin: 6px 0 0; + font-size: 12px; + font-style: italic; + color: rgba(0, 0, 0, 0.55); + } + + .is-crossed-out { + color: #888; + text-decoration: line-through; + } + + .items-list .item { + border-bottom: var(--dg-agent-list-row-border); + } + + .effects-list .effect { + border-bottom: var(--dg-agent-list-row-border); + } + + /* Combat tab */ + .tab.combat > div + div { + margin-top: var(--dg-agent-section-gap); + } + + .tab.combat .first-aid-inline { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + column-gap: 8px; + margin-bottom: var(--dg-agent-section-gap); + border: none; + padding: 0; + background: none; + + .first-aid-label { + margin: 0; + justify-self: start; + } + + .labeled-resource-checkbox { + justify-self: end; + } + } + + .tab.combat .wounds-block { + .bolded-label { + display: block; + margin: 0 0 4px; + font-size: 12px; + } + + .wounds { + box-sizing: border-box; + width: 100%; + min-height: 5rem; + margin: 0; + padding: 4px 6px; + resize: vertical; + border: 1px solid var(--dg-ink); + border-radius: 3px; + font-size: 12px; + } + } + + .tab.combat .items-list .item-header, + .tab.combat .items-list .item:not(.item-header) { + display: grid; + grid-template-columns: + 24px minmax(0, 1fr) + minmax(0, calc(4.25rem + var(--weapons-col-damage-trail))) + minmax(0, 3.5rem) + 86px; + align-items: center; + column-gap: 10px; + } + + .tab.combat .items-list .weapons-col-damage, + .tab.combat .items-list .weapons-col-armor-piercing { + display: flex; + justify-content: center; + align-items: center; + min-width: 0; + text-align: center; + white-space: normal; + line-height: 1.2; + overflow-wrap: break-word; + box-sizing: border-box; + } + + .tab.combat .items-list .weapons-col-damage { + padding: 0 calc(2px + var(--weapons-col-damage-trail)) 0 2px; + } + + .tab.combat .items-list .weapons-col-armor-piercing { + padding: 0 2px; + } + + .tab.combat .items-list .weapons-col-damage[data-action="roll"], + .tab.gear .items-list .weapons-col-damage[data-action="roll"] { + cursor: pointer; + flex-wrap: nowrap; + white-space: nowrap; + overflow-wrap: normal; + gap: 0.15em; + } + + .tab.combat .items-list .item:not(.item-header) > .flexrow { + display: contents; + } + + .tab.combat .items-list .item:not(.item-header) .item-image { + margin-right: 0; + } + + .tab.combat .items-list .item-header .item-image, + .tab.combat + .items-list + .item-header + > span:not(.centered-item-property):not(.item-controls) { + min-width: 0; + } + + /* Psychological tab */ + .tab.motivations > div + div { + margin-top: var(--dg-agent-section-gap); + } + + .tab.motivations .items-list .item-header .acute-episode-col, + .tab.motivations .items-list .item .acute-episode-col { + flex: 0 0 7.75rem; + min-width: 7.75rem; + display: flex; + justify-content: center; + align-items: center; + white-space: nowrap; + + input[type="checkbox"] { + margin: 0; + } + } + + /* Equipment tab */ + .tab.gear > div + div { + margin-top: var(--dg-agent-section-gap); + } + + .tab.gear .items-list .weapons-col-damage { + flex: 0 0 calc(4.25rem + var(--weapons-col-damage-trail)); + max-width: calc(4.25rem + var(--weapons-col-damage-trail)); + } + + .tab.gear .items-list .weapons-col-armor-piercing { + flex: 0 0 3.5rem; + max-width: 3.5rem; + } + + .tab.gear .items-list .weapons-col-damage, + .tab.gear .items-list .weapons-col-armor-piercing { + display: flex; + justify-content: center; + align-items: center; + min-width: 0; + text-align: center; + white-space: normal; + line-height: 1.2; + overflow-wrap: break-word; + box-sizing: border-box; + } + + .tab.gear .items-list .weapons-col-damage { + padding: 0 calc(2px + var(--weapons-col-damage-trail)) 0 2px; + } + + .tab.gear .items-list .weapons-col-armor-piercing { + padding: 0 2px; + } + + /* Personal tab */ + .tab.personal .bio-grid-2col, + .tab.personal .bio-grid-5col { + grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr); + row-gap: 6px; + padding-bottom: var(--dg-agent-section-gap); + font-size: 12px; + + span, + .bio-grid-label { + margin: 1px; + } + + input.fill-remaining-space { + margin: 1px; + } + } + + .tab.personal .bio-grid-5col { + grid-template-columns: + minmax(4rem, max-content) minmax(0, 1fr) 20px minmax(4rem, max-content) + minmax(0, 1fr); + } + + .tab.personal .notes-wrapper { + margin-top: var(--dg-agent-section-gap); + } + + /* Effects tab */ + .tab.effects .effect-add { + margin-bottom: var(--dg-agent-section-gap); + + .action-pill { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + width: 100%; + max-width: 100%; + text-align: center; + } + } + + .tab.effects .effect-header { + box-sizing: border-box; + margin-top: var(--dg-agent-section-gap); + margin-bottom: 3px; + padding: 6px 8px; + background: var(--dg-surface-dark); + color: var(--dg-on-dark); + border: 0; + border-radius: 3px; + min-height: 30px; + font-weight: bold; + line-height: 1.2; + text-align: center; + + &:first-of-type { + margin-top: 0; + } + + .effect-source, + .effect-controls { + display: none; + } + + .effect-label { + flex: 1 1 100%; + text-align: center; + } + } + + .tab.effects .effects-list .effect { + min-height: 30px; + padding: 3px 0; + } + + /* About tab */ + .tab.about .about-section-header { + display: none; + } + + .tab.about .dg-about-links { + display: flex; + flex-direction: column; + gap: var(--dg-agent-section-gap); + margin-bottom: var(--dg-agent-section-gap); + + a { + display: flex; + align-items: center; + justify-content: center; + gap: var(--dg-agent-section-gap); + box-sizing: border-box; + width: 100%; + padding: 6px 8px; + text-align: center; + text-decoration: none; + border-radius: 2.5px; + background-color: rgba(0, 0, 0, 0.185); + font-weight: bold; + color: var(--dg-ink); + + &:hover, + &:focus-visible { + color: var(--color-text-dark-header, #406539); + text-decoration: underline; + } + + &:visited { + color: var(--dg-ink); + } + + i { + flex: 0 0 auto; + color: inherit; + } + + span { + flex: 0 0 auto; + line-height: 1.4; + text-align: center; + } + } + } + + .tab.about .dg-about-legal p { + margin: 0.5rem 0; + font-size: 12px; + } +} diff --git a/scss/deltagreen/_character-sheet-cont.scss b/scss/deltagreen/_character-sheet-cont.scss new file mode 100644 index 0000000..0b8ab3b --- /dev/null +++ b/scss/deltagreen/_character-sheet-cont.scss @@ -0,0 +1,345 @@ +.deltagreen .sheet-header .header-top { + margin-bottom: 5px; +} + +.deltagreen .sheet-header .charname input { + /* font-family: "SpecialElite", sans-serif; */ +} + +.deltagreen .sheet-header h2.profession { + font-family: inherit; + font-size: 1.5rem; + border-bottom: 0; + margin-top: 5px; + margin-bottom: 0; +} + +.deltagreen .sheet-header h2.profession input { + width: 100%; + height: 40px; + margin: 0; +} + +div .simple-centered-flex-div { + display: flex; + align-items: center; +} + +div input.fill-remaining-space { + flex: 1; + margin: 1px; + text-align: left; +} + +div span.bio-grid-label { + margin: 1px; + text-align: left; +} + +.bio-grid-2col { + display: grid; + grid-template-columns: 100px auto; + align-items: center; + padding-bottom: 10px; + row-gap: 10px; +} + +.bio-grid-top-border { + border-top: 0px solid #aaa; + padding-top: 10px; +} + +.bio-grid-5col { + display: grid; + grid-template-columns: 100px 100px 20px 120px auto; + align-items: center; + padding-bottom: 10px; +} + +.bonds-grid-3col { + display: grid; + grid-template-columns: auto 50px max-content; + align-items: center; + padding-bottom: 10px; +} + +.centered { + text-align: center !important; +} + +.breaking-point-grid-3col { + display: grid; + grid-template-columns: 20% 70px 65px; + border-top: 0px solid #aaa; + margin-bottom: 10px; + border-bottom: 1px solid #aaa; + align-content: center; + justify-content: left; + align-items: center; + padding-bottom: 10px; + padding-top: 10px; +} + +.adaptation-grid-5col { + display: grid; + grid-template-columns: 30% 50px 50px 50px 200px; + margin-top: 10px; + margin-bottom: 10px; + align-content: center; + justify-content: left; + align-items: center; + padding-bottom: 5px; + border-bottom: 0px solid #aaa; +} + +.adapted-label { + font-weight: bold; + font-style: italic; + color: rgb(139, 0, 0); +} + +.weapon-grid-2col { + display: grid; + grid-template-columns: 48% 48%; + margin-top: 10px; + margin-bottom: 5px; + justify-content: left; + align-items: flex-end; + padding: 0px; + column-gap: 4%; +} + +.centered-item-property { + text-align: center; +} + +.bolded-label { + font-weight: bold; +} + +.resource .labeled-resource-checkbox { + vertical-align: middle; + margin-bottom: 0; +} + +.resource .first-aid-label { + vertical-align: bottom; +} + +div .motivation-section { + border-bottom: 1px solid #aaa; + margin-bottom: 10px; +} + +div .bottom-left-justified-resource { + vertical-align: bottom; + text-align: left; +} + +div .first-aid-received-section { + display: grid; + grid-template-columns: 40% 70px; + border-bottom: 1px solid #aaa; + margin-bottom: 10px; +} + +div .exhausted-section { + display: grid; + grid-template-columns: 40% 70px; + border-bottom: 1px solid #aaa; + margin-bottom: 10px; +} + +.exhausted-section input[type="number"] { + width: 75px; + + color: rgb(139, 0, 0); + text-align: center; + + background-color: rgba(0, 0, 0, 0.05); + + border: 1px solid rgb(122, 121, 113); + border-radius: 3px; + margin-left: 10px; + margin-bottom: 5px; +} + +.resource .exhaustion-label { + vertical-align: middle; +} + +.bonds-section-header { + /* border-top: 1px solid #AAA; */ + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 5px; +} + +.about-section-header { + border-top: 1px solid #aaa; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 5px; +} + +.tab.about .dg-about-links { + gap: 0.5rem; + margin-bottom: 1rem; +} + +.deltagreen .tab.about .dg-about-links a { + display: inline-flex; + align-items: center; + gap: 0.5rem; + width: fit-content; + max-width: 100%; + color: var(--dg-ink); + + &:hover, + &:focus-visible { + color: var(--color-text-dark-header, #406539); + text-decoration: underline; + } + + &:visited { + color: var(--dg-ink); + } +} + +.deltagreen .tab.about .dg-about-links a i { + flex: 0 0 auto; + color: inherit; +} + +.deltagreen .tab.about .dg-about-legal a { + color: var(--dg-ink); + + &:hover, + &:focus-visible { + color: var(--color-text-dark-header, #406539); + text-decoration: underline; + } + + &:visited { + color: var(--dg-ink); + } +} + +.tab.about .dg-about-links a span { + flex: 1 1 auto; + line-height: 1.4; + text-align: left; +} + +.tab.about .dg-about-legal p { + margin: 0.5rem 0; +} + +textarea.wounds { + resize: none; + height: 100px; + width: 100%; +} + +.resource-inner-grid-3col { + display: grid; + grid-template-columns: 35% 30% 35%; + align-items: center; + text-align: center; +} + +.resource-outer-grid-1col { + display: grid; + grid-template-columns: 100%; + align-items: center; + justify-content: center; + border: 1.5px solid var(--dg-ink); + padding: 5px; + grid-gap: 3px; + text-align: center; + border-radius: 2px; +} + +div.max-resource-value { + border: 1px solid rgb(122, 121, 113); + border-radius: 3px; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.physical-stats-outer-grid-3col { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + align-items: center; + justify-content: center; + text-align: center; + padding: 5px; + grid-gap: 5px; +} + +.physical-stats-and-feature { + border-radius: 2px; + border: 1.5px solid var(--dg-ink); +} + +.attributes-wrapper { + margin-block-end: 12px; +} + +div.horizontal-bar-section-divider, +div.horizontal-bar-section-divider.skills-header, +div.horizontal-bar-section-header { + box-sizing: border-box; + margin-bottom: 3px; + background: var(--dg-surface-dark); + color: var(--dg-on-dark); + border-radius: 3px; + line-height: 1.2; + min-height: 30px; + height: auto; + padding: 6px 8px; + text-align: center; +} + +div.horizontal-bar-section-divider { + display: flex; + align-items: center; + justify-content: center; +} + +div.horizontal-bar-section-divider.skills-header { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; +} + +/* Agent sheet: only the title — center in a single column (not grid column 2 of 3). */ +div.horizontal-bar-section-divider.skills-header:not(:has(.toggle-untrained)) { + grid-template-columns: 1fr; +} + +.horizontal-bar-section-divider .skills-header-title { + grid-column: 2; + margin: 0; + line-height: 1.2; +} + +div.horizontal-bar-section-divider.skills-header:not(:has(.toggle-untrained)) + .skills-header-title { + grid-column: 1; + justify-self: center; +} + +.horizontal-bar-section-divider .toggle-untrained { + grid-column: 3; + padding-right: 0.5rem; + justify-self: end; + align-self: center; + line-height: 1.2; +} diff --git a/scss/deltagreen/_character-sheet.scss b/scss/deltagreen/_character-sheet.scss new file mode 100644 index 0000000..e764476 --- /dev/null +++ b/scss/deltagreen/_character-sheet.scss @@ -0,0 +1,286 @@ +.deltagreen .sheet-header { + -webkit-box-flex: 0; + -ms-flex: 0 0 400px; + flex: 0 0 auto; + overflow: hidden; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -ms-flex-direction: row; + flex-direction: row; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-pack: start; + -ms-flex-pack: start; + justify-content: flex-start; + margin-bottom: 8px; +} + +.deltagreen .sheet-header .profile-img { + height: 100px; + flex: 0 0 100px; + -webkit-box-flex: 0; + -ms-flex: 0 0 100px; + + cursor: var(--cursor-pointer); /* Variable name from Foundry */ + + border: 1px solid var(--dg-ink); + margin-right: 5px; +} + +.deltagreen .sheet-header .header-fields { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; +} + +.deltagreen .sheet-header h1.charname { + height: 55px; + font-family: inherit; + font-size: 2rem; + padding: 0px; + border-bottom: 0; + margin-bottom: 0; +} + +.deltagreen .sheet-header h1.charname input { + width: 100%; + height: 100%; + margin: 0; +} + +.deltagreen .resource-label { + font-weight: bold; +} + +.sheet nav.sheet-tabs { + border-bottom: transparent !important; + border-top: transparent !important; +} + +.deltagreen .tabs { + height: 1.5rem; + line-height: 1.5rem; + text-align: center; + vertical-align: middle; + margin-bottom: 0px !important; + padding-bottom: 0px !important; +} + +.deltagreen .tabs [data-action="tab"] { + font-weight: bold; + color: inherit; + vertical-align: middle !important; + height: inherit; + line-height: inherit; + padding-inline: 5px; + border-radius: 5px 5px 0px 0px; + border-color: var(--dg-ink); + border: gray; + border-width: 1px 1px 0px 1px; + border-style: solid; +} + +.deltagreen .tabs [data-action="tab"].active { + text-shadow: none; + border-color: var(--dg-ink); + border-width: 2px 2px 0px 2px; + border-style: solid; +} + +.deltagreen .tab { + height: 100%; + overflow-y: auto; + + border-color: var(--dg-ink); + border-width: 2px; + border-style: solid; + border-radius: 3px; + + padding: 5px; +} + +.deltagreen .program-style .tabs [data-action="tab"].active { + background-color: var(--dg-surface-dark); + color: var(--dg-on-dark); +} + +.deltagreen .cowboy-style .tabs [data-action="tab"].active { + background-color: rgba(156, 115, 18, 0.3); + color: var(--dg-ink); +} + +.deltagreen .items-list .item-name.items-list-name-col { + flex: 1 1 55%; + min-width: 0; +} + +.deltagreen .items-list { + list-style: none; + /*margin: 7px 0; */ + padding: 0; + overflow-y: auto; +} + +.deltagreen .items-list .item-header { + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; +} + +.deltagreen .items-list .item { + min-height: 30px; + line-height: 24px; + padding: 3px 0; + border-bottom: 1px solid #bbb; +} + +.deltagreen .items-list .item .item-image { + -webkit-box-flex: 0; + -ms-flex: 0 0 24px; + flex: 0 0 24px; + margin-right: 5px; +} + +.deltagreen .items-list .item .item-bond-damage { + -webkit-box-flex: 0; + -ms-flex: 0 0 24px; + flex: 0 0 24px; + margin: 0; + align-self: center; + justify-self: center; +} + +.deltagreen .items-list .item img { + display: block; +} + +.deltagreen .items-list .item-name { + display: flex; + align-content: center; + + word-wrap: break-word; + + margin: 0; +} + +.deltagreen .items-list .item-controls { + -webkit-box-flex: 0; + -ms-flex: 0 0 86px; + flex: 0 0 86px; + text-align: right; +} + +.deltagreen .skill-and-training-controls { + flex: 0 0 35px; +} + +.deltagreen .action-pill, +.deltagreen button.action-pill { + padding: 2px; + border-radius: 2.5px; + font-size: 0.8rem; + font-weight: bold; + background-color: rgba(0, 0, 0, 0.185); + color: inherit; + text-decoration: none; + border: none; + cursor: pointer; +} + +.deltagreen button.action-pill { + appearance: none; +} + +.deltagreen[id^="dg-settings"] { + .window-content > div.flexcol { + gap: 1rem; + } + + .form-group label { + flex: unset; + } +} + +.deltagreen[id^="dg-settings"] section.window-content { + color: var(--color-text-primary); + + .form-group > label { + color: var(--color-form-label); + } + + .hint { + color: var(--color-form-hint); + } + + input[type="checkbox"]:checked::before, + input[type="radio"]:checked::before { + color: var(--color-text-primary); + } +} + +a.btn-tiny { + height: 26px; + margin-left: 5px; + margin-right: 5px; + font-size: 13px; + padding-top: 1px; + padding-bottom: 1px; + padding-right: 5px; + padding-left: 5px; + + text-decoration: none; + background-color: #eeeeee; + color: #333333; + + border-radius: 4px; + line-height: 25px; + border: 1px solid #333333; + min-width: 20px; + text-align: center; +} + +span.left-aligned-label { + justify-content: left; + margin-left: 5px; +} + +span.max-attribute-value-label { + border: 1px solid rgb(122, 121, 113); + border-radius: 3px; + padding-top: 3px; + padding-bottom: 1px; + padding-left: 3px; + padding-right: 3px; + height: 26px; +} + +.deltagreen .skill-label { + margin: 2px; + align-content: flex-start; +} + +.deltagreen .skill-type-label { + margin: 2px; +} + +input.percentile-skill-input { + flex: 0 0 40px; + margin: 0 5px; + height: 1.2rem; + align-self: center; + text-align: center; +} + +input.checkbox-skill-input { + flex: 0 0 20px; + line-height: 10px; + width: 1px; +} + +input.checkbox-skill-input:disabled { + visibility: hidden; +} diff --git a/scss/deltagreen/_chat-cards.scss b/scss/deltagreen/_chat-cards.scss new file mode 100644 index 0000000..e1439b0 --- /dev/null +++ b/scss/deltagreen/_chat-cards.scss @@ -0,0 +1,199 @@ +/* Delta Green stylized chat cards */ + +.chat-message.dg-chat-card-message > .message-header { + display: none; +} + +.deltagreen.chat-card.dg-chat-card { + border: 2px solid var(--dg-ink); + border-radius: 3px; + overflow: hidden; + margin: 0; +} + +.deltagreen.chat-card.dg-chat-card.cowboy-style { + font-family: "SpecialElite", sans-serif; +} + +.deltagreen.chat-card.dg-chat-card.outlaw-style, +.deltagreen.chat-card.dg-chat-card.program-style { + font-family: "PublicSans", sans-serif; +} + +.dg-chat-card__header { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + background: var(--dg-surface-dark); + color: var(--dg-on-dark); + padding: 6px 8px; +} + +.dg-chat-card__portrait-slot, +.dg-chat-card__icon-slot { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + width: 40px; + height: 40px; + min-width: 40px; + aspect-ratio: 1 / 1; + box-sizing: border-box; + overflow: hidden; +} + +.dg-chat-card__portrait, +.dg-chat-card__icon.dg-chat-card__portrait { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + object-position: center center; +} + +.dg-chat-card__identity { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + line-height: 1.2; +} + +.dg-chat-card__speaker { + font-size: 0.95rem; + font-weight: bold; + color: var(--dg-on-dark); +} + +.dg-chat-card__author { + font-size: 0.75rem; + color: var(--dg-on-dark); + opacity: 0.85; + margin-top: 1px; +} + +.dg-chat-card__metadata { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + font-size: 0.75rem; + color: var(--dg-on-dark); + opacity: 0.85; + text-align: right; + + time { + color: inherit; + } +} + +.dg-chat-card__roll-label { + padding: 4px 10px; + font-size: 0.8rem; + line-height: 1.3; + border-bottom: 1px solid var(--dg-ink); +} + +.dg-chat-card__roll-label .dg-chat-card__target-info, +.dg-chat-card__roll-label .dg-chat-card__wp-change { + cursor: help; +} + +.dg-chat-card__roll-label .dg-chat-card__ae-indicator { + margin-left: 0.2em; + font-size: 0.85em; + vertical-align: baseline; + + + .dg-chat-card__ae-indicator { + margin-left: 0.05em; + } +} + +.dg-chat-card__roll-label .dg-chat-card__armor-piercing { + display: inline-flex; + align-items: center; + gap: 2px; + white-space: nowrap; + vertical-align: baseline; + + img { + display: inline-block; + flex: 0 0 auto; + } +} + +.dg-chat-card__body { + border-color: var(--dg-ink); + border-width: 0; + border-style: solid; + border-radius: 0; + padding: 5px; + background: inherit; +} + +.dg-chat-card__body .dice-roll { + margin-top: 0.5em; +} + +.rollback-section { + order: 99; + display: flex; + margin-top: 5px; + align-items: center; + justify-content: center; + + > i { + margin: 5px; + } +} + +[data-action="rollback-skill-failure-state"] { + align-items: center; + display: inline-flex; + justify-content: center; + min-height: unset; + width: 20px; + height: 20px; + margin-left: 5px; + font-size: 12px; +} + +.strike { + text-decoration: line-through; +} + +.roll-stats-chat-table { + width: auto; + margin: 0 auto; + border-collapse: collapse; +} + +/* Override Foundry row striping; alternate per cell in reading order (checkerboard in 3 cols) */ +.dg-chat-card__body .roll-stats-chat-table tbody tr { + background-color: transparent; +} + +.dg-chat-card__body .roll-stats-chat-table td { + padding: 0.35rem 1rem; + text-align: center; + font-weight: bold; + font-size: var(--font-size-16, 1rem); + border: 1px solid var(--color-border-light-2, rgba(255, 255, 255, 0.2)); +} + +.dg-chat-card__body .roll-stats-chat-table td.roll-stats-chat-value--a { + background-color: var(--table-row-color-odd, transparent); +} + +.dg-chat-card__body .roll-stats-chat-table td.roll-stats-chat-value--b { + background-color: var(--table-row-color-even, rgba(255, 255, 255, 0.1)); +} + +.dg-chat-card__body .roll-stats-chat-table .roll-stats-chat-value { + min-width: 2rem; +} diff --git a/scss/deltagreen/_cowboy-typography.scss b/scss/deltagreen/_cowboy-typography.scss new file mode 100644 index 0000000..c154fcc --- /dev/null +++ b/scss/deltagreen/_cowboy-typography.scss @@ -0,0 +1,49 @@ +@use "../mixins" as *; + +/* DG form inputs use line-height 1.5rem with height unset — match that box size on cowboy. */ +$cowboy-form-input-height: 1.5rem; + +/* SpecialElite sits visually high in compact centered cells vs Public Sans */ +.deltagreen .window-content.cowboy-style { + .resource-inner-grid-3col input { + @include cowboy-compact-input(1.75rem, 3px); + width: 1.75rem; + } + + .max-resource-value > div { + @include cowboy-compact-label; + } + + input.percentile-skill-input { + @include cowboy-compact-input($cowboy-form-input-height, 2px); + flex: 0 0 40px; + width: 40px; + max-width: 40px; + } + + .physical-stats-outer-grid-3col input.centered, + .physical-stats-outer-grid-2col input.centered, + .stats .ae-input-host--stat input, + .edit-stats-value-row input.centered { + @include cowboy-compact-input($cowboy-form-input-height, 2px); + } + + .breaking-point-inline .breaking-point-input, + input.breaking-point-input.centered { + @include cowboy-compact-input($cowboy-form-input-height, 2px); + width: 2.5rem; + min-width: 2.5rem; + max-width: 2.5rem; + } + + .exhausted-section input[type="number"] { + @include cowboy-compact-input($cowboy-form-input-height, 2px); + width: 75px; + } + + .scenario-information-corruption-row input[type="number"] { + @include cowboy-compact-input($cowboy-form-input-height, 2px); + width: 2.5rem; + min-width: 2.5rem; + } +} diff --git a/scss/deltagreen/_effects-tab.scss b/scss/deltagreen/_effects-tab.scss new file mode 100644 index 0000000..5456a93 --- /dev/null +++ b/scss/deltagreen/_effects-tab.scss @@ -0,0 +1,115 @@ +/* Active Effects */ +.deltagreen .tab.effects { + padding: 5px; +} + +.deltagreen.item .tab[data-application-part="effects"].active { + overflow-y: auto; +} + +.deltagreen .effects-transfer-hint { + font-size: var(--font-size-12); + font-style: italic; + margin: 0 0 8px; +} + +.deltagreen .effect-add { + margin-bottom: 8px; +} + +.deltagreen .effect-add .action-pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + width: fit-content; + max-width: 100%; + text-align: center; + padding-inline: 6px; +} + +.deltagreen .effect-header { + font-weight: bold; + border-bottom: 1px solid #999; + margin-top: 8px; + padding-bottom: 2px; + gap: 4px; +} + +.deltagreen .effects-list { + list-style: none; + margin: 0; + padding: 0; +} + +.deltagreen .effects-list .effect { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) minmax(0, 6rem) auto auto; + align-items: center; + gap: 6px; + padding: 4px 2px; + border-bottom: 1px solid #ccc; + min-width: 0; +} + +.deltagreen .effects-list .effect-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deltagreen .effects-list .effect-icon { + width: 24px; + height: 24px; + border: none; +} + +.deltagreen .effects-list .effect.suppressed-effect { + opacity: 0.55; +} + +.deltagreen .effects-list .effect.disabled-effect { + opacity: 0.4; + text-decoration: line-through; +} + +.deltagreen .effects-list .effect-source { + font-size: var(--font-size-12); + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deltagreen .effects-list .effect-controls { + display: flex; + gap: 4px; +} + +.deltagreen .effects-list .effect-empty { + font-style: italic; + padding: 6px; +} + +.deltagreen .motivations .acute-episode-col { + flex: 0 0 7.75rem; + min-width: 7.75rem; + display: flex; + justify-content: center; + align-items: center; + white-space: nowrap; + + input[type="checkbox"] { + margin: 0; + } +} + +.deltagreen .motivations li.acute-episode-active { + background: rgba(180, 0, 0, 0.08); +} + +.deltagreen .active-effect-config .dg-effect-key-select { + width: 100%; + max-width: 14rem; +} diff --git a/scss/deltagreen/_foundry-theme.scss b/scss/deltagreen/_foundry-theme.scss new file mode 100644 index 0000000..2eddbb6 --- /dev/null +++ b/scss/deltagreen/_foundry-theme.scss @@ -0,0 +1,90 @@ +/* + Custom UI Styling Elements +*/ +:root :not(.no-theme) { + --color-shadow-primary: #374e13; + --color-shadow-highlight: #406539; + --color-border-highlight: #406539; + --color-border-highlight-alt: #374e13; + --color-underline-header: #406539; + --color-text-dark-header: #406539; + scrollbar-color: var(--color-text-dark-header) #111; +} + +#pause img { + content: url(../assets/img/DG_Pause_Icon.webp); +} + +#interface:not(.no-theme) * { + --color-shadow-primary: #215112 !important; + --color-shadow-highlight: #215112 !important; + --color-border-highlight: #215112 !important; + --color-border-highlight-alt: #215112 !important; + --color-underline-header: #215112 !important; + --color-text-dark-header: #215112 !important; + scrollbar-color: var(--color-text-dark-header) #111 !important; +} + +#interface:not(.theme-light) * { + --control-border-color: #215112; + --control-hover-border-color: #215112; +} + +/* The logo is defunct as of v13 */ +#logo { + content: url("../assets/img/Delta_Green_Logo.webp"); + width: 90px; + height: auto; + padding-top: 10px; + padding-bottom: 10px; +} + +.ae-mod-increase { + color: #00a82d; +} + +.ae-mod-decrease { + color: #e00000; +} + +/* Stats/skills: dark input chrome — brighter red (overlays keep inherited weight) */ +.deltagreen input.ae-mod-decrease, +.deltagreen input.centered.ae-mod-decrease, +.deltagreen input.percentile-skill-input.ae-mod-decrease, +.deltagreen .ae-input-host .ae-input-display.ae-mod-decrease { + color: #ff8a8a; + -webkit-text-fill-color: #ff8a8a; +} + +.deltagreen input.ae-mod-decrease, +.deltagreen input.centered.ae-mod-decrease, +.deltagreen input.percentile-skill-input.ae-mod-decrease { + font-weight: bold; +} + +.deltagreen input.percentile-skill-input.ae-mod-increase, +.deltagreen input.centered.ae-mod-increase { + color: #00a82d; + -webkit-text-fill-color: #00a82d; + font-weight: bold; +} + +.deltagreen .max-resource-value.ae-mod-increase > div, +.deltagreen .max-resource-value.ae-mod-decrease > div { + font-weight: bold; +} + +table.dg-roll-target-tooltip { + margin-top: 0.25em; + border-collapse: collapse; +} + +table.dg-roll-target-tooltip td { + padding: 1px 6px 1px 0; + vertical-align: top; +} + +table.dg-roll-target-tooltip td:last-child { + text-align: right; + white-space: nowrap; +} diff --git a/scss/deltagreen/_global.scss b/scss/deltagreen/_global.scss new file mode 100644 index 0000000..7fbc8c3 --- /dev/null +++ b/scss/deltagreen/_global.scss @@ -0,0 +1,9 @@ +@use "../mixins" as *; + +/* Global styles */ + +.deltagreen section.window-content { + @include dg-window-content; + @include dg-window-form-inputs; + @include dg-parchment-content-theme; +} diff --git a/scss/deltagreen/_item-sheets.scss b/scss/deltagreen/_item-sheets.scss new file mode 100644 index 0000000..cb32103 --- /dev/null +++ b/scss/deltagreen/_item-sheets.scss @@ -0,0 +1,319 @@ +@use "../mixins" as *; + +/* + Item sheets (ApplicationV2): Foundry .application uses light --color-text-primary, + but DG window-content uses light parchment backgrounds. Reset ink, headings, labels, + and form fields here so new item tabs stay readable without per-type patches. +*/ +.deltagreen.item .window-content { + color: var(--dg-ink); + scrollbar-color: #215112 #111 !important; + + @include dg-window-form-inputs-with-select; + @include dg-parchment-content-theme; + @include dg-parchment-form-fields; +} + +/* + Item Sheet Common CSS +*/ + +.item-img { + -webkit-box-flex: 0; + -ms-flex: 0 0 100px; + flex: 0 0 100px; + height: 100px; + border: 0px solid var(--dg-ink); +} + +input.item-sheet-short-input { + width: 75px; + margin-right: 5px; + margin-left: 5px; + text-align: center !important; +} + +.item-grid-button-row { + vertical-align: middle !important; + line-height: inherit; + padding: 5px 0px 0px 10px; +} + +/* + Bonds Item Sheet +*/ + +.bond-sheet-grid-2col { + @include item-sheet-grid-2col; + padding-left: 5px; +} + +.bond-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid(150px); +} + +/* + Motivation Item Sheet +*/ + +.motivation-sheet-horizontal-grid-2col { + display: grid; + grid-template-columns: 50% 50%; + align-items: center; + justify-content: center; + text-align: left; + padding-left: 5px; + padding-bottom: 10px; + grid-gap: 5px; +} + +/* + Armor Item Sheet +*/ + +.armor-sheet-grid-2col { + @include item-sheet-grid-2col; + padding-bottom: 10px; + padding-left: 5px; +} + +.armor-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid; +} + +.armor-sheet-secondary-grid-2col { + @include item-sheet-grid-2col; + justify-content: left; + text-align: left; + padding-bottom: 10px; +} + +/* + Gear Item Sheet +*/ + +.gear-sheet-grid-2col { + @include item-sheet-grid-2col; + padding-left: 5px; +} + +.gear-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid; +} + +/* + Weapon Item Sheet +*/ + +.weapon-sheet-grid-2col { + @include item-sheet-grid-2col; + padding-left: 5px; +} + +.weapon-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid; +} + +.weapon-sheet-horizontal-grid-2col .resource-label { + display: flex; + align-items: center; + min-height: 1.5rem; + margin: 0; +} + +.weapon-sheet-horizontal-grid-2col input[type="text"], +.weapon-sheet-horizontal-grid-2col select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + margin: 0; + line-height: 1.5rem; +} + +.weapon-sheet-header { + margin-bottom: 12px; +} + +/* One grid row per stat pair so left/right labels share the same baseline. */ +.weapon-sheet-stats-grid { + display: grid; + grid-template-columns: + minmax(5.5rem, auto) minmax(9rem, 1.35fr) minmax(5.5rem, auto) + minmax(9rem, 1.35fr); + align-items: center; + column-gap: 12px; + row-gap: 10px; + padding: 0 2px 10px; + text-align: left; +} + +.weapon-sheet-stats-grid .resource-label { + display: flex; + align-items: center; + min-height: var(--weapon-sheet-control-height, 1.75rem); + margin: 0; +} + +.weapon-sheet-stats-grid input[type="text"], +.weapon-sheet-stats-grid select { + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + margin: 0; + height: var(--weapon-sheet-control-height, 1.75rem); + min-height: var(--weapon-sheet-control-height, 1.75rem); + line-height: 1.5rem; + padding: 0 6px; +} + +.weapon-sheet-stats-grid select.weapon-sheet-skill-select { + min-width: 9rem; +} + +.weapon-sheet-stats-grid .labeled-resource-checkbox { + justify-self: start; + align-self: center; + margin: 0; +} + +.weapon-sheet-stats-pad { + display: block; + min-height: 0; + visibility: hidden; + pointer-events: none; +} + +/* + Tome Item Sheet +*/ + +.tome-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid; +} + +.tome-sheet-handler-info-horizontal-grid-2col { + display: grid; + grid-template-columns: max-content auto; + align-items: center; + text-align: left; + column-gap: 10px; + row-gap: 10px; + padding-bottom: 10px; + padding-right: 5px; +} + +/* + Unnatural Character Sheet Specific Classes +*/ + +.unnatural-notes-section { + display: grid; + grid-template-columns: 100%; + align-items: left; + justify-content: left; + border: 1px solid #999; + padding: 5px; + grid-gap: 3px; + text-align: left; +} + +.skill-util-buttons { + margin: 5px; + + .typed-skill-add { + margin-right: 5px; + } +} + +a[data-action="applySkillImprovements"] { + float: right; +} + +.yellow-king { + color: #a0912f; +} + +.yellow-sign { + font-weight: bold; + text-shadow: 1px 1px #313a00; +} + +.yellow-king-rotate1 { + transform: rotate(3deg); +} + +.yellow-king-rotate4 { + transform: rotate(4deg); +} + +.yellow-king-rotate2 { + transform: rotate(-1deg); +} + +.yellow-king-rotate3 { + transform: rotate(5deg); +} + +.yellow-king-grid-9col { + display: grid; + grid-template-columns: max-content 5px max-content max-content 20px max-content max-content 10px 35px; + align-items: center; + padding-bottom: 10px; +} + +.yellow-king-grid-5col { + display: grid; + grid-template-columns: min-content 275px 20px min-content auto; + align-items: center; + padding-bottom: 10px; +} + +.yellow-king-label { + margin-right: 10px; +} + +.yellow-king input, +.yellow-king select, +.yellow-king textarea { + color: #a0912f; + font-weight: bold; +} + +.yellow-king textarea:focus, +.yellow-king input:focus { + color: #a0912f; +} + +.top-div-border { + border-top: 1px solid #aaa; + margin-bottom: 10px; + padding-bottom: 10px; + padding-top: 10px; +} + +.stat-block-input { + min-height: 300px; + resize: none; +} + +.weapon-name-flexcontainer { + display: flex; + justify-content: flex-start; + flex-direction: row; + column-gap: 10px; +} + +.weapon-name-flexitem { + flex: content; + align-self: flex-start; + text-align: left; +} + +.armor-piercing-chat-card-img { + border-radius: 10%; + height: 20px !important; + width: 20px !important; + margin: 0px; + vertical-align: text-bottom; +} diff --git a/scss/deltagreen/_profession.scss b/scss/deltagreen/_profession.scss new file mode 100644 index 0000000..e9a2863 --- /dev/null +++ b/scss/deltagreen/_profession.scss @@ -0,0 +1,127 @@ +@use "../mixins" as *; + +/* Profession item sheet */ +.deltagreen .profession-sheet-grid-2col { + @include item-sheet-grid-2col; + padding-left: 5px; +} + +.deltagreen .profession-sheet-horizontal-grid-2col { + @include item-sheet-horizontal-grid(150px); +} + +.deltagreen.item .tab.profession-skills .profession-skills-scroll { + padding: 0 0.5rem 0.5rem; +} + +.deltagreen + .tab.profession-skills + .profession-skills-section + + .profession-skills-section { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-light-2, rgba(0, 0, 0, 0.15)); +} + +.deltagreen .tab.profession-skills .profession-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding-bottom: 0.35rem; + border-bottom: 1px solid var(--color-border-light-2, rgba(0, 0, 0, 0.15)); +} + +.deltagreen .tab.profession-skills .profession-section-title { + margin: 0; + font-size: var(--font-size-14, 0.875rem); + font-weight: bold; + line-height: 1.3; +} +.deltagreen .tab.profession-skills .profession-section-add { + @include dg-dark-button; + flex: 0 0 auto; + padding: 0.2rem 0.5rem; + font-size: var(--font-size-12, 0.75rem); + line-height: 1.3; +} + +.deltagreen .tab.profession-skills .profession-section-config { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + + .resource-label { + margin: 0; + } +} + +.deltagreen .tab.profession-skills .profession-skill-list-columns-header { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-bottom: 0.25rem; + font-size: var(--font-size-12, 0.75rem); + font-weight: bold; +} + +.deltagreen .tab.profession-skills .profession-skill-col-header { + display: grid; + grid-template-columns: 1fr auto 1.5rem; + gap: 0.5rem; + align-items: center; +} + +.deltagreen .tab.profession-skills .profession-skill-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.2rem 0.75rem; + list-style: none; + margin: 0; + padding: 0; +} + +.deltagreen .tab.profession-skills .profession-skill-row { + display: grid; + grid-template-columns: 1fr auto 1.5rem; + gap: 0.25rem 0.5rem; + align-items: center; + min-height: 27px; + min-width: 0; + box-sizing: border-box; +} + +.deltagreen .tab.profession-skills .profession-skill-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.deltagreen .tab.profession-skills .profession-skill-rating { + text-align: right; + min-width: 2.5rem; +} + +.deltagreen .tab.profession-skills .profession-skill-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: inherit; + opacity: 0.65; + line-height: 1; + cursor: pointer; + + &:hover:not(:disabled) { + @include dg-dark-button; + opacity: 1; + } +} diff --git a/scss/deltagreen/_prose-mirror.scss b/scss/deltagreen/_prose-mirror.scss new file mode 100644 index 0000000..7fdd648 --- /dev/null +++ b/scss/deltagreen/_prose-mirror.scss @@ -0,0 +1,154 @@ +.deltagreen .editor-host { + align-items: stretch; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* ProseMirror: compact when toggled off; expand to fill tab space while editing. */ +.deltagreen .editor-host prose-mirror { + --min-height: 5rem; + flex: 0 0 auto; +} + +.deltagreen:not(.item) .editor-host prose-mirror:not(.active) { + height: auto !important; +} + +.deltagreen .editor-host prose-mirror.active { + --min-height: 12rem; + flex: 1 1 auto; + min-height: 12rem; +} + +.deltagreen .editor-host prose-mirror.active .editor-container { + flex: 1 1 auto; + min-height: 0; +} + +/* Actor sheets: compact description when not editing. */ +.deltagreen:not(.item) .editor-host prose-mirror:not(.active) .editor-content { + position: relative; + inset: unset; + overflow: visible; + min-height: 2.5rem; +} + +/* Let the description block consume remaining tab height while editing. */ +.deltagreen:not(.item) .tab:has(.notes-wrapper prose-mirror.active) { + display: flex; + flex-direction: column; + min-height: 0; +} + +.deltagreen:not(.item) + .tab:has(.notes-wrapper prose-mirror.active) + .notes-wrapper, +.deltagreen:not(.item) + .tab:has(.notes-wrapper prose-mirror.active) + .notes-wrapper + .editor-host { + flex: 1 1 auto; + min-height: 0; +} + +/* Item sheets: description fills space below header (view and edit). */ +.deltagreen.item form { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.deltagreen.item .window-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + +.deltagreen.item [data-application-part="header"], +.deltagreen.item [data-application-part="tabs"] { + flex: 0 0 auto; +} + +/* Item tab bodies — flex only when .active; do not override core display:none on inactive tabs. */ +.deltagreen.item .tab[data-tab][data-application-part].active { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +/* Profession skills tab: outer panel flexes; inner region scrolls (see description tab). */ +.deltagreen.item .tab.profession-skills.active, +.deltagreen.item .tab[data-application-part="skills"].active { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + height: auto; + max-height: 100%; + overflow: hidden; +} + +.deltagreen.item .tab.profession-skills .profession-skills-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; +} + +.deltagreen.item + .tab[data-application-part="description"].active.top-div-border, +.deltagreen.item + .tab[data-application-part="handler"].active + > .tab.top-div-border { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.deltagreen.item .tab.top-div-border > .editor-host, +.deltagreen.item + .tab[data-application-part="description"].active + > .editor-host { + flex: 1 1 auto; + min-height: 0; +} + +.deltagreen.item .tab.top-div-border .editor-host prose-mirror { + flex: 1 1 auto; + min-height: 5rem; +} + +.deltagreen.item + .tab.top-div-border + .editor-host + prose-mirror:not(.active) + .editor-content { + position: absolute; + inset: 0; + overflow: auto; + min-height: unset; +} + +.deltagreen.item .tab.top-div-border .editor-host prose-mirror.active { + min-height: 5rem; +} + +/* Core uses display:block on hover for prose-mirror toggles, which breaks flex icon centering. */ +.deltagreen prose-mirror:hover button.toggle:enabled { + display: flex; + justify-content: center; + align-items: center; + padding: 0; +} + +.deltagreen prose-mirror button.toggle > i { + margin: 0; + line-height: 1; +} diff --git a/scss/deltagreen/_stats-edit.scss b/scss/deltagreen/_stats-edit.scss new file mode 100644 index 0000000..86f7542 --- /dev/null +++ b/scss/deltagreen/_stats-edit.scss @@ -0,0 +1,51 @@ +.stats-wrapper { + position: relative; +} + +/* Edit icon */ +.stats-edit-btn { + position: absolute; + top: 50%; + right: -12px; + + width: 24px; + height: 24px; + + display: flex; + align-items: center; + justify-content: center; + + border-radius: 50%; + background: var(--color-border-dark, #444); + color: var(--dg-on-dark); + + font-size: 12px; + + cursor: pointer; + opacity: 0; + transition: + opacity 0.15s ease, + transform 0.15s ease; + transform: translateY(-50%) scale(1); +} + +/* Show on hover of whole stats block */ +.stats-wrapper:hover .stats-edit-btn { + opacity: 1; + transform: translateY(-50%) scale(1.05); +} + +/* Hover feedback */ +.stats-edit-btn:hover { + background: var(--color-border-highlight, #c9a227); +} + +.physical-stats-outer-grid-2col { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + justify-content: center; + text-align: center; + padding: 5px; + grid-gap: 5px; +} diff --git a/scss/dg-dialog.scss b/scss/dg-dialog.scss new file mode 100644 index 0000000..9013130 --- /dev/null +++ b/scss/dg-dialog.scss @@ -0,0 +1,2 @@ +@use "dg-dialog/base"; +@use "dg-dialog/dialogs"; diff --git a/scss/dg-dialog/_base.scss b/scss/dg-dialog/_base.scss new file mode 100644 index 0000000..f912d90 --- /dev/null +++ b/scss/dg-dialog/_base.scss @@ -0,0 +1,83 @@ +/* Delta Green — shared DialogV2 styling (.dg-dialog-app) */ + +@use "../mixins" as *; + +.dg-dialog-app { + section.window-content { + @include dg-window-content; + @include dg-window-form-inputs-with-select; + @include dg-parchment-content-theme; + @include dg-parchment-form-fields; + } + + .dg-dialog__field-label, + .dg-dialog .dg-dialog__heading, + .dg-dialog__intro { + color: inherit; + } + + .dg-dialog__field-label, + .dg-dialog .dg-dialog__heading { + font-weight: bold; + } + + .dg-dialog__intro { + margin: 0 0 0.5rem; + line-height: 1.4; + } + + .dg-dialog__messages { + margin-top: 0.5rem; + } + + .dg-dialog__message--error, + .dg-dialog .validation-error { + color: var(--color-text-error, #c00); + font-weight: bold; + margin: 0.25rem 0; + } + + .dg-dialog__message--warning, + .dg-dialog .cap-warning { + color: var(--color-text-warning, #c60); + font-weight: bold; + margin: 0.25rem 0; + } + + .form-footer { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: stretch; + gap: 0.5rem; + margin-top: 0.75rem; + + > button { + flex: 1 1 0; + min-width: 0; + @include dg-dark-button; + + &:disabled { + background-color: #555; + color: #f0f0f0; + border-color: #555; + opacity: 1; + cursor: not-allowed; + } + } + } + + &--pick-statistics .form-footer { + display: grid; + grid-template-columns: 1fr 1fr; + + > button[data-action="skip"] { + grid-column: 1 / -1; + } + } + + &--stimulants .form-footer, + &--modify-roll .form-footer { + display: none; + } +} diff --git a/scss/dg-dialog/_dialogs.scss b/scss/dg-dialog/_dialogs.scss new file mode 100644 index 0000000..2c4b768 --- /dev/null +++ b/scss/dg-dialog/_dialogs.scss @@ -0,0 +1,930 @@ +/* Assign stats */ +.dg-dialog--assign-stats .assign-stats-remaining { + margin: 0 0 0.75rem; + font-weight: bold; +} + +.dg-dialog--assign-stats .assign-stats-remaining.is-invalid { + color: var(--color-text-warning, #ff9200); +} + +.dg-dialog--assign-stats .assign-stats-rows { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.dg-dialog--assign-stats .assign-stats-row { + display: grid; + grid-template-columns: 3rem 1fr; + align-items: center; + gap: 0.75rem; +} + +.dg-dialog--assign-stats .assign-stats-label { + font-weight: bold; +} + +.dg-dialog--assign-stats .assign-stats-row input { + width: 100%; + text-align: center; +} + +/* Roll stats */ +.dg-dialog--roll-stats .roll-stats-layout { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: start; +} + +.dg-dialog--roll-stats .roll-stats-pool-section { + min-width: 5.5rem; +} + +.dg-dialog--roll-stats .roll-stats-pool-hint { + margin: 0.75rem 0 0; + font-size: var(--font-size-12, 0.75rem); + text-align: center; + color: var(--color-text-dark-secondary, rgba(0, 0, 0, 0.65)); +} + +.dg-dialog--roll-stats .roll-stats-pool { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + width: 3rem; + min-height: 3rem; + padding: 0.5rem; + border: 1px dashed rgb(122, 121, 113); + border-radius: 4px; +} + +.dg-dialog--roll-stats .roll-stats-pool.drag-over { + border-color: var(--color-border-highlight, #ff6400); + background: rgba(255, 100, 0, 0.08); +} + +.dg-dialog--roll-stats .roll-stat-token { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; + font-weight: bold; + font-size: var(--font-size-16, 1rem); + border: 1px solid rgb(122, 121, 113); + border-radius: 4px; + background: rgba(0, 0, 0, 0.05); + cursor: grab; + user-select: none; + box-sizing: border-box; +} + +.dg-dialog--roll-stats .roll-stat-token.is-dragging { + opacity: 0.5; + cursor: grabbing; +} + +.dg-dialog--roll-stats .roll-stats-assignment-section { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.dg-dialog--roll-stats .roll-stat-row { + display: grid; + grid-template-columns: 3rem auto; + align-items: center; + gap: 0.75rem; +} + +.dg-dialog--roll-stats .roll-stat-label { + font-weight: bold; +} + +.dg-dialog--roll-stats .roll-stat-slot { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + flex: 0 0 auto; + padding: 0; + border: 1px dashed rgb(122, 121, 113); + border-radius: 4px; + box-sizing: border-box; +} + +.dg-dialog--roll-stats .roll-stat-slot.drag-over { + border-color: var(--color-border-highlight, #ff6400); + background: rgba(255, 100, 0, 0.08); +} + +.dg-dialog--roll-stats .roll-stat-slot .roll-stat-token { + cursor: grab; + width: 100%; + height: 100%; +} + +/* Add profession skill (item editor) */ +.dg-dialog-app.dg-dialog-app--add-profession-skill + .profession-typed-label-group.hidden, +.dg-dialog--add-profession-skill .profession-typed-label-group.hidden { + display: none; +} + +.dg-dialog-app.dg-dialog-app--add-profession-skill + .profession-typed-label-group, +.dg-dialog--add-profession-skill .profession-typed-label-group { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; +} + +.dg-dialog-app.dg-dialog-app--add-profession-skill + .profession-typed-label-group + > *, +.dg-dialog--add-profession-skill .profession-typed-label-group > * { + flex: 0 0 auto; + width: 100%; + max-width: 100%; +} + +.dg-dialog-app.dg-dialog-app--add-profession-skill .profession-typed-name-field, +.dg-dialog-app.dg-dialog-app--add-profession-skill .profession-choose-one-field, +.dg-dialog--add-profession-skill .profession-typed-name-field, +.dg-dialog--add-profession-skill .profession-choose-one-field { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; +} + +.dg-dialog-app.dg-dialog-app--add-profession-skill + .profession-typed-name-field + > label, +.dg-dialog--add-profession-skill .profession-typed-name-field > label { + flex: none; + width: 100%; + line-height: 1.3; +} + +.dg-dialog--add-profession-skill .profession-typed-name-field input { + width: 100%; +} + +.dg-dialog--add-profession-skill .profession-choose-one-field { + padding-top: 0.25rem; +} + +.dg-dialog--add-profession-skill .profession-choose-one-checkbox { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; +} + +.dg-dialog--add-profession-skill .profession-choose-one-hint { + margin: 0.25rem 0 0; +} + +/* Modify roll — keep layout in deltagreen.css under .dg-dialog--modify-roll */ + +/* Stimulants */ +.dg-dialog--stimulants .stimulants-dialog { + display: grid; + grid-template-columns: 10rem 1fr; + gap: 12px; + align-items: center; +} + +.dg-dialog--stimulants .stimulants-option { + display: contents; +} + +.dg-dialog--stimulants .stimulants-choice-btn { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + border-radius: 3px; + font-size: 0.8rem; + font-weight: bold; + background-color: var(--dg-surface-dark); + color: var(--dg-on-dark); + border: 1px solid var(--dg-surface-dark); + cursor: pointer; + white-space: nowrap; + text-align: center; +} + +.dg-dialog--stimulants .stimulants-choice-btn:hover { + background-color: var(--dg-surface-dark-hover); + color: var(--dg-on-dark); + border-color: var(--dg-surface-dark-hover); +} + +.dg-dialog--stimulants .stimulants-explanation { + margin: 0; + font-size: var(--font-size-12); + line-height: 1.35; +} + +/* Modify percentile roll */ +.dg-dialog--modify-roll .form-group { + margin-bottom: 0.5rem; +} + +.dg-dialog--modify-roll .modify-roll-readonly { + flex: 1; + font-weight: bold; +} + +.dg-dialog--modify-roll .modify-roll-section { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid rgb(122, 121, 113); +} + +.dg-dialog--modify-roll .modify-roll-section h3 { + margin: 0 0 0.35rem; + font-size: var(--font-size-14, 0.875rem); + font-weight: bold; +} + +.dg-dialog--modify-roll .modify-roll-hint { + margin: 0 0 0.65rem; + font-size: var(--font-size-12); + line-height: 1.35; +} + +.dg-dialog--modify-roll .modify-roll-quick-grid { + display: grid; + grid-template-columns: 5rem 1fr; + gap: 10px 12px; + align-items: center; +} + +.dg-dialog--modify-roll .modify-roll-quick-option { + display: contents; +} + +.dg-dialog--modify-roll .modify-roll-quick-btn, +.dg-dialog--modify-roll .modify-roll-submit-btn { + box-sizing: border-box; + padding: 4px 8px; + border-radius: 3px; + font-size: 0.8rem; + font-weight: bold; + background-color: var(--dg-surface-dark); + color: var(--dg-on-dark); + border: 1px solid var(--dg-surface-dark); + cursor: pointer; + white-space: nowrap; + text-align: center; +} + +.dg-dialog--modify-roll .modify-roll-quick-btn:hover, +.dg-dialog--modify-roll .modify-roll-submit-btn:hover { + background-color: var(--dg-surface-dark-hover); + color: var(--dg-on-dark); + border-color: var(--dg-surface-dark-hover); +} + +.dg-dialog--modify-roll .modify-roll-quick-explanation { + margin: 0; + font-size: var(--font-size-12); + line-height: 1.35; +} + +.dg-dialog--modify-roll .modify-roll-custom-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.dg-dialog--modify-roll .modify-roll-custom-row input[type="number"] { + flex: 0 0 5rem; + text-align: center; +} + +.dg-dialog--modify-roll .modify-roll-submit-btn { + flex: 0 0 auto; + min-width: 5rem; +} + +/* Character creation — flex chain keeps footer visible; skill totals scroll */ +.dg-dialog-app.dg-dialog-app--character-creation.application { + display: flex; + flex-direction: column; +} + +.dg-dialog-app.dg-dialog-app--character-creation.application > .window-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--character-creation .dialog-form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--character-creation + .dialog-form + > .dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--character-creation .dialog-form > .form-footer { + flex: 0 0 auto; +} + +.dg-dialog-app.dg-dialog-app--character-creation section.window-content { + min-height: 38rem; +} + +.dg-dialog--character-creation.dg-dialog { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--character-creation .dg-dialog__layout--two-column { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 40px; + flex: 1 1 auto; + min-height: 0; + min-width: 0; + overflow: hidden; +} + +.dg-dialog--character-creation .dg-dialog__column--left, +.dg-dialog--character-creation .dg-dialog__column--right { + display: flex; + flex-direction: column; + flex: 1 1 0; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--character-creation .dg-dialog__column--right > .dg-dialog__messages, +.dg-dialog--hard-experience .dg-dialog__column--right > .dg-dialog__messages { + flex: 0 0 6rem; + height: 6rem; + min-height: 6rem; + max-height: 6rem; + overflow-y: auto; + margin-top: 0.5rem; + scrollbar-color: #215112 #111 !important; +} + +/* Character creation tabs — same equal-width overlap as agent sheet */ +.dg-dialog--character-creation nav.character-creation-tabs { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: center; + gap: 0; + width: 100%; + min-width: 0; + box-sizing: border-box; + margin: 0; + padding: 0; + border-top: none; + border-bottom: transparent; + height: 1.5rem; + line-height: 1.5rem; + position: relative; + z-index: 2; + overflow-x: auto; + overflow-y: hidden; +} + +.dg-dialog-app.dg-dialog-app--character-creation + section.window-content.program-style { + --dg-agent-tab-inactive-bg: rgb(186, 184, 181); + --dg-agent-tab-active-bg: var(--dg-surface-dark); + --dg-agent-tab-active-color: var(--dg-on-dark); +} + +.dg-dialog-app.dg-dialog-app--character-creation + section.window-content.cowboy-style, +.dg-dialog-app.dg-dialog-app--character-creation + section.window-content.outlaw-style { + --dg-agent-tab-inactive-bg: rgb(210, 190, 150); + --dg-agent-tab-active-bg: rgb(194, 168, 110); + --dg-agent-tab-active-color: var(--dg-ink); +} + +.dg-dialog--character-creation nav.character-creation-tabs [data-action="tab"] { + flex: 1 1 0; + min-width: 0; + position: relative; + text-align: center; + margin-left: -14px; + padding-inline: 3px; + font-size: 11px; + overflow: visible; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + -1px 0 2px rgba(0, 0, 0, 0.1), + 1px 0 2px rgba(0, 0, 0, 0.1); +} + +.dg-dialog--character-creation + nav.character-creation-tabs + [data-action="tab"]:first-child { + margin-left: 0; +} + +.dg-dialog--character-creation + nav.character-creation-tabs + [data-action="tab"]:nth-child(1) { + z-index: 1; +} + +.dg-dialog--character-creation + nav.character-creation-tabs + [data-action="tab"]:nth-child(2) { + z-index: 2; +} + +.dg-dialog--character-creation + nav.character-creation-tabs + [data-action="tab"]:nth-child(3) { + z-index: 3; +} + +.dg-dialog-app.dg-dialog-app--character-creation + section.window-content + .character-creation-tabs + [data-action="tab"]:not(.active) { + background: var(--dg-agent-tab-inactive-bg); +} + +.dg-dialog-app.dg-dialog-app--character-creation + section.window-content + .character-creation-tabs + [data-action="tab"].active { + z-index: 10; + margin-bottom: -2px; + padding-bottom: 2px; + background: var(--dg-agent-tab-active-bg); + color: var(--dg-agent-tab-active-color); + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.22), + -2px 0 5px rgba(0, 0, 0, 0.14), + 2px 0 5px rgba(0, 0, 0, 0.14); +} + +.dg-dialog--character-creation .dg-dialog__tab-panels { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--character-creation .dg-dialog__tab-panels > .tab { + display: none; + overflow-y: auto; + min-height: 0; + scrollbar-color: #215112 #111 !important; +} + +.dg-dialog--character-creation .dg-dialog__tab-panels > .tab.active { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.dg-dialog--character-creation .character-creation-skill-totals { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin-bottom: 0.5rem; + scrollbar-color: #215112 #111 !important; +} + +.dg-dialog--character-creation .character-creation-skills-section { + display: flex; + flex-direction: column; + margin-bottom: 0; +} + +.dg-dialog--character-creation .character-creation-skill-totals-header { + margin-bottom: 0.5rem; + flex: 0 0 auto; +} + +.dg-dialog--character-creation + .character-creation-skill-totals-header + .dg-dialog__intro { + margin: 0; + line-height: 1.4; +} + +.dg-dialog--character-creation .character-creation-section-heading { + font-size: var(--font-size-16); + margin: 0 0 0.5rem; + line-height: 1.35; +} + +.dg-dialog--character-creation .character-creation-skills-columns-header { + flex: 0 0 auto; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.75rem; + margin-bottom: 0.25rem; + font-size: var(--font-size-12); + font-weight: bold; +} + +.dg-dialog--character-creation .character-creation-skill-col-header { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + padding: 0 0.15rem; +} + +.dg-dialog--character-creation .character-creation-skills-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.2rem 0.75rem; +} + +.dg-dialog--character-creation .dg-dialog__skill-entry { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.25rem 0.5rem; + align-items: center; + min-width: 0; +} + +.dg-dialog--character-creation .dg-dialog__skill-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dg-dialog--character-creation .dg-dialog__skill-value { + text-align: right; + min-width: 2.5rem; +} + +.dg-dialog--character-creation + .dg-dialog__skill-entry.is-modified + .dg-dialog__skill-label, +.dg-dialog--character-creation + .dg-dialog__skill-entry.is-modified + .dg-dialog__skill-value { + color: var(--color-text-success, #2d8a3e); +} + +.dg-dialog--character-creation .character-creation-choose-one-section { + flex: 0 0 auto; + margin: 0.5rem 0; +} + +.dg-dialog--character-creation .character-creation-choose-one-list { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.dg-dialog--character-creation .character-creation-choose-one-row { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.dg-dialog--character-creation .character-creation-choose-one-row input { + width: 100%; +} + +.dg-dialog--character-creation .character-creation-option-list { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 0.75rem; + margin: 0; + padding: 0; +} + +.dg-dialog--character-creation .character-creation-option-column { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.dg-dialog--character-creation .character-creation-option-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + min-width: 0; +} + +.dg-dialog--character-creation .character-creation-option-row > label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + width: 100%; + min-width: 0; +} + +.dg-dialog--character-creation + .character-creation-option-row + .option-skill-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +.dg-dialog--character-creation + .character-creation-option-row + .option-choose-one-label { + width: 100%; +} + +.dg-dialog--character-creation + .character-creation-option-row + .option-choose-one-label:disabled { + opacity: 0.65; +} + +.dg-dialog--character-creation + .dg-dialog__tab-panels + > .tab[data-tab="bonus-skills"].active { + display: flex; + flex-direction: column; + min-height: 28rem; + box-sizing: border-box; +} + +.dg-dialog--character-creation .character-creation-bonus-intro { + flex: 0 0 auto; + margin: 0 0 0.5rem; + line-height: 1.4; +} + +.dg-dialog--character-creation .character-creation-bonus-grid { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-evenly; + min-height: 0; + margin: 0; + padding: 0; +} + +.dg-dialog--character-creation .character-creation-bonus-slot { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 0 0 auto; +} + +.dg-dialog--character-creation .character-creation-bonus-slot select { + flex: 0 1 11rem; + min-width: 7rem; +} + +.dg-dialog--character-creation + .character-creation-bonus-slot + .bonus-typed-label { + flex: 1 1 0; + min-width: 5rem; +} + +.dg-dialog--character-creation + .character-creation-bonus-slot + .bonus-typed-label.hidden { + display: none; +} + +.dg-dialog--character-creation + .dg-dialog__tab-panels + > .tab[data-tab="bonds"].active { + display: flex; + flex-direction: column; + min-height: 28rem; + box-sizing: border-box; +} + +.dg-dialog--character-creation .character-creation-bonds-list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: space-evenly; + min-height: 0; + margin: 0; + padding: 0; +} + +.dg-dialog--character-creation .character-creation-bonds-intro { + flex: 0 0 auto; + margin: 0 0 0.5rem; + line-height: 1.4; +} + +.dg-dialog--character-creation .character-creation-bond-row { + display: grid; + grid-template-columns: 1fr 1fr 3.5rem; + gap: 0.35rem 0.5rem; + align-items: end; + min-width: 0; + flex: 0 0 auto; +} + +.dg-dialog--character-creation .character-creation-bond-field { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + font-size: var(--font-size-12); +} + +.dg-dialog--character-creation .character-creation-bond-field-label { + white-space: nowrap; +} + +.dg-dialog--character-creation .character-creation-bond-field input { + width: 100%; + min-width: 0; +} + +.dg-dialog--character-creation .character-creation-bond-field-score input { + text-align: center; +} + +/* Damaged Veterans */ +.dg-dialog-app.dg-dialog-app--damaged-veterans.application, +.dg-dialog-app.dg-dialog-app--things-disorder.application { + display: flex; + flex-direction: column; +} + +.dg-dialog--damaged-veterans .damaged-veterans-options, +.dg-dialog--things-disorder .things-disorder-options { + display: flex; + flex-direction: column; + gap: 0.65rem; + margin: 0; + padding: 0; + border: 0; +} + +.dg-dialog--damaged-veterans label.checkbox.damaged-veterans-option, +.dg-dialog--things-disorder label.checkbox.things-disorder-option, +.dg-dialog--hard-experience label.checkbox.hard-experience-bond-option { + align-items: flex-start; + gap: 0.5rem; + font-size: inherit; + line-height: 1.4; +} + +.dg-dialog--damaged-veterans + label.checkbox.damaged-veterans-option + > input[type="radio"], +.dg-dialog--things-disorder + label.checkbox.things-disorder-option + > input[type="radio"], +.dg-dialog--hard-experience + label.checkbox.hard-experience-bond-option + > input[type="radio"] { + margin-top: 0.15rem; +} + +.dg-dialog--damaged-veterans .damaged-veterans-option-text strong { + font-weight: bold; +} + +/* Hard Experience — reuse character creation two-column layout */ +.dg-dialog-app.dg-dialog-app--hard-experience.application { + display: flex; + flex-direction: column; +} + +.dg-dialog-app.dg-dialog-app--hard-experience.application > .window-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--hard-experience .dialog-form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + max-height: 100%; + overflow: hidden; +} + +.dg-dialog-app.dg-dialog-app--hard-experience .dialog-form > .dialog-content { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--hard-experience.dg-dialog { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dg-dialog--hard-experience .hard-experience-bonus-section { + margin-bottom: 1rem; +} + +.dg-dialog--hard-experience .hard-experience-bond-removal { + flex: 0 0 auto; +} + +.dg-dialog--hard-experience .hard-experience-bond-options { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin: 0; + padding: 0; + border: 0; +} + +/* Edit statistics */ +.dg-dialog--edit-stats .edit-stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem 0.5rem; +} + +.dg-dialog--edit-stats .edit-stats-stat-block { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.dg-dialog--edit-stats .edit-stats-value-row { + display: flex; + flex-direction: column; + gap: 0.25rem; + align-items: stretch; +} + +.dg-dialog--edit-stats .edit-stats-value-row input { + width: 100%; + text-align: center; +} + +.dg-dialog--edit-stats .edit-stats-feature-row { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.dg-dialog--edit-stats .edit-stats-feature-row textarea { + width: 100%; + min-height: 2.5rem; + resize: vertical; +} + +/* Damage or lethality roll choice */ +.dg-dialog--damage-or-lethality .damage-or-lethality-weapon { + margin: 0.5rem 0 0; +} diff --git a/system.json b/system.json index fec8891..07d6792 100644 --- a/system.json +++ b/system.json @@ -28,7 +28,11 @@ } }, "esmodules": ["module/deltagreen.js"], - "styles": ["css/deltagreen.css", "css/dialogue.css"], + "styles": [ + "css/deltagreen.css", + "css/dg-dialog.css", + "css/damage-modify.css" + ], "languages": [ { "lang": "en", @@ -233,10 +237,15 @@ "htmlFields": ["description"] }, "tome": { - "htmlFields": ["description", "handlerNotes"] + "htmlFields": ["description", "handlerNotes"], + "gmOnlyFields": ["handlerNotes"] }, "ritual": { - "htmlFields": ["description", "handlerNotes"] + "htmlFields": ["description", "handlerNotes"], + "gmOnlyFields": ["handlerNotes"] + }, + "profession": { + "htmlFields": ["description"] } } } diff --git a/templates/active-effect/change-row.html b/templates/active-effect/change-row.html new file mode 100644 index 0000000..9235448 --- /dev/null +++ b/templates/active-effect/change-row.html @@ -0,0 +1,29 @@ +
  • +
    + +
    +
    + {{formInput fields.type name=change.typePath value=change.type choices=dgChangeTypes}} +
    +
    + {{formInput fields.value name=change.valuePath value=change.value elementType="input"}} +
    + +
    + {{formInput fields.priority name=change.priorityPath value=change.priority placeholder=defaultPriority}} +
    +
    + +
    +
  • diff --git a/templates/actor/edit-stats.html b/templates/actor/edit-stats.html index 4c0fe54..19757fc 100644 --- a/templates/actor/edit-stats.html +++ b/templates/actor/edit-stats.html @@ -1,26 +1,26 @@ -
    - {{#each actor.system.statistics as |statistic key| }} -
    -
    -
    - +
    +
    + {{#each statKeys as |key| }} + {{#with (lookup @root.actor.system.statistics key) as |statistic| }} +
    +
    -
    - -
    + value="{{lookup (lookup @root.sourceStatistics key) 'value'}}" + data-dtype="Number" + min="0" /> + +
    + {{/with}} + {{/each}}
    - {{/each}}
    diff --git a/templates/actor/partials/ae-backed-input.html b/templates/actor/partials/ae-backed-input.html new file mode 100644 index 0000000..02f4591 --- /dev/null +++ b/templates/actor/partials/ae-backed-input.html @@ -0,0 +1,22 @@ +{{#if displayClass}} +
    + + +
    +{{else}} + +{{/if}} diff --git a/templates/actor/partials/agent-skill-row-partial.html b/templates/actor/partials/agent-skill-row-partial.html new file mode 100644 index 0000000..16ba829 --- /dev/null +++ b/templates/actor/partials/agent-skill-row-partial.html @@ -0,0 +1,81 @@ +{{#if (eq skill.skillKind "typed") }} +
    +
    + +
    +
    + + +
    + +
    +
    +
    +{{else}} +
    + + + + +
    +{{/if}} diff --git a/templates/actor/partials/agent-special-training-row-partial.html b/templates/actor/partials/agent-special-training-row-partial.html new file mode 100644 index 0000000..0e053c0 --- /dev/null +++ b/templates/actor/partials/agent-special-training-row-partial.html @@ -0,0 +1,32 @@ +
    +
    + +
    + + +
    +
    +
    diff --git a/templates/actor/partials/armor-section-partial.html b/templates/actor/partials/armor-section-partial.html new file mode 100644 index 0000000..b2e83d7 --- /dev/null +++ b/templates/actor/partials/armor-section-partial.html @@ -0,0 +1,75 @@ +
    +
    + {{localize 'DG.Gear.Armor'}} +
    +
      +
    1. + +
      + {{#if actor.system.settings.sorting.armorSortAlphabetical }} + + + + {{else}} + + + + {{/if}} +
      +
      + {{localize 'DG.Gear.Name'}} +
      + {{localize 'DG.Gear.ArmorRating'}} + {{localize 'DG.Gear.Equipped'}} + + +
    2. + + {{#each actor.armor as |item id| }} + +
    3. + +
      + +
      + + {{item.name}} + + {{item.system.protection}} + + + {{#if item.system.equipped }} + + {{else}} + + {{/if}} + + + +
    4. + {{/each}} +
    +
    diff --git a/templates/actor/partials/attributes-grid-partial.html b/templates/actor/partials/attributes-grid-partial.html index 6d9a53c..72802f4 100644 --- a/templates/actor/partials/attributes-grid-partial.html +++ b/templates/actor/partials/attributes-grid-partial.html @@ -7,7 +7,7 @@
    -
    + +
    +
      +
    1. + +
      + +
      +
      +
      {{localize 'DG.Bonds.Name'}}
      +
      {{localize 'DG.Bonds.Relationship'}}
      +
      {{localize 'DG.Bonds.Score'}}
      + +
    2. + + {{#each actor.items as |item id| }} + {{#if_eq item.type "bond" }} + +
    3. + +
      + +
      + {{item.name}} + {{item.system.relationship}} + {{item.system.score}} + +
    4. + + {{/if_eq}} + {{/each}} +
    + +

    {{localizeWithFallback "DG.Generic.CheckDamagedBondsBoxUntilNextHomeScene" "Check a damaged Bond's box until the next Home scene ends." }}

    + +
    diff --git a/templates/actor/partials/custom-skills-partial-agent.html b/templates/actor/partials/custom-skills-partial-agent.html new file mode 100644 index 0000000..c4f5ceb --- /dev/null +++ b/templates/actor/partials/custom-skills-partial-agent.html @@ -0,0 +1,15 @@ +{{#if showSpecialTrainingSection }} +
    + {{localize "DG.Sheet.BlockHeaders.SpecialTraining"}} +
    + +
    + {{#each specialTrainingColumns as |column| }} +
    + {{#each column as |block| }} + {{> "systems/deltagreen/templates/actor/partials/agent-special-training-row-partial.html" block=block }} + {{/each}} +
    + {{/each}} +
    +{{/if}} diff --git a/templates/actor/partials/custom-skills-partial.html b/templates/actor/partials/custom-skills-partial.html index 002a57f..6a4ddd5 100644 --- a/templates/actor/partials/custom-skills-partial.html +++ b/templates/actor/partials/custom-skills-partial.html @@ -92,23 +92,13 @@
    - {{#if_not_eq (localize (concat 'DG.Attributes.' skill.key)) (concat "DG.Attributes." skill.key) }} - - {{else}} - {{#if_not_eq (localize (concat 'DG.Skills.' skill.key)) (concat "DG.Skills." skill.key) }} - - {{else}} - - {{/if_not_eq}} - {{/if_not_eq}} +
    + +
    +
    + + +
    +
    + + +
    +
    diff --git a/templates/actor/partials/left-bar-exhaustion-section.html b/templates/actor/partials/left-bar-exhaustion-section.html new file mode 100644 index 0000000..77fe85f --- /dev/null +++ b/templates/actor/partials/left-bar-exhaustion-section.html @@ -0,0 +1,40 @@ + + {{localize 'DG.Physical.Exhaust'}} + + +
    + + {{#if physicalUi.isExhausted }} + + {{else}} + + {{/if}} +
    + +
    + + +
    diff --git a/templates/actor/partials/left-bar-rest-tab.html b/templates/actor/partials/left-bar-rest-tab.html new file mode 100644 index 0000000..2c25604 --- /dev/null +++ b/templates/actor/partials/left-bar-rest-tab.html @@ -0,0 +1,16 @@ +
    + {{> "systems/deltagreen/templates/actor/partials/left-bar-exhaustion-section.html" }} + + +
    diff --git a/templates/actor/partials/left-bar-sanity-tab.html b/templates/actor/partials/left-bar-sanity-tab.html new file mode 100644 index 0000000..c7cf42d --- /dev/null +++ b/templates/actor/partials/left-bar-sanity-tab.html @@ -0,0 +1,87 @@ +
    +
    + + + +
    + + + +
    {{localize 'DG.Sheet.BlockHeaders.Adaptations'}}
    + +
    + + + + + +
    + +
    + + + + + + +
    + +
    +
    +
    diff --git a/templates/actor/partials/notes-partial.html b/templates/actor/partials/notes-partial.html index 922f6d7..86a5815 100644 --- a/templates/actor/partials/notes-partial.html +++ b/templates/actor/partials/notes-partial.html @@ -2,5 +2,5 @@
    {{localize "DG.Sheet.BlockHeaders.PhysicalDescriptionNotes"}}
    -
    {{{enrichedDescription}}}
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}}
    diff --git a/templates/actor/partials/other-gear-section-partial.html b/templates/actor/partials/other-gear-section-partial.html new file mode 100644 index 0000000..e3c780e --- /dev/null +++ b/templates/actor/partials/other-gear-section-partial.html @@ -0,0 +1,247 @@ +{{!-- OTHER GEAR/MISC --}} + +
    +
    + {{localize 'DG.Gear.OtherGear'}} +
    + +
      +
    1. +
      + {{#if actor.system.settings.sorting.gearSortAlphabetical }} + + + + {{else}} + + + + {{/if}} +
      + +
      {{localize 'DG.Gear.Name'}}
      + + {{localize 'DG.Gear.Equipped'}} + + +
    2. + + {{#each actor.gear as |item id| }} +
    3. +
      + +
      + + {{item.name}} + + + {{#if item.system.equipped }} + + {{else}} + + {{/if}} + + + +
    4. + {{/each}} +
    +
    + +{{#if showHyperGeometrySection }} + {{!-- TOMES --}} +
    + +
    + {{localize 'DG.Gear.Tomes'}} +
    + +
      + +
    1. + +
      + {{#if actor.system.settings.sorting.tomeSortAlphabetical }} + + + + + {{else}} + + + + + {{/if}} +
      + +
      {{localize 'DG.Tomes.Name'}}
      + + + +
    2. + + {{#each actor.tomes as |item id| }} + +
    3. + +
      + +
      + + + {{#if item.system.revealed }} + {{item.name}} + {{else}} + ??? + {{#if (playerHasGamemasterPrivileges) }} + ({{item.name}}) + {{/if}} + {{/if}} + + + + +
    4. + + {{/each}} + +
    + +
    + + {{!-- RITUALS --}} +
    + +
    + + {{localize 'DG.Gear.Rituals'}} + + +
    + +
      + +
    1. + +
      + {{#if actor.system.settings.sorting.ritualSortAlphabetical }} + + + + {{else}} + + + + {{/if}} +
      + +
      {{localize 'DG.Ritual.Name'}}
      + + + +
    2. + + {{#each actor.rituals as |item id| }} + +
    3. + +
      + +
      + + + {{#if item.system.revealed }} + {{item.name}} + {{else}} + ??? + {{#if (playerHasGamemasterPrivileges) }} + ({{item.name}}) + {{/if}} + {{/if}} + + + + +
    4. + + {{/each}} + +
    + +
    +{{/if}} diff --git a/templates/actor/partials/profession-subname.html b/templates/actor/partials/profession-subname.html new file mode 100644 index 0000000..ee7b340 --- /dev/null +++ b/templates/actor/partials/profession-subname.html @@ -0,0 +1,15 @@ +{{#if professionItem}} + +{{else}} + +{{/if}} diff --git a/templates/actor/partials/sanity-agent.html b/templates/actor/partials/sanity-agent.html index 22b585d..7e51ec0 100644 --- a/templates/actor/partials/sanity-agent.html +++ b/templates/actor/partials/sanity-agent.html @@ -8,16 +8,16 @@
    ???
    {{else}} - + data-tooltip="{{actor.system.sanity.tooltip}}" /> / -
    {{numberFormat actor.system.sanity.max decimals=0 sign=false}}
    diff --git a/templates/actor/partials/weapons-section-partial.html b/templates/actor/partials/weapons-section-partial.html new file mode 100644 index 0000000..7ae33d8 --- /dev/null +++ b/templates/actor/partials/weapons-section-partial.html @@ -0,0 +1,123 @@ +
    +
    + {{localize 'DG.Gear.Weapons'}} +
    + +
      +
    1. +
      + {{#if actor.system.settings.sorting.weaponSortAlphabetical }} + + + + {{else}} + + + + {{/if}} +
      + + + {{localize 'DG.Gear.Name'}} + + + {{localize 'DG.Gear.DamageOrLethality'}} + {{localize 'DG.Gear.ArmorPiercing'}} + +
    2. + + {{#each actor.weapons as |item id| }} +
    3. +
      + + + {{item.name}} + + +
      + + {{#if (hasWeaponDamageAndLethality item)}} + + {{toUpperCase item.system.damage}} + {{#if_eq item.system.skill 'unarmed_combat' }} + {{actor.system.statistics.str.meleeDamageBonusFormula}} + {{/if_eq}} + {{#if_eq item.system.skill 'melee_weapons' }} + {{actor.system.statistics.str.meleeDamageBonusFormula}} + {{/if_eq}} + / {{item.system.lethality}}% + + + {{else}} + {{#if (hasWeaponDamage item.system.damage)}} + + {{toUpperCase item.system.damage}} + {{#if_eq item.system.skill 'unarmed_combat' }} + {{actor.system.statistics.str.meleeDamageBonusFormula}} + {{/if_eq}} + {{#if_eq item.system.skill 'melee_weapons' }} + {{actor.system.statistics.str.meleeDamageBonusFormula}} + {{/if_eq}} + + + {{else}} + {{#if (hasWeaponLethality item.system.lethality)}} + + {{item.system.lethality}}% + + + {{else}} + + {{/if}} + {{/if}} + {{/if}} + + {{item.system.armorPiercing}} + + +
    4. + {{/each}} +
    +
    diff --git a/templates/actor/parts/about-tab.html b/templates/actor/parts/about-tab.html index 607354c..5289bc3 100644 --- a/templates/actor/parts/about-tab.html +++ b/templates/actor/parts/about-tab.html @@ -1,8 +1,6 @@
    -
    -

    + {{#if (eq actor.type "vehicle") }} + + {{else if (eq actor.type "unnatural") }} + + {{else}} + {{/if}}

    diff --git a/templates/actor/parts/left-bar.html b/templates/actor/parts/left-bar.html index b583ced..1fd3dfa 100644 --- a/templates/actor/parts/left-bar.html +++ b/templates/actor/parts/left-bar.html @@ -4,25 +4,19 @@ src="{{actor.img}}" data-action="editImage" data-edit="img" - data-tooltip="{{actor.name}}" - height="150" - width="100" /> -
    + data-tooltip="{{actor.name}}" /> +
    - + {{> "systems/deltagreen/templates/actor/partials/profession-subname.html" }}
    - + @@ -30,21 +24,25 @@ {{#each actor.system.statistics as |statistic key| }}
    -
    -
    @@ -52,8 +50,6 @@
    -
    -
    {{!-- HP --}}
    @@ -69,7 +65,7 @@ value="{{actor.system.health.value}}" data-dtype="Number" /> / -
    {{numberFormat actor.system.health.max decimals=0 sign=false}}
    @@ -90,7 +86,7 @@ value="{{actor.system.wp.value}}" data-dtype="Number" /> / -
    {{numberFormat actor.system.wp.max decimals=0 sign=false}}
    @@ -101,22 +97,27 @@ {{/if}}
    -
    - -
    - - - +
    +
    -
    - - -
    + +
    + {{> "systems/deltagreen/templates/actor/partials/left-bar-sanity-tab.html" }} + {{> "systems/deltagreen/templates/actor/partials/left-bar-rest-tab.html" }} +
    diff --git a/templates/actor/parts/motivations-tab-agent.html b/templates/actor/parts/motivations-tab-agent.html new file mode 100644 index 0000000..f53242c --- /dev/null +++ b/templates/actor/parts/motivations-tab-agent.html @@ -0,0 +1,71 @@ +{{!-- Psychological Tab (formerly 'Mental', formerly 'Motivations') --}} +
    + + {{> "systems/deltagreen/templates/actor/partials/bonds-section-partial.html" }} + +
    + {{localize "DG.Sheet.BlockHeaders.MotivationsAndDisorders"}} +
    + +
    +
      + +
    1. +
      {{localize 'DG.Mental.Motivations'}}
      +
      {{localize 'DG.Mental.Disorders'}}
      +
      {{localize 'DG.ItemWindow.Motivations.AcuteEpisode'}}
      + +
    2. + + {{#each actor.items as |item id| }} + {{#if_eq item.type 'motivation' }} + +
    3. + + {{item.name}} + + + + {{#if item.system.crossedOut }}{{dgDisorderLabel item.system.disorder}}{{/if}} + + + + + + + +
    4. + + {{/if_eq}} + {{/each}} +
    +
    +
    diff --git a/templates/actor/parts/motivations-tab-v2.html b/templates/actor/parts/motivations-tab-v2.html deleted file mode 100644 index 1572866..0000000 --- a/templates/actor/parts/motivations-tab-v2.html +++ /dev/null @@ -1,122 +0,0 @@ -{{!-- Psychological Tab (formerly 'Mental', formerly 'Motivations') --}} -
    -
    - {{localize "DG.Sheet.BlockHeaders.BreakingPointAndAdaptations"}} -
    -
    -
    - -
    - - - - - - - - - - - - - - - -
    -
    -
    - -
    - {{localize "DG.Sheet.BlockHeaders.MotivationsAndDisorders"}} -
    - -
    -
      - -
    1. -
      {{localize 'DG.Mental.Motivations'}}
      -
      {{localize 'DG.Mental.Disorders'}}
      - -
    2. - - {{#each actor.items as |item id| }} - {{#if_eq item.type 'motivation' }} - -
    3. - - {{item.name}} - - - {{#if item.system.crossedOut }} - {{item.system.disorder}} - {{/if}} - - -
    4. - - {{/if_eq}} - {{/each}} -
    -
    -
    diff --git a/templates/actor/parts/motivations-tab.html b/templates/actor/parts/motivations-tab.html deleted file mode 100644 index c734c2a..0000000 --- a/templates/actor/parts/motivations-tab.html +++ /dev/null @@ -1,149 +0,0 @@ -{{!-- Psychological Tab (formerly 'Mental', formerly 'Motivations') --}} -
    -
    - {{localize "DG.Sheet.BlockHeaders.BreakingPointAndAdaptations"}} -
    -
    -
    - - - {{localize 'DG.Mental.Reset'}} -
    - -
    - -
    - - - - - - - - - - - - - - - -
    -
    -
    - -
    - {{localize "DG.Sheet.BlockHeaders.MotivationsAndDisorders"}} -
    - -
    -
      - -
    1. -
      {{localize 'DG.Mental.Motivations'}}
      -
      {{localize 'DG.Mental.Disorders'}}
      - -
    2. - - {{#each actor.items as |item id| }} - {{#if_eq item.type 'motivation' }} - -
    3. - - {{item.name}} - - - {{#if item.system.crossedOut }} - {{item.system.disorder}} - {{/if}} - - -
    4. - - {{/if_eq}} - {{/each}} -
    -
    -
    diff --git a/templates/actor/parts/personal-tab-agent.html b/templates/actor/parts/personal-tab-agent.html new file mode 100644 index 0000000..c160b37 --- /dev/null +++ b/templates/actor/parts/personal-tab-agent.html @@ -0,0 +1,106 @@ +{{!-- djlint:off H020 --}} + +
    +
    + {{localize "DG.Sheet.BlockHeaders.PersonalInfoCV"}} +
    +
    + {{localize 'DG.CV.Sex'}} + + + {{localize 'DG.CV.AgeAndDoB'}} + +
    + +
    + + {{localize 'DG.CV.Employer'}} + + + {{localize 'DG.CV.Nationality'}} + + +
    + +
    + {{localize 'DG.CV.Education'}} + +
    + + {{#if (showImpossibleLandscapesContent) }} + +
    + {{localize "DG.Sheet.BlockHeaders.ScenarioInformation"}} +
    + +
    +
    + {{localize 'DG.ImpossibleLanscapes.Gift'}} + + {{localize 'DG.ImpossibleLanscapes.Insight'}} + +
    +
    +
    +
    + {{localize 'DG.ImpossibleLanscapes.IHaveSeenThe'}} + {{localize 'DG.ImpossibleLanscapes.YellowSign'}} + +
    +
    + + {{localize 'DG.ImpossibleLanscapes.CorruptionFirstHalf'}} + {{localize 'DG.ImpossibleLanscapes.CorruptionSecondHalf'}} + + +
    +
    +
    +
    + + {{/if}} + + {{> "systems/deltagreen/templates/actor/partials/notes-partial.html"}} +
    diff --git a/templates/actor/parts/physical-tab-v2.html b/templates/actor/parts/physical-tab-v2.html deleted file mode 100644 index 60757bc..0000000 --- a/templates/actor/parts/physical-tab-v2.html +++ /dev/null @@ -1,59 +0,0 @@ -{{!-- Physical Statistics like STR, CON, etc. --}} -
    - -
    - {{localize "DG.Sheet.BlockHeaders.Injuries"}} -
    - -
    -
    - - -
    -
    - - - - - {{#if actor.system.physical.exhausted }} - - - - - {{/if}} -
    -
    - - - - -
    -
    -
    diff --git a/templates/actor/parts/physical-tab.html b/templates/actor/parts/physical-tab.html deleted file mode 100644 index 4024cc8..0000000 --- a/templates/actor/parts/physical-tab.html +++ /dev/null @@ -1,61 +0,0 @@ -{{!-- Physical Statistics like STR, CON, etc. --}} -
    - - {{> "systems/deltagreen/templates/actor/partials/attributes-grid-partial.html"}} - -
    - {{localize "DG.Sheet.BlockHeaders.Injuries"}} -
    - -
    -
    - - -
    -
    - - - - - {{#if actor.system.physical.exhausted }} - - - - - {{/if}} -
    -
    - - - - -
    -
    -
    diff --git a/templates/actor/parts/right-bar.html b/templates/actor/parts/right-bar.html index 301c144..fc37a4b 100644 --- a/templates/actor/parts/right-bar.html +++ b/templates/actor/parts/right-bar.html @@ -1,9 +1,9 @@
    - {{> "systems/deltagreen/templates/actor/parts/skills-tab.html" }} - {{> "systems/deltagreen/templates/actor/parts/physical-tab-v2.html" }} - {{> "systems/deltagreen/templates/actor/parts/motivations-tab-v2.html" }} - {{> "systems/deltagreen/templates/actor/parts/gear-tab.html" }} - {{> "systems/deltagreen/templates/actor/parts/bio-tab-v2.html" }} - {{> "systems/deltagreen/templates/actor/parts/bonds-tab.html" }} + {{> "systems/deltagreen/templates/actor/parts/skills-tab-agent.html" }} + {{> "systems/deltagreen/templates/actor/parts/combat-tab-agent.html" }} + {{> "systems/deltagreen/templates/actor/parts/gear-tab-agent.html" }} + {{> "systems/deltagreen/templates/actor/parts/motivations-tab-agent.html" }} + {{> "systems/deltagreen/templates/actor/parts/personal-tab-agent.html" }} + {{> "systems/deltagreen/templates/actor/parts/effects-tab.html" }} {{> "systems/deltagreen/templates/actor/parts/about-tab.html" }}
    diff --git a/templates/actor/parts/skills-tab-agent.html b/templates/actor/parts/skills-tab-agent.html new file mode 100644 index 0000000..f1f94b7 --- /dev/null +++ b/templates/actor/parts/skills-tab-agent.html @@ -0,0 +1,69 @@ +
    +
    + + +
    + {{#each skillColumns as |column| }} +
    + {{#each column as |skill| }} + {{> "systems/deltagreen/templates/actor/partials/agent-skill-row-partial.html" skill=skill }} + {{/each}} +
    + {{/each}} +
    + + {{> "systems/deltagreen/templates/actor/partials/custom-skills-partial-agent.html" }} +
    + + + + {{#if showNotesInSkills }} + {{> "systems/deltagreen/templates/actor/partials/notes-partial.html"}} + {{/if}} +
    diff --git a/templates/actor/parts/skills-tab.html b/templates/actor/parts/skills-tab.html index 5ef1b96..dd86d98 100644 --- a/templates/actor/parts/skills-tab.html +++ b/templates/actor/parts/skills-tab.html @@ -29,9 +29,7 @@ - +
    +
    + +
    +
    + {{speakerName}} + {{authorName}} +
    + +
    + {{#if rollLabel}} +
    {{{rollLabel}}}
    + {{/if}} +
    {{{content}}}
    + diff --git a/templates/dialog/add-profession-skill.html b/templates/dialog/add-profession-skill.html new file mode 100644 index 0000000..d4dfd9f --- /dev/null +++ b/templates/dialog/add-profession-skill.html @@ -0,0 +1,39 @@ +
    +
    + + +
    +
    + + +
    + +
    diff --git a/templates/dialog/add-profession.html b/templates/dialog/add-profession.html new file mode 100644 index 0000000..de4ef0a --- /dev/null +++ b/templates/dialog/add-profession.html @@ -0,0 +1,165 @@ +
    +
    +
    +
    +
    +

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

    +

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

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

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

    +
    + {{#each automaticChooseOneRows}} + + {{/each}} +
    +
    + {{/if}} +
    + +
    + + +
    + {{#if showSkillOptionsTab}} +
    +

    {{localize "DG.Profession.Dialog.SkillsOptionsHeader" picks=optionPicks}}

    +
    + {{#each optionColumns}} +
    + {{#each this}} +
    + + {{#if isChooseOne}} + + {{/if}} +
    + {{/each}} +
    + {{/each}} +
    +
    + {{/if}} + +
    +

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

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

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

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

    {{this}}

    + {{/each}} + {{#each capWarnings}} +

    {{warning}}

    + {{/each}} + {{#each atCapMessages}} +

    {{this}}

    + {{/each}} +
    +
    +
    +
    diff --git a/templates/dialog/apply-skill-improvements.html b/templates/dialog/apply-skill-improvements.html index 213281f..499009a 100644 --- a/templates/dialog/apply-skill-improvements.html +++ b/templates/dialog/apply-skill-improvements.html @@ -1,7 +1,7 @@ -
    - +
    +
    - + {{{localize "DG.Skills.ApplySkillImprovements.DialogEffectsFollowing" skillList=failedSkillNames}}}
    diff --git a/templates/dialog/assign-stats.html b/templates/dialog/assign-stats.html new file mode 100644 index 0000000..60e7930 --- /dev/null +++ b/templates/dialog/assign-stats.html @@ -0,0 +1,19 @@ +
    +

    + {{localize "DG.ProfessionSetup.AssignStats.PointsRemaining" remaining=remaining}} +

    +
    + {{#each stats}} + + {{/each}} +
    +
    diff --git a/templates/dialog/damage-or-lethality-roll.html b/templates/dialog/damage-or-lethality-roll.html new file mode 100644 index 0000000..7014edc --- /dev/null +++ b/templates/dialog/damage-or-lethality-roll.html @@ -0,0 +1,6 @@ +
    +

    {{localize "DG.DamageOrLethalityRoll.Intro"}}

    +

    + {{localize "DG.ModifyDamageRollDialogue.Weapon"}} {{itemName}} +

    +
    diff --git a/templates/dialog/damaged-veterans.html b/templates/dialog/damaged-veterans.html new file mode 100644 index 0000000..e4dff3a --- /dev/null +++ b/templates/dialog/damaged-veterans.html @@ -0,0 +1,19 @@ +
    +

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

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

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

    +

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

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

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

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

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

    +

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

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

    {{this}}

    + {{/each}} +
    +
    +
    +
    diff --git a/templates/dialog/modify-damage-roll.html b/templates/dialog/modify-damage-roll.html index 91b7f14..2547e55 100644 --- a/templates/dialog/modify-damage-roll.html +++ b/templates/dialog/modify-damage-roll.html @@ -1,4 +1,4 @@ -
    +
    {{localize 'DG.ModifyDamageRollDialogue.Weapon'}} {{data.label}} @@ -16,4 +16,4 @@ {{selectOptions (getAvailableRollModes) selected=(getDefaultRollMode) localize=true}}
    - \ No newline at end of file +
    diff --git a/templates/dialog/modify-percentile-roll.html b/templates/dialog/modify-percentile-roll.html index 8df5c75..d971f18 100644 --- a/templates/dialog/modify-percentile-roll.html +++ b/templates/dialog/modify-percentile-roll.html @@ -1,32 +1,48 @@ -
    -
    - {{localize 'DG.ModifySkillRollDialogue.Test'}} - - {{data.label}} - - {{localize 'DG.ModifySkillRollDialogue.OriginalTarget'}} - - {{#if data.hideTarget}} - - {{/if}} - - {{#unless data.hideTarget}} - - {{/unless}} - - - {{localize 'DG.ModifySkillRollDialogue.TargetModifier'}} -
    - - -
    - - - {{selectOptions (getAvailableRollModes) selected=(getDefaultRollMode) localize=true}}
    -
    \ No newline at end of file + +
    +

    {{localize "DG.ModifySkillRollDialogue.QuickModifiersHeader"}}

    +

    {{localize "DG.ModifySkillRollDialogue.QuickModifiersHint"}}

    +
    + {{#each quickModifiers}} +
    + +

    {{explanation}}

    +
    + {{/each}} +
    +
    + +
    +

    {{localize "DG.ModifySkillRollDialogue.CustomModifierHeader"}}

    +

    {{localize "DG.ModifySkillRollDialogue.CustomModifierHint"}}

    +
    + + +
    +
    +
    diff --git a/templates/dialog/pick-statistics.html b/templates/dialog/pick-statistics.html new file mode 100644 index 0000000..05fa5d3 --- /dev/null +++ b/templates/dialog/pick-statistics.html @@ -0,0 +1,3 @@ +
    +

    {{localize "DG.ProfessionSetup.PickStatistics.Intro"}}

    +
    diff --git a/templates/dialog/rename-profession.html b/templates/dialog/rename-profession.html new file mode 100644 index 0000000..6cdda36 --- /dev/null +++ b/templates/dialog/rename-profession.html @@ -0,0 +1,10 @@ +
    +
    + + +
    +
    diff --git a/templates/dialog/roll-stats.html b/templates/dialog/roll-stats.html new file mode 100644 index 0000000..ee3812a --- /dev/null +++ b/templates/dialog/roll-stats.html @@ -0,0 +1,23 @@ +
    +
    +
    + {{#each stats}} +
    + {{label}} +
    +
    + {{/each}} +
    +
    +
    + {{#each rolls}} +
    {{total}}
    + {{/each}} +
    +
    +
    +

    {{localize "DG.ProfessionSetup.RollStats.PoolHeader"}}

    +
    diff --git a/templates/dialog/special-training.html b/templates/dialog/special-training.html index d1fd2f5..4951360 100644 --- a/templates/dialog/special-training.html +++ b/templates/dialog/special-training.html @@ -1,3 +1,4 @@ +
    {{{selectElement}}}
    -
    \ No newline at end of file + +
    diff --git a/templates/dialog/take-stimulants.html b/templates/dialog/take-stimulants.html new file mode 100644 index 0000000..4ad9ce6 --- /dev/null +++ b/templates/dialog/take-stimulants.html @@ -0,0 +1,20 @@ +
    +
    +
    + +

    {{localize "DG.Physical.StimulantsRegularExplanation"}}

    +
    +
    + +

    {{localize "DG.Physical.StimulantsHardExplanation"}}

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

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

    +
    + {{localize "DG.DamagedVeterans.ThingsMan.Dialog.Title"}} + {{#each disorders}} + + {{/each}} +
    + +
    diff --git a/templates/dialog/typed-skill.html b/templates/dialog/typed-skill.html new file mode 100644 index 0000000..f35ffa2 --- /dev/null +++ b/templates/dialog/typed-skill.html @@ -0,0 +1,17 @@ +
    +
    + + +
    +
    + + +
    +
    diff --git a/templates/item/item-armor-sheet.html b/templates/item/item-armor-sheet.html deleted file mode 100644 index b7a7f33..0000000 --- a/templates/item/item-armor-sheet.html +++ /dev/null @@ -1,50 +0,0 @@ -
    -
    -
    - - - -
    - - {{localize 'DG.Bonds.Name'}}: - - - {{localize 'DG.ItemWindow.Armor.Expense'}}: - - - {{localize 'DG.ItemWindow.Armor.Equipped'}}: - - -
    -
    - -
    - {{localize 'DG.ItemWindow.Armor.ArmorRating'}}: - -
    - -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body --}} -
    - - {{!-- Description Tab --}} -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    -
    -
    diff --git a/templates/item/item-bond-sheet.html b/templates/item/item-bond-sheet.html deleted file mode 100644 index 23b59f7..0000000 --- a/templates/item/item-bond-sheet.html +++ /dev/null @@ -1,35 +0,0 @@ -
    -
    -
    - -
    - - {{localize 'DG.Bonds.Name'}}: - - - {{localize 'DG.Bonds.Score'}}: - - - {{localize 'DG.Bonds.Relationship'}}: - - -
    -
    -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body --}} -
    - {{!-- Description Tab --}} -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    -
    -
    diff --git a/templates/item/item-gear-sheet.html b/templates/item/item-gear-sheet.html deleted file mode 100644 index f656bf9..0000000 --- a/templates/item/item-gear-sheet.html +++ /dev/null @@ -1,47 +0,0 @@ -
    -
    -
    - - - -
    - - {{localize 'DG.Bonds.Name'}}: - - - - {{localize 'DG.ItemWindow.Gear.Expense'}}: - - - {{localize 'DG.ItemWindow.Gear.Equipped'}}: - - -
    -
    - -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body --}} -
    - - {{!-- Description Tab --}} -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    -
    -
    diff --git a/templates/item/item-motivation-sheet.html b/templates/item/item-motivation-sheet.html deleted file mode 100644 index 6bcbc20..0000000 --- a/templates/item/item-motivation-sheet.html +++ /dev/null @@ -1,66 +0,0 @@ -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body motivation-sheet-horizontal-grid-2col --}} -
    - -
    - -
    - {{localize 'DG.ItemWindow.Motivations.IsDisorder'}}: - - {{localize 'DG.ItemWindow.Motivations.Motivation'}}: - - {{!-- Only Show the Disorders Section if Motivation Lost Checked --}} - {{#if item.system.crossedOut }} - - {{localize 'DG.ItemWindow.Motivations.Disorder'}}: - - {{localize 'DG.ItemWindow.Motivations.DisorderHasBeenCured'}}: - - {{/if}} - -
    -
    - -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    - -
    - -
    diff --git a/templates/item/item-ritual-sheet.html b/templates/item/item-ritual-sheet.html deleted file mode 100644 index 50f49cc..0000000 --- a/templates/item/item-ritual-sheet.html +++ /dev/null @@ -1,113 +0,0 @@ -
    -
    -
    - - -
    - {{#if item.system.revealed}} - - {{#if (playerHasGamemasterPrivileges)}}{{/if}} - {{localize 'DG.Tome.Name'}}: - - - - {{localize 'DG.ItemWindow.Ritual.StudyTime'}}: - - - {{localize 'DG.ItemWindow.Ritual.ActivationTime'}}: - - - {{localize 'DG.ItemWindow.Ritual.ActivationCosts'}}: - - {{else}} - {{#if (playerHasGamemasterPrivileges)}} - - {{#if (playerHasGamemasterPrivileges)}}{{/if}} - {{localize 'DG.Tome.Name'}}: - - - - {{localize 'DG.ItemWindow.Ritual.StudyTime'}}: - - - {{localize 'DG.ItemWindow.Ritual.ActivationTime'}}: - - - {{localize 'DG.ItemWindow.Ritual.ActivationCosts'}}: - - {{else}} - - {{#if (playerHasGamemasterPrivileges)}}{{/if}} - {{localize 'DG.Tome.Name'}}: - - ??? - - {{localize 'DG.ItemWindow.Ritual.StudyTime'}}: - ??? - - {{localize 'DG.ItemWindow.Ritual.ActivationTime'}}: - ??? - - {{localize 'DG.ItemWindow.Ritual.ActivationCosts'}}: - ??? - {{/if}} - {{/if}} -
    - -
    - -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body --}} -
    - - {{!-- Description Tab --}} -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    - - {{!-- Handler Notes Tab --}} - {{#if (playerHasGamemasterPrivileges)}} -
    -
    - {{localize 'DG.ItemWindow.Tome.UnnaturalIncrease'}}: - - - {{localize 'DG.ItemWindow.Ritual.LearnedSanityDamage'}}: - - - / - - - - - {{localize 'DG.ItemWindow.Ritual.ActivationSanityDamage'}}: - - - / - - - -
    - - {{localize 'DG.ItemWindow.Tome.HandlerNotes'}}: - -
    -
    - {{editor enrichedHandlerNotes target="system.handlerNotes" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    -
    - {{/if}} -
    -
    diff --git a/templates/item/item-tome-sheet.html b/templates/item/item-tome-sheet.html deleted file mode 100644 index b85bccd..0000000 --- a/templates/item/item-tome-sheet.html +++ /dev/null @@ -1,99 +0,0 @@ -
    -
    -
    - - -
    - {{#if item.system.revealed}} - - {{#if (playerHasGamemasterPrivileges)}}{{/if}} - {{localize 'DG.Tome.Name'}}: - - - - {{localize 'DG.Tome.Language'}}: - - - {{localize 'DG.Tome.StudyTime'}}: - - {{else}} - {{#if (playerHasGamemasterPrivileges)}} - - {{#if (playerHasGamemasterPrivileges)}}{{/if}} - {{localize 'DG.Tome.Name'}}: - - - - {{localize 'DG.Tome.Language'}}: - - - {{localize 'DG.Tome.StudyTime'}}: - - {{else}} - - {{#if (playerHasGamemasterPrivileges)}}{{/if}} - {{localize 'DG.Tome.Name'}}: - - ??? - - {{localize 'DG.Tome.Language'}}: - ??? - - {{localize 'DG.Tome.StudyTime'}}: - ??? - {{/if}} - {{/if}} -
    - -
    - -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body --}} -
    - - {{!-- Description Tab --}} -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    - - {{!-- Handler Notes Tab --}} - {{#if (playerHasGamemasterPrivileges)}} -
    -
    - {{localize 'DG.ItemWindow.Tome.UnnaturalIncrease'}}: - - - {{localize 'DG.ItemWindow.Tome.OccultIncrease'}}: - - - {{localize 'DG.ItemWindow.Tome.StudySanityDamage'}}: - - - / - - - -
    - - {{localize 'DG.ItemWindow.Tome.HandlerNotes'}}: - -
    -
    - {{editor enrichedHandlerNotes target="system.handlerNotes" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    -
    - {{/if}} -
    -
    diff --git a/templates/item/item-weapon-sheet.html b/templates/item/item-weapon-sheet.html deleted file mode 100644 index cc03669..0000000 --- a/templates/item/item-weapon-sheet.html +++ /dev/null @@ -1,127 +0,0 @@ -
    -
    -
    - - - -
    - - {{localize 'DG.Bonds.Name'}}: - - - {{localize 'DG.ItemWindow.Weapons.Expense'}}: - - - {{localize 'DG.ItemWindow.Gear.Equipped'}}: - - -
    -
    - -
    - -
    - - {{localize 'DG.ItemWindow.Weapons.Skill'}}: - -
    - -
    - {{#if_eq item.system.skill 'custom'}} - {{localize 'DG.ItemWindow.Weapons.CustomSkillTarget'}}: - - {{/if_eq}} -
    - - -
    - - {{localize 'DG.ItemWindow.Weapons.ArmorPiercing'}}: - - - {{localize 'DG.ItemWindow.Weapons.SkillModifier'}}: - - - {{localize 'DG.ItemWindow.Weapons.IsLethality'}}: - - -
    - - -
    - - {{localize 'DG.ItemWindow.Weapons.Range'}}: - - - {{localize 'DG.ItemWindow.Weapons.KillRadius'}}: - - - {{!-- If a weapon is 'Lethal', need to show the d100% Lethality input, otherwise only show the regular Damage formula (e.g. 1D6, 1D4+2, etc...) --}} - - {{#unless item.system.isLethal}} - - {{localize 'DG.ItemWindow.Weapons.Damage'}}: - - - {{/unless}} - - {{#if item.system.isLethal}} - - {{localize 'DG.ItemWindow.Weapons.Lethality'}} %: - - - {{/if}} - -
    - -
    - -
    - {{localize 'DG.ItemWindow.Weapons.Ammo'}}: - -
    -
    -
    - - {{!-- Sheet Tab Navigation --}} - - - {{!-- Sheet Body --}} -
    - - {{!-- Description Tab --}} -
    -
    - {{editor enrichedDescription target="system.description" engine="prosemirror" button=true owner=owner editable=editable}} -
    -
    -
    -
    diff --git a/templates/item/parts/armor-description.html b/templates/item/parts/armor-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/armor-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/armor-header.html b/templates/item/parts/armor-header.html new file mode 100644 index 0000000..cd22438 --- /dev/null +++ b/templates/item/parts/armor-header.html @@ -0,0 +1,32 @@ +
    +
    + + + +
    + + {{localize 'DG.Bonds.Name'}}: + + + {{localize 'DG.ItemWindow.Armor.Expense'}}: + + + {{localize 'DG.ItemWindow.Armor.Equipped'}}: + + +
    +
    + +
    + {{localize 'DG.ItemWindow.Armor.ArmorRating'}}: + +
    + +
    diff --git a/templates/item/parts/bond-description.html b/templates/item/parts/bond-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/bond-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/bond-header.html b/templates/item/parts/bond-header.html new file mode 100644 index 0000000..d4080d3 --- /dev/null +++ b/templates/item/parts/bond-header.html @@ -0,0 +1,18 @@ +
    +
    + +
    + + {{localize 'DG.Bonds.Name'}}: + + + {{localize 'DG.Bonds.Score'}}: + + + {{localize 'DG.Bonds.Relationship'}}: + + +
    +
    +
    diff --git a/templates/item/parts/effects-tab.html b/templates/item/parts/effects-tab.html new file mode 100644 index 0000000..281ae58 --- /dev/null +++ b/templates/item/parts/effects-tab.html @@ -0,0 +1,96 @@ +
    + {{#if (or (eq item.type 'weapon') (eq item.type 'armor') (eq item.type 'gear'))}} +

    {{localize "DG.ActiveEffects.TransferWhenEquipped"}}

    + {{/if}} + {{#if (eq item.type 'motivation')}} +

    {{localize "DG.ActiveEffects.TransferWhenAcuteEpisode"}}

    + {{/if}} + + {{#if canManageEffects}} + + {{/if}} + +
    + {{localize "DG.ActiveEffects.Temporary"}} + +
    +
      + {{#each sheetEffects.temporary as |effect|}} +
    1. + {{effect.name}} + {{effect.name}} + {{#if effect.duration.label}} + {{effect.duration.label}} + {{/if}} + {{#if effect.suppressionReasonKey}} + + + + {{/if}} + + {{#if ../canManageEffects}} + + + + {{/if}} + +
    2. + {{else}} +
    3. {{localize "DG.ActiveEffects.None"}}
    4. + {{/each}} +
    + +
    + {{localize "DG.ActiveEffects.Permanent"}} + +
    +
      + {{#each sheetEffects.permanent as |effect|}} +
    1. + {{effect.name}} + {{effect.name}} + {{#if effect.suppressionReasonKey}} + + + + {{/if}} + + {{#if ../canManageEffects}} + + + + {{/if}} + +
    2. + {{else}} +
    3. {{localize "DG.ActiveEffects.None"}}
    4. + {{/each}} +
    +
    diff --git a/templates/item/parts/gear-description.html b/templates/item/parts/gear-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/gear-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/gear-header.html b/templates/item/parts/gear-header.html new file mode 100644 index 0000000..07d6a24 --- /dev/null +++ b/templates/item/parts/gear-header.html @@ -0,0 +1,27 @@ +
    +
    + + + +
    + + {{localize 'DG.Bonds.Name'}}: + + + {{localize 'DG.ItemWindow.Gear.Expense'}}: + + + {{localize 'DG.ItemWindow.Gear.Equipped'}}: + + +
    +
    + +
    diff --git a/templates/item/parts/motivation-description.html b/templates/item/parts/motivation-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/motivation-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/motivation-header.html b/templates/item/parts/motivation-header.html new file mode 100644 index 0000000..faa89c5 --- /dev/null +++ b/templates/item/parts/motivation-header.html @@ -0,0 +1,50 @@ +
    +
    + {{localize 'DG.ItemWindow.Motivations.Motivation'}}: + + {{localize 'DG.ItemWindow.Motivations.IsDisorder'}}: + + {{#if item.system.crossedOut }} + {{localize 'DG.ItemWindow.Motivations.Disorder'}}: + {{#if disorderUsesKnownId}} + {{disorderLabel}} + + {{else}} + + {{/if}} + {{localize 'DG.ItemWindow.Motivations.AcuteEpisode'}}: + + {{localize 'DG.ItemWindow.Motivations.DisorderHasBeenCured'}}: + + {{/if}} +
    +
    diff --git a/templates/item/parts/profession-description.html b/templates/item/parts/profession-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/profession-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/profession-header.html b/templates/item/parts/profession-header.html new file mode 100644 index 0000000..3050f45 --- /dev/null +++ b/templates/item/parts/profession-header.html @@ -0,0 +1,26 @@ +
    +
    + +
    + {{localize 'DG.ItemWindow.Profession.Name'}}: + + {{localize 'DG.ItemWindow.Profession.Bonds'}}: + +
    +
    +
    diff --git a/templates/item/parts/profession-skills-tab.html b/templates/item/parts/profession-skills-tab.html new file mode 100644 index 0000000..6f68d75 --- /dev/null +++ b/templates/item/parts/profession-skills-tab.html @@ -0,0 +1,88 @@ +
    +
    +
    +
    +

    {{localize 'DG.ItemWindow.Profession.AutomaticSkills'}}

    + +
    +
    +
    + {{localize 'DG.ItemWindow.Profession.Skill'}} + {{localize 'DG.ItemWindow.Profession.Rating'}} +
    +
    + {{localize 'DG.ItemWindow.Profession.Skill'}} + {{localize 'DG.ItemWindow.Profession.Rating'}} +
    +
    +
      + {{#each automaticSkillRows}} +
    1. + {{label}} + {{rating}}% + +
    2. + {{/each}} +
    +
    + +
    +
    +

    {{localize 'DG.ItemWindow.Profession.OptionSkills'}}

    + +
    +
    + {{localize 'DG.ItemWindow.Profession.OptionPicks'}}: + +
    +
    +
    + {{localize 'DG.ItemWindow.Profession.Skill'}} + {{localize 'DG.ItemWindow.Profession.Rating'}} +
    +
    + {{localize 'DG.ItemWindow.Profession.Skill'}} + {{localize 'DG.ItemWindow.Profession.Rating'}} +
    +
    +
      + {{#each optionSkillRows}} +
    1. + {{label}} + {{rating}}% + +
    2. + {{/each}} +
    +
    +
    +
    diff --git a/templates/item/parts/ritual-description.html b/templates/item/parts/ritual-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/ritual-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/ritual-handler.html b/templates/item/parts/ritual-handler.html new file mode 100644 index 0000000..e8dfd13 --- /dev/null +++ b/templates/item/parts/ritual-handler.html @@ -0,0 +1,30 @@ +
    +
    + {{localize 'DG.ItemWindow.Tome.UnnaturalIncrease'}}: + + + {{localize 'DG.ItemWindow.Ritual.LearnedSanityDamage'}}: + + + / + + + + + {{localize 'DG.ItemWindow.Ritual.ActivationSanityDamage'}}: + + + / + + + +
    + + {{localize 'DG.ItemWindow.Tome.HandlerNotes'}}: + +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.handlerNotes}} +
    +
    diff --git a/templates/item/parts/ritual-header.html b/templates/item/parts/ritual-header.html new file mode 100644 index 0000000..d4ee46c --- /dev/null +++ b/templates/item/parts/ritual-header.html @@ -0,0 +1,55 @@ +
    +
    + + +
    + {{#if item.system.revealed}} + + {{#if (playerHasGamemasterPrivileges)}}{{/if}} + {{localize 'DG.Tome.Name'}}: + + + + {{localize 'DG.ItemWindow.Ritual.StudyTime'}}: + + + {{localize 'DG.ItemWindow.Ritual.ActivationTime'}}: + + + {{localize 'DG.ItemWindow.Ritual.ActivationCosts'}}: + + {{else}} + {{#if (playerHasGamemasterPrivileges)}} + + + {{localize 'DG.Tome.Name'}}: + + + + {{localize 'DG.ItemWindow.Ritual.StudyTime'}}: + + + {{localize 'DG.ItemWindow.Ritual.ActivationTime'}}: + + + {{localize 'DG.ItemWindow.Ritual.ActivationCosts'}}: + + {{else}} + {{localize 'DG.Tome.Name'}}: + ??? + + {{localize 'DG.ItemWindow.Ritual.StudyTime'}}: + ??? + + {{localize 'DG.ItemWindow.Ritual.ActivationTime'}}: + ??? + + {{localize 'DG.ItemWindow.Ritual.ActivationCosts'}}: + ??? + {{/if}} + {{/if}} +
    + +
    + +
    diff --git a/templates/item/parts/tome-description.html b/templates/item/parts/tome-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/tome-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/tome-handler.html b/templates/item/parts/tome-handler.html new file mode 100644 index 0000000..832b919 --- /dev/null +++ b/templates/item/parts/tome-handler.html @@ -0,0 +1,25 @@ +
    +
    + {{localize 'DG.ItemWindow.Tome.UnnaturalIncrease'}}: + + + {{localize 'DG.ItemWindow.Tome.OccultIncrease'}}: + + + {{localize 'DG.ItemWindow.Tome.StudySanityDamage'}}: + + + / + + + +
    + + {{localize 'DG.ItemWindow.Tome.HandlerNotes'}}: + +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.handlerNotes}} +
    +
    diff --git a/templates/item/parts/tome-header.html b/templates/item/parts/tome-header.html new file mode 100644 index 0000000..8640c56 --- /dev/null +++ b/templates/item/parts/tome-header.html @@ -0,0 +1,46 @@ +
    +
    + + +
    + {{#if item.system.revealed}} + + {{#if (playerHasGamemasterPrivileges)}}{{/if}} + {{localize 'DG.Tome.Name'}}: + + + + {{localize 'DG.Tome.Language'}}: + + + {{localize 'DG.Tome.StudyTime'}}: + + {{else}} + {{#if (playerHasGamemasterPrivileges)}} + + + {{localize 'DG.Tome.Name'}}: + + + + {{localize 'DG.Tome.Language'}}: + + + {{localize 'DG.Tome.StudyTime'}}: + + {{else}} + {{localize 'DG.Tome.Name'}}: + ??? + + {{localize 'DG.Tome.Language'}}: + ??? + + {{localize 'DG.Tome.StudyTime'}}: + ??? + {{/if}} + {{/if}} +
    + +
    + +
    diff --git a/templates/item/parts/weapon-description.html b/templates/item/parts/weapon-description.html new file mode 100644 index 0000000..5f81c26 --- /dev/null +++ b/templates/item/parts/weapon-description.html @@ -0,0 +1,5 @@ +
    + {{> "systems/deltagreen/templates/partials/prose-mirror-host.html" html=richText.description}} +
    diff --git a/templates/item/parts/weapon-header.html b/templates/item/parts/weapon-header.html new file mode 100644 index 0000000..27665f5 --- /dev/null +++ b/templates/item/parts/weapon-header.html @@ -0,0 +1,83 @@ +
    +
    + + + +
    + + {{localize 'DG.Bonds.Name'}}: + + + {{localize 'DG.ItemWindow.Weapons.Expense'}}: + + + {{localize 'DG.ItemWindow.Gear.Equipped'}}: + + +
    +
    + +
    + + {{localize 'DG.ItemWindow.Weapons.Skill'}}: + + + {{localize 'DG.ItemWindow.Weapons.Range'}}: + + + {{#if_eq item.system.skill 'custom'}} + {{localize 'DG.ItemWindow.Weapons.CustomSkillTarget'}}: + + + + {{/if_eq}} + + {{localize 'DG.ItemWindow.Weapons.ArmorPiercing'}}: + + + {{localize 'DG.ItemWindow.Weapons.KillRadius'}}: + + + {{localize 'DG.ItemWindow.Weapons.SkillModifier'}}: + + + {{localize 'DG.ItemWindow.Weapons.Damage'}}: + + + {{localize 'DG.ItemWindow.Weapons.Lethality'}} %: + + + {{localize 'DG.ItemWindow.Weapons.Ammo'}}: + + + {{localize 'DG.ItemWindow.Weapons.IsLethality'}}: + + +
    +
    diff --git a/templates/partials/prose-mirror-host.html b/templates/partials/prose-mirror-host.html new file mode 100644 index 0000000..25c30bd --- /dev/null +++ b/templates/partials/prose-mirror-host.html @@ -0,0 +1 @@ +
    {{{html}}}
    diff --git a/templates/roll/damage-roll.hbs b/templates/roll/damage-roll.hbs new file mode 100644 index 0000000..426b698 --- /dev/null +++ b/templates/roll/damage-roll.hbs @@ -0,0 +1,21 @@ +
    +
    +
    {{formula}}
    +
    +
    +
    +
    +
    + {{formula}} + {{total}} +
    +
      +
    1. {{total}}
    2. +
    +
    +
    +
    +
    +

    {{total}}

    +
    +
    diff --git a/templates/roll/lethality-roll.hbs b/templates/roll/lethality-roll.hbs new file mode 100644 index 0000000..f5167fe --- /dev/null +++ b/templates/roll/lethality-roll.hbs @@ -0,0 +1,30 @@ +
    +
    +
    {{resultString}}
    +
    +
    +
    +
    +
    + d100 + {{total}} +
    +
      +
    1. {{total}}
    2. +
    +
    +
    + 2d10 (d10 + d10) + {{nonLethalTotal}} +
    +
      +
    1. {{die1}}
    2. +
    3. {{die2}}
    4. +
    +
    +
    +
    +
    +

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

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

    {{lowResult}} / {{highResult}}

    +
    +