diff --git a/README.md b/README.md index cb36e925..52762111 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,14 @@ This module provides compendiums of the playtest materials, pre-configured for u 3. Click **Install**. 4. Activate the module in your world settings. +### Bridge Nine Playtest Adventure (Optional) +This module provides a compendium containing the Bridge Nine playtest adventure, pre-configured for use in Foundry VTT. +1. In Foundry VTT, go to **Add-on Modules** > **Install Module**. +2. Paste the following URL in the **Manifest URL** field: + `https://raw.githubusercontent.com/Doc-Sun/Bridge_Nine_Adventure_CosmereRPG/main/module.json` +3. Click **Install**. +4. Activate the module in your world settings. + ## Feature requests and bug reports If you encounter any bugs or have ideas for new features, please report them by creating an [Issue](https://github.com/stanavdb/cosmere-rpg/issues). For guidelines on Issues see [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/eslint.config.mjs b/eslint.config.mjs index 39c16b80..19514672 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default tseslint.config( '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-unused-vars': 'off', + 'no-unexpected-multiline': 'off', }, languageOptions: { parserOptions: { diff --git a/src/assets/fonts/roboto-condensed/LICENSE b/src/assets/fonts/roboto-condensed/LICENSE new file mode 100644 index 00000000..65a3057b --- /dev/null +++ b/src/assets/fonts/roboto-condensed/LICENSE @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/roboto-condensed/RobotoCondensed-Bold.woff2 b/src/assets/fonts/roboto-condensed/RobotoCondensed-Bold.woff2 new file mode 100644 index 00000000..030883a7 Binary files /dev/null and b/src/assets/fonts/roboto-condensed/RobotoCondensed-Bold.woff2 differ diff --git a/src/assets/fonts/roboto-condensed/RobotoCondensed-BoldItalic.woff2 b/src/assets/fonts/roboto-condensed/RobotoCondensed-BoldItalic.woff2 new file mode 100644 index 00000000..1d47a859 Binary files /dev/null and b/src/assets/fonts/roboto-condensed/RobotoCondensed-BoldItalic.woff2 differ diff --git a/src/assets/fonts/roboto-condensed/RobotoCondensed-Italic.woff2 b/src/assets/fonts/roboto-condensed/RobotoCondensed-Italic.woff2 new file mode 100644 index 00000000..83d5830d Binary files /dev/null and b/src/assets/fonts/roboto-condensed/RobotoCondensed-Italic.woff2 differ diff --git a/src/assets/fonts/roboto-condensed/RobotoCondensed-Regular.woff2 b/src/assets/fonts/roboto-condensed/RobotoCondensed-Regular.woff2 new file mode 100644 index 00000000..0066a53c Binary files /dev/null and b/src/assets/fonts/roboto-condensed/RobotoCondensed-Regular.woff2 differ diff --git a/src/assets/fonts/roboto-slab/LICENSE b/src/assets/fonts/roboto-slab/LICENSE new file mode 100644 index 00000000..c2d27cb3 --- /dev/null +++ b/src/assets/fonts/roboto-slab/LICENSE @@ -0,0 +1,192 @@ +Copyright 2011-2024 The Roboto Slab Project Authors (https://github.com/googlefonts/robotoslab) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +------------------------------------------------------------------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/src/assets/fonts/roboto-slab/RobotoSlab-Bold.ttf b/src/assets/fonts/roboto-slab/RobotoSlab-Bold.ttf new file mode 100644 index 00000000..8a597f2d Binary files /dev/null and b/src/assets/fonts/roboto-slab/RobotoSlab-Bold.ttf differ diff --git a/src/assets/fonts/roboto-slab/RobotoSlab-Regular.ttf b/src/assets/fonts/roboto-slab/RobotoSlab-Regular.ttf new file mode 100644 index 00000000..faaa4463 Binary files /dev/null and b/src/assets/fonts/roboto-slab/RobotoSlab-Regular.ttf differ diff --git a/src/assets/fonts/roboto/LICENSE b/src/assets/fonts/roboto/LICENSE new file mode 100644 index 00000000..e5d1dbc0 --- /dev/null +++ b/src/assets/fonts/roboto/LICENSE @@ -0,0 +1,192 @@ +Copyright 2011-2024 The Roboto Project Authors (https://github.com/googlefonts/roboto) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +------------------------------------------------------------------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/src/assets/fonts/roboto/Roboto-Bold.woff2 b/src/assets/fonts/roboto/Roboto-Bold.woff2 new file mode 100644 index 00000000..a73ce84a Binary files /dev/null and b/src/assets/fonts/roboto/Roboto-Bold.woff2 differ diff --git a/src/assets/fonts/roboto/Roboto-BoldItalic.woff2 b/src/assets/fonts/roboto/Roboto-BoldItalic.woff2 new file mode 100644 index 00000000..fa0f28a5 Binary files /dev/null and b/src/assets/fonts/roboto/Roboto-BoldItalic.woff2 differ diff --git a/src/assets/fonts/roboto/Roboto-Italic.woff2 b/src/assets/fonts/roboto/Roboto-Italic.woff2 new file mode 100644 index 00000000..5ec46a3c Binary files /dev/null and b/src/assets/fonts/roboto/Roboto-Italic.woff2 differ diff --git a/src/assets/fonts/roboto/Roboto-Regular.woff2 b/src/assets/fonts/roboto/Roboto-Regular.woff2 new file mode 100644 index 00000000..49713b47 Binary files /dev/null and b/src/assets/fonts/roboto/Roboto-Regular.woff2 differ diff --git a/src/assets/icons/svg/dice/retro-adv.svg b/src/assets/icons/svg/dice/retro-adv.svg new file mode 100644 index 00000000..aed64c92 --- /dev/null +++ b/src/assets/icons/svg/dice/retro-adv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/svg/dice/retro-crit.svg b/src/assets/icons/svg/dice/retro-crit.svg new file mode 100644 index 00000000..1c79decf --- /dev/null +++ b/src/assets/icons/svg/dice/retro-crit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/svg/dice/retro-dis.svg b/src/assets/icons/svg/dice/retro-dis.svg new file mode 100644 index 00000000..b0023d9b --- /dev/null +++ b/src/assets/icons/svg/dice/retro-dis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/svg/border_attribute_left.svg b/src/assets/icons/svg/sheet/border_attribute_left.svg similarity index 100% rename from src/icons/svg/border_attribute_left.svg rename to src/assets/icons/svg/sheet/border_attribute_left.svg diff --git a/src/icons/svg/border_attribute_right.svg b/src/assets/icons/svg/sheet/border_attribute_right.svg similarity index 100% rename from src/icons/svg/border_attribute_right.svg rename to src/assets/icons/svg/sheet/border_attribute_right.svg diff --git a/src/icons/svg/border_bar.svg b/src/assets/icons/svg/sheet/border_bar.svg similarity index 100% rename from src/icons/svg/border_bar.svg rename to src/assets/icons/svg/sheet/border_bar.svg diff --git a/src/icons/svg/border_corner.svg b/src/assets/icons/svg/sheet/border_corner.svg similarity index 100% rename from src/icons/svg/border_corner.svg rename to src/assets/icons/svg/sheet/border_corner.svg diff --git a/src/icons/svg/border_defense.svg b/src/assets/icons/svg/sheet/border_defense.svg similarity index 100% rename from src/icons/svg/border_defense.svg rename to src/assets/icons/svg/sheet/border_defense.svg diff --git a/src/icons/svg/border_defense_double.svg b/src/assets/icons/svg/sheet/border_defense_double.svg similarity index 100% rename from src/icons/svg/border_defense_double.svg rename to src/assets/icons/svg/sheet/border_defense_double.svg diff --git a/src/icons/svg/border_deflect.svg b/src/assets/icons/svg/sheet/border_deflect.svg similarity index 100% rename from src/icons/svg/border_deflect.svg rename to src/assets/icons/svg/sheet/border_deflect.svg diff --git a/src/icons/svg/border_full_100x100.svg b/src/assets/icons/svg/sheet/border_full_100x100.svg similarity index 100% rename from src/icons/svg/border_full_100x100.svg rename to src/assets/icons/svg/sheet/border_full_100x100.svg diff --git a/src/icons/svg/border_level.svg b/src/assets/icons/svg/sheet/border_level.svg similarity index 100% rename from src/icons/svg/border_level.svg rename to src/assets/icons/svg/sheet/border_level.svg diff --git a/src/icons/svg/border_sheet_corner.svg b/src/assets/icons/svg/sheet/border_sheet_corner.svg similarity index 100% rename from src/icons/svg/border_sheet_corner.svg rename to src/assets/icons/svg/sheet/border_sheet_corner.svg diff --git a/src/icons/svg/border_sheet_full_150x150.svg b/src/assets/icons/svg/sheet/border_sheet_full_150x150.svg similarity index 100% rename from src/icons/svg/border_sheet_full_150x150.svg rename to src/assets/icons/svg/sheet/border_sheet_full_150x150.svg diff --git a/src/icons/svg/border_stat.svg b/src/assets/icons/svg/sheet/border_stat.svg similarity index 100% rename from src/icons/svg/border_stat.svg rename to src/assets/icons/svg/sheet/border_stat.svg diff --git a/src/icons/svg/border_tab_closed.svg b/src/assets/icons/svg/sheet/border_tab_closed.svg similarity index 100% rename from src/icons/svg/border_tab_closed.svg rename to src/assets/icons/svg/sheet/border_tab_closed.svg diff --git a/src/icons/svg/border_tab_open.svg b/src/assets/icons/svg/sheet/border_tab_open.svg similarity index 100% rename from src/icons/svg/border_tab_open.svg rename to src/assets/icons/svg/sheet/border_tab_open.svg diff --git a/src/icons/svg/border_tier.svg b/src/assets/icons/svg/sheet/border_tier.svg similarity index 100% rename from src/icons/svg/border_tier.svg rename to src/assets/icons/svg/sheet/border_tier.svg diff --git a/src/icons/svg/star_cosmere.svg b/src/assets/icons/svg/sheet/star_cosmere.svg similarity index 100% rename from src/icons/svg/star_cosmere.svg rename to src/assets/icons/svg/sheet/star_cosmere.svg diff --git a/src/declarations/foundry/client/data/abstract/client-document.d.ts b/src/declarations/foundry/client/data/abstract/client-document.d.ts index 581ac116..fe72030b 100644 --- a/src/declarations/foundry/client/data/abstract/client-document.d.ts +++ b/src/declarations/foundry/client/data/abstract/client-document.d.ts @@ -10,6 +10,8 @@ declare function _ClientDocumentMixin< >(base: BaseClass): Mixin; declare class ClientDocument { + readonly uuid: string; + /** * A collection of Application instances which should be re-rendered whenever this document is updated. * The keys of this object are the application ids and the values are Application instances. Each diff --git a/src/declarations/foundry/client/data/documents/actor.d.ts b/src/declarations/foundry/client/data/documents/actor.d.ts index 52e15986..d46081dd 100644 --- a/src/declarations/foundry/client/data/documents/actor.d.ts +++ b/src/declarations/foundry/client/data/documents/actor.d.ts @@ -30,7 +30,9 @@ declare class Actor< get items(): Collection; get effects(): Collection; + get isToken(): boolean; get appliedEffects(): ActiveEffect[]; + get token(): TokenDocument; /** * Return a data object which defines the data schema against which dice rolls can be evaluated. @@ -55,6 +57,19 @@ declare class Actor< options?: Actor.ToggleStatusEffectOptions, ): Promise; + /** + * Retrieve an Array of active tokens which represent this Actor in the current canvas Scene. + * If the canvas is not currently active, or there are no linked actors, the returned Array will be empty. + * If the Actor is a synthetic token actor, only the exact Token which it represents will be returned. + * @param linked Limit results to Tokens which are linked to the Actor. Otherwise, return all Tokens even those which are not linked. + * @param document Return the Document instance rather than the PlaceableObject + * @returns An array of Token instances in the current Scene which reference this Actor. + */ + public getActiveTokens( + linked?: boolean, + document?: boolean + ): (TokenDocument | Token)[]; + /** * Get all ActiveEffects that may apply to this Actor. * If CONFIG.ActiveEffect.legacyTransferral is true, this is equivalent to actor.effects.contents. @@ -77,16 +92,16 @@ declare class Actor< /** * Handle how changes to a Token attribute bar are applied to the Actor. * This allows for game systems to override this behavior and deploy special logic. - * @param {string} attribute The attribute path - * @param {number} value The target attribute value - * @param {boolean} isDelta Whether the number represents a relative change (true) or an absolute change (false) - * @param {boolean} isBar Whether the new value is part of an attribute bar, or just a direct value - * @returns {Promise} The updated Actor document + * @param attribute The attribute path + * @param value The target attribute value + * @param isDelta Whether the number represents a relative change (true) or an absolute change (false) + * @param isBar Whether the new value is part of an attribute bar, or just a direct value + * @returns The updated Actor document */ public async modifyTokenAttribute( - attribute, - value, - isDelta, - isBar, + attribute: string, + value: number, + isDelta: boolean, + isBar: boolean, ): Promise; } diff --git a/src/declarations/foundry/client/data/documents/chat-message.d.ts b/src/declarations/foundry/client/data/documents/chat-message.d.ts index e4a9f670..4d73998f 100644 --- a/src/declarations/foundry/client/data/documents/chat-message.d.ts +++ b/src/declarations/foundry/client/data/documents/chat-message.d.ts @@ -4,4 +4,5 @@ declare interface ChatMessage { timestamp: number; whisper: string[]; rolls: Roll[]; + flags: Record; } diff --git a/src/declarations/foundry/client/data/documents/table-result.d.ts b/src/declarations/foundry/client/data/documents/table-result.d.ts index 8c8a7839..488adb8d 100644 --- a/src/declarations/foundry/client/data/documents/table-result.d.ts +++ b/src/declarations/foundry/client/data/documents/table-result.d.ts @@ -1,4 +1,6 @@ -declare interface TableResult { +declare class TableResult extends _ClientDocumentMixin( + foundry.documents.BaseTableResult, +) { /** * Get the value of a "flag" for this document * See the setFlag method for more details on flags @@ -6,4 +8,10 @@ declare interface TableResult { * @param key The flag key */ getFlag(scope: string, key: string): T; + + static fromSource(source: object, context?: any = {}): TableResult; + + img: string; + + text: string; } diff --git a/src/declarations/foundry/common/abstract/data.d.ts b/src/declarations/foundry/common/abstract/data.d.ts index 89cb82fb..3babf775 100644 --- a/src/declarations/foundry/common/abstract/data.d.ts +++ b/src/declarations/foundry/common/abstract/data.d.ts @@ -259,6 +259,8 @@ namespace foundry { readonly system: Schema; get flags(): Record; + get pack(): string | undefined; + get compendium(): CompendiumCollection | undefined; /** * The canonical name of this Document type, for example "Actor". @@ -600,6 +602,35 @@ namespace foundry { operation: DatabaseCreateOperation, user: documents.BaseUser, ): Promise; + + /* --- Database Update Operations --- */ + + /** + * Pre-process an update operation for a single Document instance. Pre-operation events only occur for the client + * which requested the operation. + * + * @param changes The candidate changes to the Document + * @param options Additional options which modify the update request + * @param user The User requesting the document update + * @returns A return value of false indicates the update operation should be cancelled. + * @internal + */ + async _preUpdate( + changes: object, + options: object, + user: documents.BaseUser, + ): Promise; + + /** + * Post-process an update operation for a single Document instance. Post-operation events occur for all connected + * clients. + * + * @param changed The differential data that was changed relative to the documents prior values + * @param options Additional options which modify the update request + * @param userId The id of the User requesting the document update + * @internal + */ + _onUpdate(changed: object, options: object, userId: string); } interface DataValidationOptions { @@ -755,7 +786,7 @@ namespace foundry { * @param options Options provided to the model constructor * @returns Migrated and cleaned source data which will be stored to the model instance */ - _initializeSource( + protected _initializeSource( data: object | DataModel, options?: object, ): object; @@ -773,7 +804,7 @@ namespace foundry { * This mirrors the workflow of SchemaField#initialize but with some added functionality. * @param options Options provided to the model constructor */ - _initialize(options?: object); + protected _initialize(options?: object); /** * Reset the state of this data instance back to mirror the contained source data, erasing any changes. diff --git a/src/declarations/foundry/common/abstract/fields.d.ts b/src/declarations/foundry/common/abstract/fields.d.ts index 6d5aca2d..de731549 100644 --- a/src/declarations/foundry/common/abstract/fields.d.ts +++ b/src/declarations/foundry/common/abstract/fields.d.ts @@ -202,12 +202,26 @@ declare namespace foundry { */ initialize(value: any, model: object, options?: object): any; + /** + * Export the current value of the field into a serializable object. + * @param value The initialized value of the field + * @returns An exported representation of the field + */ + toObject(value: any): any; + /** * Recursively traverse a schema and retrieve a field specification by a given path * @param path The field path as an array of strings * @internal */ _getField(path: string[]): DataField; + + /** + * Cast a non-default value to ensure it is the correct type for the field + * @param value The provided non-default value + * @returns The standardized value + */ + protected _cast(value: any): any; } class SchemaField extends DataField { diff --git a/src/declarations/foundry/common/abstract/validation.d.ts b/src/declarations/foundry/common/abstract/validation.d.ts index e52355c4..b9af6e77 100644 --- a/src/declarations/foundry/common/abstract/validation.d.ts +++ b/src/declarations/foundry/common/abstract/validation.d.ts @@ -1,6 +1,91 @@ declare namespace foundry { namespace data { namespace validation { + namespace DataModelValidationFailure { + interface Config { + /** + * The value that failed validation for this field. + */ + invalidValue?: any; + + /** + * The value it was replaced by, if any. + */ + fallback?: any; + + /** + * Whether the value was dropped from some parent collection. + * @default true + */ + dropped?: boolean; + + /** + * The validation error message. + */ + message?: string; + + /** + * Whether this failure was unresolved + * @default false + */ + unresolved?: boolean; + } + + interface ElementValidationFailure { + /** + * Either the element's index or some other identifier for it. + */ + id: string | number; + + /** + * Optionally a user-friendly name for the element. + */ + name?: string; + + /** + * The element's validation failure. + */ + failure: DataModelValidationFailure; + } + } + + class DataModelValidationFailure { + /** + * The value that failed validation for this field. + */ + public invalidValue?: any; + + /** + * The value it was replaced by, if any. + */ + public fallback?: any; + + /** + * Whether the value was dropped from some parent collection. + * @defaultValue true + */ + public dropped?: boolean; + + /** + * The validation error message. + */ + public message?: string; + + /** + * If this field contains other fields that are validated + * as part of its validation, their results are recorded here. + */ + public fields: Record; + + /** + * If this field contains a list of elements that are validated + * as part of its validation, their results are recorded here. + */ + public elements: ElementValidationFailure[]; + + constructor(config?: DataModelValidationFailure.Config); + } + class DataModelValidationError extends Error { constructor(errors: Record); } diff --git a/src/index.ts b/src/index.ts index 8fec169b..b359830f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,17 @@ import { ActorType, Condition, ItemType } from './system/types/cosmere'; - +import { SYSTEM_ID } from './system/constants'; +import { TEMPLATES } from './system/utils/templates'; import COSMERE from './system/config'; import './style.scss'; import './system/hooks'; -import { preloadHandlebarsTemplates } from './system/util/handlebars'; -import { registerSettings } from './system/settings'; +import { preloadHandlebarsTemplates } from './system/utils/handlebars'; +import { + registerDeferredSettings, + registerSystemKeybindings, + registerSystemSettings, +} from './system/settings'; import * as applications from './system/applications'; import * as dataModels from './system/data'; @@ -50,8 +55,13 @@ Hooks.once('init', async () => { CONFIG.Token.documentClass = documents.CosmereTokenDocument; + Roll.TOOLTIP_TEMPLATE = `systems/${SYSTEM_ID}/templates/${TEMPLATES.CHAT_ROLL_TOOLTIP}`; + CONFIG.ActiveEffect.legacyTransferral = false; + // Add fonts + configureFonts(); + Actors.unregisterSheet('core', ActorSheet); registerActorSheet(ActorType.Character, applications.actor.CharacterSheet); registerActorSheet(ActorType.Adversary, applications.actor.AdversarySheet); @@ -73,6 +83,12 @@ Hooks.once('init', async () => { registerItemSheet(ItemType.Talent, applications.item.TalentItemSheet); registerItemSheet(ItemType.Equipment, applications.item.EquipmentItemSheet); registerItemSheet(ItemType.Weapon, applications.item.WeaponItemSheet); + registerItemSheet(ItemType.Goal, applications.item.GoalItemSheet); + registerItemSheet(ItemType.Power, applications.item.PowerItemSheet); + registerItemSheet( + ItemType.TalentTree, + applications.item.TalentTreeItemSheet, + ); CONFIG.Dice.types.push(dice.PlotDie); CONFIG.Dice.terms.p = dice.PlotDie; @@ -87,14 +103,25 @@ Hooks.once('init', async () => { // @ts-expect-error see note CONFIG.Dice.rolls.push(dice.DamageRoll); - // Load templates - await preloadHandlebarsTemplates(); - // Register status effects registerStatusEffects(); // Register settings - registerSettings(); + registerSystemSettings(); + registerSystemKeybindings(); + + // Load templates + await preloadHandlebarsTemplates(); +}); + +Hooks.once('setup', () => { + // Register some settings after modules have had a chance to initialize + registerDeferredSettings(); +}); + +Hooks.once('ready', () => { + // Chat message listeners + documents.CosmereChatMessage.activateListeners(); }); /** @@ -127,7 +154,7 @@ function registerActorSheet( type: ActorType, sheet: typeof foundry.applications.api.ApplicationV2, ) { - Actors.registerSheet('cosmere-rpg', sheet as any, { + Actors.registerSheet(SYSTEM_ID, sheet as any, { types: [type], makeDefault: true, label: `TYPES.Actor.${type}`, @@ -138,10 +165,112 @@ function registerItemSheet( type: ItemType, sheet: typeof foundry.applications.api.ApplicationV2, ) { - Items.registerSheet('cosmere-rpg', sheet as any, { + Items.registerSheet(SYSTEM_ID, sheet as any, { types: [type], makeDefault: true, label: `TYPES.Item.${type}`, }); } /* eslint-enable @typescript-eslint/no-explicit-any */ + +/** + * Configure additional system fonts. + */ +function configureFonts() { + Object.assign(CONFIG.fontDefinitions, { + Roboto: { + editor: true, + fonts: [ + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto/Roboto-Regular.woff2`, + ], + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto/Roboto-Bold.woff2`, + ], + weight: 'bold', + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto/Roboto-Italic.woff2`, + ], + style: 'italic', + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto/Roboto-BoldItalic.woff2`, + ], + weight: 'bold', + style: 'italic', + }, + ], + }, + 'Roboto Condensed': { + editor: true, + fonts: [ + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto-condensed/RobotoCondensed-Regular.woff2`, + ], + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto-condensed/RobotoCondensed-Bold.woff2`, + ], + weight: 'bold', + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto-condensed/RobotoCondensed-Italic.woff2`, + ], + style: 'italic', + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto-condensed/RobotoCondensed-BoldItalic.woff2`, + ], + weight: 'bold', + style: 'italic', + }, + ], + }, + 'Roboto Slab': { + editor: true, + fonts: [ + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto-slab/RobotoSlab-Regular.ttf`, + ], + }, + { + urls: [ + `systems/${SYSTEM_ID}/assets/fonts/roboto-slab/RobotoSlab-Bold.ttf`, + ], + weight: 'bold', + }, + ], + }, + 'Penumbra Web Pro': { + editor: true, + fonts: [ + { + urls: [ + `https://fonts.cdnfonts.com/s/56565/PenumbraWebPro-Serif.woff`, + ], + }, + ], + }, + 'Cosmere Dingbats': { + editor: true, + fonts: [ + { + urls: [ + `https://dl.dropboxusercontent.com/scl/fi/9909gen4fd0oveyzfposx/CosmereDingbats-Regular.otf?rlkey=ig6odq9hxyo1st8kt3ujp1czz&st=72qrads3&raw=1`, + ], + }, + ], + }, + }); +} diff --git a/src/lang/en.json b/src/lang/en.json index 38633f8d..335baeb7 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -127,6 +127,12 @@ "Deflect": "Deflect", "NoObscuredSense": "Unaffected by Obscured Senses." }, + "Deflect": { + "Natural": { + "Label": "Natural Deflect", + "Hint": "Natural deflect is used when no armor is worn, or when the natural deflect is higher than the worn armor." + } + }, "Size": { "Small": "Small", "Medium": "Medium", @@ -207,6 +213,7 @@ "ToggleShowSkills": "Show skills without ranks", "ToggleCollapseSkills": "Hide skills without ranks", "ConfigureDefense": "Configure Defense", + "ConfigureDeflect": "Configure Deflect", "ConfigureMovement": "Configure Movement Rate", "ConfigureSensesRange": "Configure Senses Range", "ConfigureRecovery": "Configure Recovery Die", @@ -249,7 +256,7 @@ } }, "Skills": { - "AdjustTooltip": "Left Click to increase [skill]'s Rank.
Right Click to decrease [skill]'s Rank." + "AdjustTooltip": "Left Click to increase {skill}'s Rank.
Right Click to decrease {skill}'s Rank." } } }, @@ -340,6 +347,21 @@ "label": "Connection", "label_plural": "Connections", "desc_placeholder": "Edit this to create a description of the NPC and how they are connected to the character." + }, + "Goal": { + "label": "Goal", + "label_plural": "Goals", + "desc_placeholder": "Edit this to describe the goal." + }, + "Power": { + "label": "Power", + "label_plural": "Powers", + "desc_placeholder": "Edit this to describe the power.", + "New": "New {type}" + }, + "TalentTree": { + "label": "Talent Tree", + "label_plural": "Talent Trees" } }, "Weapon": { @@ -380,7 +402,8 @@ "Trait": { "Cumbersome": "Cumbersome", "Dangerous": "Dangerous", - "Presentable": "Presentable" + "Presentable": "Presentable", + "Unique": "Unique" }, "Deflect": "Deflect" }, @@ -405,6 +428,99 @@ "Hint": "Identifier of the Ancenstry this action belongs to. (e.g. \"human\")" } }, + "Goal": { + "Level": { + "Label": "Progress" + }, + "Reward": { + "Type": { + "Items": "Items", + "SkillRanks": "Skill Ranks", + "Label": "Type" + }, + "Skill": { + "Label": "Skill" + }, + "Ranks": { + "Label": "Ranks" + }, + "Items": { + "Label": "Items" + }, + "Validation": { + "MissingSkillOrRanks": "Must set a Skill and Ranks", + "MissingItems": "Must set Items" + } + } + }, + "Power": { + "Identifier": { + "Hint": "Used to uniquely identify this power. Should be the same as the identifier of the associated skill, unless a custom skill is used." + }, + "CustomSkill": { + "Label": "Use custom skill?", + "Hint": "If checked, configure a custom skill instead of the skill matching this power's identifier." + }, + "Skill": { + "Label": "Skill", + "Hint": "The skill associated with this power. Gaining this power grants the character access to this skill." + }, + "Notification": { + "PowerExists": "A power with identifier \"{identifier}\" already exists on {actor}." + } + }, + "Path": { + "LinkedSkills": { + "Label": "Linked Skills", + "Hint": "These skills are displayed alongside the path in the character sheet." + } + }, + "Talent": { + "Type": { + "Ancestry": "Ancestry", + "Path": "Path", + "Power": "Power" + }, + "Prerequisite": { + "Type": { + "Talent": "Talent", + "Attribute": "Attribute", + "Skill": "Skill", + "Connection": "Connection", + "Level": "Level" + }, + "Mode": { + "AnyOf": "Any of", + "AllOf": "All of" + }, + "Level": { + "Label": "Level" + } + }, + "GrantRule": { + "Label": "On Obtain", + "Hint": "These rules are applied when the talent is obtained.", + "Type": { + "Items": "Grant Items", + "Label": "Type" + }, + "Items": { + "Label": "Items" + } + }, + "Power": { + "Label": "Power Identifier", + "Hint": "Identifier of the power this talent belongs to." + } + }, + "TalentTree": { + "Width": { + "Label": "Width" + }, + "Height": { + "Label": "Height" + } + }, "Activation": { "Type": { "Action": "Action", @@ -500,7 +616,7 @@ }, "Identifier": { "Label": "Identifier", - "Description": "Used to uniquely identify this [type]. Identifier can only contain letters (a-z), numbers (0-9), dashes (-), and underscores (_)." + "Hint": "Used to uniquely identify this {type}. Identifier can only contain letters (a-z), numbers (0-9), dashes (-), and underscores (_)." }, "Type": "Type", "Injury": { @@ -572,7 +688,12 @@ }, "Damage": { "Title": "Damage", - "Formula": "Damage Formula" + "Formula": "Damage Formula", + "GrazeOverride": "Override Graze Damage Calculation", + "GrazeShow": "Show New Graze Damage Formula", + "GrazeHide": "Hide New Graze Damage Forumla", + "GrazeFormula": "New Formula", + "GrazeHint": "Formula entered here will be the entirety of the calculation for graze damage. Any die rolls will be rolled separately from the main damage roll. You can pull in all or part of the main roll using @damage.total/@damage.unmodded/@damage.dice (the latter being the system default) or the selected attribute mod with @mod" }, "Modality": { "Title": "Modality", @@ -607,6 +728,19 @@ "ConnectionDescription": "Description", "ConnectionDescriptionPlaceholder": "Connection Description", "DropTalents": "Drop Talents here to add them to the list." + }, + "GrantRules": { + "Description": "Description", + "Create": "New rule", + "Edit": "Edit rule", + "Delete": "Remove rule" + } + }, + "Goal": { + "Reward": { + "Title": "Rewards", + "Create": "New Reward", + "SkillRanksDescriptionValue": "{ranks} ranks in {skill}" } } } @@ -632,24 +766,6 @@ } } }, - "Talent": { - "Type": { - "Ancestry": "Ancestry", - "Path": "Path" - }, - "Prerequisite": { - "Type": { - "Talent": "Talent", - "Attribute": "Attribute", - "Skill": "Skill", - "Connection": "Connection" - }, - "Mode": { - "AnyOf": "Any of", - "AllOf": "All of" - } - } - }, "Currencies": {}, "Combat": { "FastPlayers": "Fast Characters", @@ -737,19 +853,35 @@ "sur": "Survival" }, "ChatMessage": { - "Action": { - "Graze": "Graze: Reduce Focus", - "ApplyDamage": "Apply Damage", - "ApplyGraze": "Apply Graze Damage", - "ApplyHealing": "Apply Healing" + "Buttons": { + "DamageFull": "Click to apply full damage to selected token(s).", + "DamageHalf": "Click to apply half damage to selected token(s).", + "DamageDouble": "Click to apply double damage to selected token(s).", + "Healing": "Click to apply healing to selected token(s).", + "ReduceFocus": "Click to reduce focus from selected token(s).", + "RollCrit": "Click to roll critical damage.", + "RollAdvantage": "Click to roll with advantage.", + "RollDisadvantage": "Click to roll with disadvantage." + }, + "Trays": { + "Targets": "Targets" + }, + "InjuryRoll": "Injury Roll", + "InjuryDuration": { + "Dead": "{actor} has died.", + "Permanent": "{actor} is permanently injured.", + "Temporary": "{actor} is injured for {days} days." }, "ApplyDamage": "[actor] takes [amount] damage", "ApplyHealing": "[actor] recovers [amount] health", "UndoDamage": "Undo damage", "UndoHealing": "Undo healing", "ViewRollsDetails": "Roll Details", - "InjuryDuration": "Injury Duration.", - "Welcome": "

Welcome to the Cosmere RPG System!

Thank you for trying out the community-developed Cosmere Roleplaying Game system! You’re currently using version [version]. As this is an early release, some bugs or missing features are to be expected, but your feedback will help us improve the system.

If you encounter any issues or have suggestions, we’d love to hear from you! Please report them on our GitHub Issues page, and be sure to review the contribution guidelines if you’d like to get involved in development.

For discussions, updates, and support, join us on the Official Cosmere RPG Discord server. You can find us in vtt-discussion > Foundry VTT System Development.

We’re excited to have you on board and hope you enjoy playing in the Cosmere!

" + "Welcome": "

Welcome to the Cosmere RPG System!

Thank you for trying out the community-developed Cosmere Roleplaying Game system! You're currently using version [version]. As this is an early release, some bugs or missing features are to be expected, but your feedback will help us improve the system.

If you encounter any issues or have suggestions, we'd love to hear from you! Please report them on our GitHub Issues page, and be sure to review the contribution guidelines if you'd like to get involved in development.

For discussions, updates, and support, join us on the Official Cosmere RPG Discord server. You can find us in vtt-discussion > Foundry VTT System Development.

We're excited to have you on board and hope you enjoy playing in the Cosmere!

" + }, + "Theme": { + "Default": "Default", + "Stormlight": "Stormlight Archive" } }, "DICE": { @@ -768,7 +900,8 @@ "Damage": { "Label": "Damage", "Apply": "Apply", - "Graze": "Graze" + "Graze": "Graze", + "Full": "Full" } }, "ROLLS": { @@ -808,6 +941,9 @@ "ConfigureRecoveryDie": { "Title": "Configure Recovery Die: {actor}" }, + "ConfigureDeflect": { + "Title": "Configure Deflect: {actor}" + }, "EditTalentPrerequisite": { "Title": "Edit Prerequisite" }, @@ -827,8 +963,23 @@ "DuplicateLevel": "A rule for level {level} already exists" } }, + "EditGrantRule": { + "Title": "Edit Rule" + }, + "EditGoalReward": { + "Title": "Edit Reward" + }, "ReleaseNotes": { "Title": "Cosmere Roleplaying Game - Release Notes {version}" + }, + "ConfigureTalentTree": { + "Title": "Configure Talent Tree: {name}", + "Warning": { + "OutOfBounds": "The selected bounds would place at least one talent outside the tree." + }, + "Notification": { + "TalentPicked": "Added talent {talent} to {actor}" + } } }, "COMPONENT": { @@ -838,6 +989,17 @@ "WrongType": "The dropped Document must be of type {type}", "WrongSubtype": "The dropped {type} must be of type {subtype}" } + }, + "DocumentDropListComponent": { + "Placeholder": "Drop a {type} here to add it to the list", + "Warning": { + "DocumentAlreadyInList": "The dropped {type} is already in the list", + "WrongType": "The dropped Document must be of type {type}", + "WrongSubtype": "The dropped {type} must be of type {subtype}" + } + }, + "MultiValueSelect": { + "DefaultPlaceholder": "Select an option" } }, "GENERIC": { @@ -865,6 +1027,8 @@ "Name": "Name", "Level": "Level", "Add": "Add", + "Type": "Type", + "Description": "Description", "Button": { "Roll": "Roll", "Continue": "Continue", @@ -875,7 +1039,9 @@ "Update": "Update" }, "Notification": { - "GrazeFocusSpent": "Reduced focus by 1" + "GrazeFocusSpent": "Reduced focus by 1", + "AddedItem": "Added {type} \"{item}\" to {actor}", + "IncreasedSkillRank": "Increased {actor}'s {skill} rank by {amount}" }, "Warning": { "NotImplemented": "Sorry! [action] is not implemented yet", @@ -886,7 +1052,8 @@ "NoDuplicateExpertises": "Cannot create duplicate expertise", "NoFocus": "Not enough focus to perform this action", "TalentCannotBePrerequisiteOfItself": "A talent cannot be a prerequisite of itself", - "DuplicatePrerequisiteTalentRef": "A talent with id {talentId} already exists ({talentName})" + "DuplicatePrerequisiteTalentRef": "A talent with id {talentId} already exists ({talentName})", + "ItemAlreadyInTree": "An item with id \"{itemId}\" already exists in the {name} talent tree" }, "DerivedValue": { "Mode": { @@ -908,6 +1075,7 @@ "ancestry": "Ancestry", "path": "Path", "connection": "Connection", + "goal": "Goal", "injury": "Injury", "specialty": "Specialty", "loot": "Loot", @@ -916,12 +1084,57 @@ "action": "Action", "talent": "Talent", "equipment": "Equipment", - "weapon": "Weapon" + "weapon": "Weapon", + "power": "Power", + "talent_tree": "Talent Tree" } }, "UNITS": { "Distance": { "Feet": "Feet" } + }, + "SETTINGS": { + "itemSheetSideTabs": { + "name": "Vertical Side Tabs for Item Sheets", + "hint": "If enabled, item sheets use vertical tabs down the right-hand side, similar to the character sheet, instead of the default in-line horizontal ones." + }, + "skipRollDialogByDefault": { + "name": "Skip Roll Dialog by Default", + "hint": "If enabled, rolls will skip the roll configuration dialog when clicked, using the default roll configuration. The dialog can still be accessed by holding the 'Skip/Show Dialog' key modifier from the system controls." + }, + "enableOverlayButtons": { + "name": "Enable Overlay Buttons", + "hint": "When outputting a skill test or damage roll, enable overlay buttons on the chat message that offer additional functionality for the roll." + }, + "enableApplyButtons": { + "name": "Enable Apply Buttons", + "hint": "When outputting a damage roll, enable buttons on the chat message that allow for damage or healing to be applied to tokens, or to reduce focus from those tokens." + }, + "alwaysShowApplyButtons": { + "name": "Always Show Apply Buttons", + "hint": "When outputting a damage roll always show the apply buttons, instead of only on hover." + }, + "applyButtonsTo": { + "name": "Apply Button Options", + "hint": "Determines which tokens damage, healing, or focus reduction is applied to when applying via the chat card buttons.", + "choices": { + "SelectedOnly": "Apply to Selected Tokens", + "TargetedOnly": "Apply to Targeted Tokens", + "SelectedAndTargeted": "Apply to Both", + "PrioritiseSelected": "Prioritise Selected Tokens", + "PrioritiseTargeted": "Prioritise Targeted Tokens" + } + }, + "systemTheme": { + "name": "Preferred System Theme", + "hint": "Specify the themed set of colours to use for system documents and interface elements. The selected theme will respect the prefered Dark/Light mode selected in the core settings." + } + }, + "KEYBINDINGS": { + "skipDialogDefault": "Skip/Show Dialog", + "skipDialogAdvantage": "Skip Dialog with Advantage", + "skipDialogDisadvantage": "Skip Dialog with Disadvantage", + "skipDialogRaiseStakes": "Skip Dialog with Raise Stakes" } -} +} \ No newline at end of file diff --git a/src/style.scss b/src/style.scss index 99417c0a..d9885a30 100644 --- a/src/style.scss +++ b/src/style.scss @@ -4,14 +4,33 @@ /* Globals */ /* ----------------------------------------- */ :root { - --plotweaver-color-black: black; - --plotweaver-color-grey-1: #111; - --plotweaver-color-grey-2: #222; - --plotweaver-color-grey-3: #333; - --plotweaver-color-grey-4: #444; - --plotweaver-color-grey-5: #555; - --plotweaver-color-white: white; - --plotweaver-color-off-white: #f0f0e0; + --plotweaver-color-black: #000; + --plotweaver-color-dark-1: #111; + --plotweaver-color-dark-2: #222; + --plotweaver-color-dark-3: #333; + --plotweaver-color-dark-4: #444; + --plotweaver-color-dark-5: #555; + --plotweaver-color-dark-6: #666; + --plotweaver-color-dark-7: #777; + --plotweaver-color-light-1: #888; + --plotweaver-color-light-2: #999; + --plotweaver-color-light-3: #aaa; + --plotweaver-color-light-4: #bbb; + --plotweaver-color-light-5: #ccc; + --plotweaver-color-light-6: #ddd; + --plotweaver-color-light-7: #eee; + --plotweaver-color-white: #fff; + + --plotweaver-color-off-white-1: #f8f4f1; + --plotweaver-color-off-white-2: #f0f0e0; + + --plotweaver-color-complication: #ab3d38; + --plotweaver-color-complication-text: #ab3d38; + --plotweaver-color-complication-background: #ffdddd; + --plotweaver-color-opportunity: #1e3c60; + --plotweaver-color-opportunity-text: #7fa4e9; + --plotweaver-color-opportunity-background: #dde5ff; + --plotweaver-color-parchment: #f1ebe8; --plotweaver-color-health-front: #286f3e; --plotweaver-color-health-back: #6b1b2a; @@ -20,41 +39,83 @@ --plotweaver-color-invest-front: #3e6abb; --plotweaver-color-invest-back: #182845; - // --font-primary: 'Laski Sans'; - // --font-h5: 'Laski-Sans'; + --plotweaver-background-25: rgb(0 0 0 / 25%); + --plotweaver-background-10: rgb(0 0 0 / 10%); + --plotweaver-background-5: rgb(0 0 0 / 5%); + + --plotweaver-shadow-15: rgba(0, 0, 0, 0.15); + --plotweaver-shadow-45: rgba(0, 0, 0, 0.45); + --plotweaver-shadow-85: rgba(0, 0, 0, 0.85); + + --plotweaver-shadow-svg: drop-shadow(0 0 4px rgba(255, 0, 0, 0.6)); + + --plotweaver-font-normal: "Roboto", sans-serif; + --plotweaver-font-header: "Roboto Slab", serif; + --plotweaver-font-condensed: "Roboto Condensed", sans-serif; + --plotweaver-font-icons: "Cosmere Dingbats"; /* ----------------------------------------- */ /* Default Generic Theme */ /* ----------------------------------------- */ - &.theme-plotweaver { + .cosmere-theme-default { //TO-DO: Replace with Plotweaver colours - --plotweaver-color-accent: #c9a760; - --plotweaver-color-faded: #5e6c7e; - --plotweaver-color-sheet: #132137; - - --plotweaver-color-base-1: #010e2d; - --plotweaver-color-base-2: #10213e; - --plotweaver-color-base-3: #1b2d49; - --plotweaver-color-base-4: #263c5e; - --plotweaver-color-base-5: #456a95; - --plotweaver-color-base-6: #506684; + &.theme-dark { + --plotweaver-color-accent: #c9a760; + --plotweaver-color-faded: #5e6c7e; + --plotweaver-color-sheet: #132137; + + --plotweaver-color-base-1: #010e2d; + --plotweaver-color-base-2: #10213e; + --plotweaver-color-base-3: #1b2d49; + --plotweaver-color-base-4: #263c5e; + --plotweaver-color-base-5: #456a95; + --plotweaver-color-base-6: #506684; + } + + &.theme-light { + --plotweaver-color-accent: #fff; + --plotweaver-color-faded: #fff; + --plotweaver-color-sheet: #fff; + + --plotweaver-color-base-1: #fff; + --plotweaver-color-base-2: #fff; + --plotweaver-color-base-3: #fff; + --plotweaver-color-base-4: #fff; + --plotweaver-color-base-5: #fff; + --plotweaver-color-base-6: #fff; + } } /* ----------------------------------------- */ /* Stormlight Archive Theme */ /* ----------------------------------------- */ - &.theme-stormlight { + .cosmere-theme-stormlight { //TO-DO: Move to Stormlight Archive module - --plotweaver-color-accent: #c9a760; - --plotweaver-color-faded: #5e6c7e; - --plotweaver-color-sheet: #132137; - - --plotweaver-color-base-1: #010e2d; - --plotweaver-color-base-2: #10213e; - --plotweaver-color-base-3: #1b2d49; - --plotweaver-color-base-4: #263c5e; - --plotweaver-color-base-5: #456a95; - --plotweaver-color-base-6: #506684; + &.theme-dark { + --plotweaver-color-accent: #c9a760; + --plotweaver-color-faded: #5e6c7e; + --plotweaver-color-sheet: #132137; + + --plotweaver-color-base-1: #010e2d; + --plotweaver-color-base-2: #10213e; + --plotweaver-color-base-3: #1b2d49; + --plotweaver-color-base-4: #263c5e; + --plotweaver-color-base-5: #456a95; + --plotweaver-color-base-6: #506684; + } + + &.theme-light { + --plotweaver-color-accent: #ff0000; + --plotweaver-color-faded: #fff; + --plotweaver-color-sheet: #fff; + + --plotweaver-color-base-1: #fff; + --plotweaver-color-base-2: #fff; + --plotweaver-color-base-3: #fff; + --plotweaver-color-base-4: #fff; + --plotweaver-color-base-5: #fff; + --plotweaver-color-base-6: #fff; + } } } @@ -62,18 +123,7 @@ /* Fonts */ /* ----------------------------------------- */ @import url('https://fonts.googleapis.com/css2?family=Didact+Gothic&display=swap'); - -@font-face { - font-family: 'Penumbra Web Pro'; - font-style: normal; - font-weight: 400; - src: url('https://fonts.cdnfonts.com/css/penumbra-web-pro') -} - -@font-face { - font-family: 'cosmere-dingbats'; - src: url('https://dl.dropboxusercontent.com/scl/fi/9909gen4fd0oveyzfposx/CosmereDingbats-Regular.otf?rlkey=ig6odq9hxyo1st8kt3ujp1czz&st=72qrads3&raw=1'); -} +@import url('https://fonts.cdnfonts.com/css/penumbra-web-pro'); .application h1, span.document-name { @@ -101,18 +151,26 @@ span.document-name { span.cosmere-icon, em.cosmere-icon, i.cosmere-icon { - font-family: 'cosmere-dingbats'; + font-family: var(--plotweaver-font-icons) !important; + + &.complication-color { + color: var(--plotweaver-color-complication-text); + } &.complication { - color: red; + color: var(--plotweaver-color-complication); &::after { content: 'C'; } } + &.opportunity-color { + color: var(--plotweaver-color-opportunity-text); + } + &.opportunity { - color: #98a4f5; + color: var(--plotweaver-color-opportunity); &::after { content: 'O'; diff --git a/src/style/chat/module.scss b/src/style/chat/module.scss index be44ccd2..2a009cd4 100644 --- a/src/style/chat/module.scss +++ b/src/style/chat/module.scss @@ -1,436 +1,641 @@ -.chat-message { +:is(.chat-popout, #chat-log, .chat-log) .chat-message { + padding: 0.5rem; + border-width: 1px; + border-block-end-width: 2px; + border-radius: 6px; + font-family: var(--plotweaver-font-normal); + font-size: var(--font-size-13); + position: relative; + background: var(--plotweaver-color-parchment); + + .overlay { + text-align: center !important; + flex-grow: 1; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0.125rem; + padding: 0 0.375rem; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 1; + + div { + width: 22px; + height: 22px; + padding: 0; + + img { + border: 0; + + &:hover { + cursor: pointer; + filter: var(--plotweaver-shadow-svg); + } + } + } + } + .message-header { margin-bottom: 0.5rem; + position: relative; + .message-delete { + display: none; + } + .message-sender { display: flex; - align-items: center; + align-items: start; + gap: 0.75rem; + white-space: unset; + .avatar { + height: 100%; + display: grid; + place-content: center; + } + img { - width: 2.75rem; - box-shadow: 0 0 0.35rem 0 black; - border-radius: 50%; + width: 38px; + height: 38px; + border-radius: 100%; + box-shadow: 0 0 6px var(--plotweaver-shadow-85); + object-fit: cover; + object-position: top; border: none; + flex: none; + background: var(--plotweaver-color-dark-3); } + } - .name { - display: flex; - flex-direction: column; - padding-left: 1rem; + .name-stacked { + flex: 1; + display: flex; + min-height: 32px; + flex-direction: column; + justify-content: center; + line-height: normal; + } + + .title { + font-family: var(--plotweaver-font-header); + font-size: var(--font-size-16); + font-weight: bold; + color: var(--plotweaver-color-dark-3); + } - .title { - font-weight: bold; - font-size: 12pt; - } + .subtitle { + font-size: var(--font-size-11); + color: var(--plotweaver-color-dark-6); + } - .subtitle { - font-size: 9pt; - margin-top: -0.2rem; - } + .message-metadata { + font-size: var(--font-size-10); + transform: translate(2px, -4px); + flex: none; + + time { + color: var(--plotweaver-color-dark-6); } } } - .roll { - .die-result.plot { - width: 2.5rem; - } - } + .message-content { + position: relative; - .chat-card { - display: flex; - flex-direction: column; + .chat-card { + display: flex; + flex-direction: column; + gap: 0.375rem; + + .chat-card-section { + padding: 0.5rem; + border: 1px solid var(--plotweaver-color-light-1); + border-radius: 3px; + background: var(--plotweaver-color-off-white-1); + overflow: hidden; - &.activity { - .header { - cursor: pointer; - background: #f7efe6; - padding: 0.3rem; - border-radius: 0.2rem; - border: 1px solid gray; - - hr { - border: none; - border-bottom: 1px solid currentColor; - opacity: 0.2; - margin: 0.3rem 0.25rem 0 0.25rem; + &.critical { + border: 3px double var(--plotweaver-color-opportunity); } - .summary { - display: flex; - flex-direction: row; - align-items: center; - - .icon { - width: 2.25rem; - height: 2.25rem; - } - - .name.stacked { - flex: 1; + &.description { + .summary { display: flex; - flex-direction: column; - margin-left: 0.5rem; - justify-content: center; - - .title { - font-weight: bold; - font-size: 13pt; + align-items: center; + gap: 0.5rem; + + & > img { + width: 32px; + height: 32px; + border: 2px solid var(--plotweaver-color-dark-3); + box-shadow: 0 0 4px var(--plotweaver-shadow-45); + border-radius: 0; + background-color: var(--plotweaver-color-dark-6); + object-fit: cover; + object-position: top; + } - .cosmere-icon { - font-size: 10pt; - margin-left: 0.25rem; - } + & > .cosmere-icon { + font-size: var(--font-size-16); + color: var(--plotweaver-color-opportunity); + padding-right: 0.25rem; } - .subtitle { - font-size: 8pt; - opacity: 0.75; + .name-stacked { + padding-right: 0.5rem; + flex: 1; + display: flex; + flex-direction: column; + + .title { + font-family: var(--plotweaver-font-header); + font-size: var(--font-size-14); + font-weight: bold; + color: var(--plotweaver-color-dark-1); + } + + .subtitle { + font-size: var(--font-size-10); + color: var(--plotweaver-color-dark-6); + } + + .traits { + font-size: var(--font-size-10); + color: var(--plotweaver-color-dark-6); + + strong { + color: var(--plotweaver-color-opportunity); + font-weight: 600; + } + } } } - .controls { - display: flex; - align-items: center; - padding-right: 0.1rem; + .details { + font-family: var(--plotweaver-font-normal); + font-size: var(--font-size-11); + padding: 0; - .delim { - border-right: 1px solid currentColor; - height: 1.6rem; - margin: 0 0.2rem; - opacity: 0.3; + & > .wrapper { + display: flex; + flex-direction: column; + gap: 0.25rem; + overflow: hidden; } - i { - width: 1rem; - text-align: center; - opacity: 0.5; - transition: 0.3s; + p { + margin: 0; + + &:first-child { + padding-top: 0.5rem; + } } } } - .description { - transition: 0.9s ease-in-out; - overflow: hidden; - text-overflow: ellipsis; - } + &.injury { + .summary { + .name-stacked { + .title { + font-size: var(--font-size-13); - &.collapsed { - hr { - display: none; + strong { + color: var(--plotweaver-color-complication); + } + } + } } + } - .description { - max-height: 0; + .section-header { + margin-bottom: 0.25rem; + font-family: var(--plotweaver-font-normal); + display: flex; + justify-content: space-between; + align-items: flex-start; + + .title { + font-weight: bold; + font-size: var(--font-size-13); + text-transform: uppercase; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0.25rem; + margin-bottom: 0.1rem; } - } - &:not(.collapsed) { - .description { - max-height: 1040px; + .subtitle { + font-size: var(--font-size-12); + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.25rem; + font-style: italic; + + .skill { + font-weight: 600; + color: var(--plotweaver-color-opportunity); + } + + .attribute { + color: var(--plotweaver-color-light-2); + text-transform: uppercase; + } } - .controls i { - transform: rotate(-90deg); + .types { + font-size: var(--font-size-10); + font-family: var(--plotweaver-font-condensed); + color: var(--plotweaver-color-dark-6) !important; + font-weight: 600; + text-transform: uppercase; } - } - } + } - .flavor { - margin-top: 0.5rem; - font-style: italic; - opacity: 0.9; - } - } + .dice-roll { + .dice-flavor { + display: none; + } - &.skill { - } - } + .dice-result { + .dice-roll-injury, + .dice-roll-d20 { + height: 32px; + } - .card-rolls { - &.damage { - margin-top: 1rem; - } + .dice-roll-damage { + height: 36px; + } - .title { - font-weight: bold; - } + .dice-roll-d20, + .dice-roll-damage { + display: flex; + } - .dice-formula { - background: rgba(0, 0, 0, 0.1); - padding: 0.3rem; - border-radius: 0.2rem; - border: 1px solid gray; - margin-top: 0.5rem; - color: #1f1f1f; - text-align: center; - font-size: 10pt; - } + .dice-tooltip { + flex: auto; + order: unset; + display: block; + overflow: hidden; + + .tooltip-part { + border-bottom: 1px solid var(--color-border-light-1); + padding: 0.125rem 0; + + &.constant { + padding-right: 6px; + min-height: 40px; + display: grid; + } + + &:last-child { + border: none; + padding-bottom: 0; + } + } - .dice-details { - display: none; - } + .dice { + display: flex; + align-items: center; + + .dice-rolls { + flex: 1; + margin: 5px 0 5px 10px; + display: flex; + flex-wrap: wrap; + gap: 1px; + align-items: center; + + .roll { + float: unset; + margin: 0; + + &.success, &.max { + color: var(--plotweaver-color-opportunity); + filter: sepia(1) hue-rotate(180deg); + } + + &.failure, &.min { + color: var(--plotweaver-color-complication); + filter: sepia(0.8) hue-rotate(-50deg); + } + + &.rerolled, &.discarded { + color: inherit; + filter: sepia(0.5) contrast(0.75) opacity(0.4); + } + + &.plotdie > img { + width:24px; + height:24px; + border: 0; + border-radius: 0; + } + } + + .constant { + font-family: var(--plotweaver-font-normal); + font-weight: bold; + font-size: var(--font-size-16); + margin-left: 0.25rem; + + .sign { + color: var(--plotweaver-color-light-2); + font-weight: normal; + margin-right: 2px; + } + } + } + + .total { + flex-basis: 25%; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + + .label { + font-family: var(--plotweaver-font-condensed); + font-size: var(--font-size-10); + color: var(--plotweaver-color-dark-6); + text-transform: uppercase; + text-align: center; + } + + .value { + font-family: var(--plotweaver-font-normal); + color: var(--plotweaver-color-dark-1); + font-weight: bold; + font-size: var(--font-size-20); + } + + .sign { + color: var(--plotweaver-color-light-2); + font-weight: normal; + margin-right: 2px; + } + } + } + } - .dice-total { - cursor: pointer; - background: rgba(0, 0, 0, 0.1); - border-radius: 0.2rem; - border: 1px solid gray; - margin-top: 0.5rem; + .dice-formula, .dice-total { + border-radius: 3px; + background: var(--plotweaver-background-5); + padding: 0.25rem; + line-height: normal; + flex: auto; + margin: 0; + width: 100%; + } - hr { - border: none; - border-bottom: 1px solid currentColor; - opacity: 0.2; - margin: 0 0.25rem; - } + .dice-formula.graze::before { + font-family: var(--plotweaver-font-normal); + font-size: var(--font-size-10); + font-weight: 600; + color: var(--plotweaver-color-light-2); + + text-align: left !important; + flex-grow: 1; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + padding: 0 0.375rem; + display: flex; + justify-content: flex-start; + align-items: center; + z-index: 1; + + content: 'GRAZE'; + } - .mod { - opacity: 0.5; - } + .dice-total { + font-weight: bold; + font-size: var(--font-size-24); + padding: 0; + font-family: var(--plotweaver-font-normal); + position: relative; - .controls { - opacity: 0.5; - position: absolute; - right: 0.5rem; - font-size: 10pt; - height: 100%; - top: 0; - display: flex; - align-items: center; - - .delim { - border-right: 1px solid currentColor; - height: 1rem; - margin: 0 0.2rem; - } + &.ignored { + opacity: .4; + } - i { - width: 1rem; - transition: 0.3s; - } + &.opportunity { + color: var(--plotweaver-color-opportunity); + background: var(--plotweaver-color-opportunity-background); + border-color: var(--plotweaver-color-opportunity); + } - .damage-type { - margin-right: 0.5rem; - } - } + &.complication { + color: var(--plotweaver-color-complication); + background: var(--plotweaver-color-complication-background); + border-color: var(--plotweaver-color-complication); + } + } - .total-value { - position: relative; - color: #1f1f1f; - text-align: center; - font-size: 15pt; - font-weight: bold; - padding: 0.3rem; - border-radius: 0.2rem; - - .cosmere-icon { - &.opportunity { - color: #235bad; - } + .dice-roll-damage .dice-total { + display: flex; + justify-content: space-between; + align-items: center; + } - &.complication { - color: #ad2323; - } - } + .dice-subtotal { + padding: 0 0.5rem; + font-weight: 600; + display: flex; + flex-direction: column; + width: 20%; + + .value { + color: var(--plotweaver-color-dark-6); + font-size: var(--font-size-12); + font-family: var(--plotweaver-font-normal); + } - &.opportunity { - background: #c3d1e0; - border-color: #686f85; - color: #235bad; - } + .label { + color: var(--plotweaver-color-light-1); + font-size: var(--font-size-10); + font-family: var(--plotweaver-font-condensed); + text-transform: uppercase; + } - &.complication { - background: #e0c3c3; - border-color: #856868; - color: #ad2323; - } + &.right { + border-left: 1px solid var(--plotweaver-color-light-4); + } - &.opportunity.complication { - background: #d6c3e0; - border-color: #7f6885; - color: #1f1f1f; + &.left { + border-right: 1px solid var(--plotweaver-color-light-4); + } + } + } } - } + } - .roll-breakdown { - transition: 0.3s; - overflow: hidden; - height: 2.3rem; - - ul { + .chat-card-tray { + & > label { display: flex; - padding: 0.3rem 0.25rem; - margin: 0; + justify-content: center; + align-items: center; + gap: 0.25rem; + font-size: var(--font-size-11); + font-family: var(--plotweaver-font-normal); + font-weight: bold; + text-transform: uppercase; - .delim { - margin-right: .5rem; + & > span { + flex: none; } - } - } - &.collapsed { - .roll-breakdown { - height: 0; + & > i:first-of-type { + color: var(--plotweaver-color-light-2); + } } - > hr { - display: none; + & > label::before, + & > label::after { + content: ""; + flex-basis: 50%; + border-top: 1px dotted var(--plotweaver-color-dark-6); + align-self: center; } - } - &:not(.collapsed) { - .controls { - i { - transform: rotate(-90deg); + .target-headers { + color: var(--plotweaver-color-light-2); + font-size: var(--font-size-10); + font-family: var(--plotweaver-font-condensed); + font-weight: 600; + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: flex-end; + margin-top: 0.25rem; + + & > span { + width: 15%; + text-align: center; } } - } - } - .buttons-container { - margin-top: 0.5rem; - - a[role='button'] { - display: flex; - align-items: center; - justify-content: center; - padding: 0.3rem; - background: rgba(255, 255, 255, 0.4); - font-weight: bold; - font-size: 10pt; - border-radius: 0.3rem; - margin: 0 0.2rem; - border: 1px solid gray; - } - } + .target-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + list-style: none; + padding: 0; + margin: 0.25rem 0; - .die.roll { - position: relative; - width: 1.5rem; - height: 1.7rem; - display: flex; - align-items: center; - justify-content: center; + .target { + display: flex; + align-items: center; + cursor: pointer; + font-size: var(--font-size-13); + font-family: var(--plotweaver-font-normal); + font-weight: bold; + + .name { + color: var(--plotweaver-color-dark-1); + width: 55%; + } - &:not(:first-child) { - margin-left: 0.25rem; - } + .result { + color: var(--plotweaver-color-dark-4); + width: 15%; + text-align: center; - > span { - z-index: 1; - color: white; - font-weight: bold; - font-size: 15pt; - text-shadow: 0 0 0.2rem black; + .success { + color: var(--plotweaver-color-health-front) + } - .cosmere-icon { - color: white; - display: flex; - margin-top: 0.2rem; + .failure { + color: var(--plotweaver-color-complication) + } + } + } } } - &.p:has(i.complication) { - > span { - display: flex; - font-size: 8pt; - align-items: center; + .collapsible { + cursor: pointer; + + .collapsible-content { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 250ms ease; - > span { - font-size: 10pt; - margin-right: 0.1rem; + & > .wrapper { + overflow: hidden; } } - } - &.discarded { - opacity: 0.3; - } - - &::after { - content: ''; - background-size: 1.5rem; - position: absolute; - width: 100%; - height: 100%; - left: 0; - top: 0; - opacity: 0.6; - z-index: 0; - background-repeat: no-repeat; - background-position: center; - } - - &.d4::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d4.svg); - } + .fa-caret-down { + transform: rotate(-90deg); + transition: all 250ms ease; + } - &.d6::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d6.svg); + &.expanded .fa-caret-down { + transform: rotate(0deg); + } + + &.expanded .collapsible-content { + grid-template-rows: 1fr; + } } + } + } - &.d8::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d8.svg); - } + .apply-buttons { + justify-content: space-evenly; + align-items: center; + padding-right: 0; + padding-top: 5px; + text-align: right !important; + display: flex; + font-family: var(--plotweaver-font-normal); + position: static; + + button { + width: 44px; + height: 22px; + font-size: var(--font-size-14); + font-weight: 600; + line-height: 22px; + padding: 0; + gap: 0; + display: flex; + align-items: center; + justify-content: space-evenly; + padding: 0.125rem; - &.d10::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d10.svg); + & > i { + margin: 0; } - &.d12::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d12.svg); + &.damage { + color: var(--plotweaver-color-complication); } - &.d20::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d20.svg); + &.healing { + color: var(--plotweaver-color-health-front); } - &.p::after { - background-image: url(/systems/cosmere-rpg/assets/icons/svg/dice/d6.svg); + &.focus { + color: var(--plotweaver-color-focus-front); } } } - .damage-notification { - display: flex; - align-items: center; - } - - .action { - display: flex; - align-items: center; - justify-content: center; - padding: 0.2rem; - background: rgba(255, 255, 255, 0.4); - font-weight: bold; - border-radius: 0.3rem; - margin: 0 0.2rem; - border: 1px solid gray; - font-size: 10pt; - } - - .card-actions { - display: flex; - align-items: center; - - .action { - padding: 0.3rem; - width: 2rem; - } - - .delim { - height: 1.25rem; - margin: 0 0.2rem; - border-left: 1px solid var(--color-border-light-primary); - border-right: 1px solid var(--color-border-light-highlight); - } - } + +} - .message-content i.deflect { - font-size: 9pt; - margin-right: 0.1rem; - opacity: 0.75; +/* Modifier Keys */ +:is(.chat-popout, #chat-log, .chat-log)[data-modifier-shift] { + .chat-message .message-header .message-delete { + display: unset; } -} +} \ No newline at end of file diff --git a/src/style/components.scss b/src/style/components.scss index 416ce0ed..2a766b51 100644 --- a/src/style/components.scss +++ b/src/style/components.scss @@ -78,3 +78,67 @@ app-document-reference-input { font-style: italic; } } + +app-multi-value-select { + display: flex; + + .values { + flex: 1; + display: flex; + flex-wrap: wrap; + + .value { + --background: rgba(0, 0, 0, 0.1); + + display: flex; + align-items: center; + background: var(--background); + padding: 1px 4px; + border: 1px solid var(--color-border-dark-tertiary); + border-radius: 2px; + white-space: nowrap; + margin: 0.12rem; + + .controls { + margin-left: 0.25rem; + } + } + } +} + +app-document-drop-list { + --input-focus-outline-color: var(--color-cool-3); + + display: block; + border: 1px dashed var(--color-dark-4); + padding: 0 0.5rem; + border-radius: 0.3rem; + outline: 1px solid transparent; + transition: outline-color 0.5s; + + ul { + .document { + display: flex; + align-items: center; + + .link { + flex: 1; + display: flex; + justify-content: center; + } + } + } + + p { + text-align: center; + font-size: 10pt; + font-style: italic; + color: var(--color-dark-4); + text-shadow: 0 0 0.2rem black; + } + + &.dragover { + outline: 2px solid var(--input-focus-outline-color); + box-shadow: 0 0 5px var(--color-shadow-primary); + } +} \ No newline at end of file diff --git a/src/style/module.scss b/src/style/module.scss index 218f3120..d09b092a 100644 --- a/src/style/module.scss +++ b/src/style/module.scss @@ -3,3 +3,45 @@ @import './style/sidebar/combat.scss'; @import './style/dialog.scss'; @import './style/components.scss'; + +.application { + p.notes, + p.hint { + color: #efe6d8bf; + } + + .controls-dropdown.free { + border-radius: 4px; + } +} + +#tooltip .talenttip { + .header { + display: flex; + align-items: center; + justify-content: center; + position: relative; + + height: 3rem; + + h4 { + font-size: 2rem; + font-weight: bold; + margin: 0; + text-shadow: 0 0 0.5rem black; + } + + .bg-image { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + filter: blur(5px); + z-index: -1; + } + } +} \ No newline at end of file diff --git a/src/style/sheets/actor/character.scss b/src/style/sheets/actor/character.scss index 4fae5c3d..2582aba7 100644 --- a/src/style/sheets/actor/character.scss +++ b/src/style/sheets/actor/character.scss @@ -47,6 +47,7 @@ background: #302831; padding: 0.3rem 0.5rem; min-height: 5rem; + font-family: inherit; &:read-only { background: #0b0a13; @@ -165,10 +166,9 @@ position: relative; border-radius: 0.3rem; margin-bottom: 1rem; + display: flex; - flex-direction: row; - align-items: center; - padding: 0.2rem 1rem; + flex-direction: column; border: 1px solid #463a47; overflow: hidden; @@ -176,40 +176,61 @@ text-shadow: 0 0 0.5rem black; box-shadow: 0 0 0.5rem black; - cursor: pointer; - - .name { + .details { display: flex; - flex-direction: column; - flex: 1; + flex-direction: row; + align-items: center; + padding: 0.2rem 1rem 0 1rem; z-index: 1; + cursor: pointer; - .label { - font-size: 13pt; + .name { + display: flex; + flex-direction: column; + flex: 1; + z-index: 1; + + .label { + font-size: 13pt; + font-weight: bold; + } + + .item-type { + font-size: 9pt; + opacity: 0.75; + } + } + + .level { + font-size: 14pt; font-weight: bold; + z-index: 1; } - - .item-type { - font-size: 9pt; - opacity: 0.75; + + .name, + .level { + &:hover { + text-shadow: 0 0 8px var(--color-shadow-primary); + } + } + + .controls { + margin-left: 1rem; + z-index: 1; } - } - - .level { - font-size: 14pt; - font-weight: bold; - z-index: 1; - } - .name, - .level { - &:hover { - text-shadow: 0 0 8px var(--color-shadow-primary); + &:not(:has(+ .skill-list)) { + padding-bottom: 0.2rem; } } - .controls { - margin-left: 1rem; + .skill-list { + list-style: none; + margin: 0; + padding: 0; + padding-top: 0.8rem; + padding-bottom: 0.3rem; + background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, .4) 40%); z-index: 1; } @@ -217,8 +238,9 @@ position: absolute; left: 0; right: 0; - opacity: 0.25; + opacity: 0.2; top: -6rem; + z-index: 0; } &.placeholder { @@ -258,6 +280,10 @@ &:not(.collapsed):not(:first-child):not(:last-child) { border-bottom: 1px solid #302831; + height:auto; + min-height: 1.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; } &:not(.details) { @@ -367,6 +393,10 @@ .goal { &:not(:first-child):not(:last-child) { border-bottom: 1px solid #463a47 !important; + height: auto; + min-height: 1.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; } } } @@ -375,6 +405,10 @@ .connection { &:nth-child(even):not(:last-child) { border-bottom: 1px solid #463a47 !important; + height: auto; + min-height: 1.5rem; + padding-top: 0.25rem; + padding-bottom: 0.25rem; } } } diff --git a/src/style/sheets/actor/module.scss b/src/style/sheets/actor/module.scss index 45dd1dd0..1a9ed5e7 100644 --- a/src/style/sheets/actor/module.scss +++ b/src/style/sheets/actor/module.scss @@ -164,6 +164,13 @@ } } + .value { + text-align: center; + font-size: 18pt; + font-weight: bold; + margin: 0.25ch; + } + /* --- Tabs --- */ .tab[data-tab] { @@ -269,6 +276,7 @@ } .deflect { + position: relative; background: rgb(11 10 19 / 100%); width: 2rem; display: flex; @@ -281,6 +289,13 @@ span { margin: -0.1rem 0 0.3rem 0 !important } + + a[data-action="configure-deflect"] { + position: absolute; + font-size: 6pt; + left: 1.2rem; + top: 0; + } } } @@ -444,10 +459,15 @@ } } + &:first-child, &:last-child { + .value input { + font-weight: bold; + } + } + &:first-child { .value input { border-bottom-left-radius: 1rem; - font-weight: bold; } } @@ -925,64 +945,77 @@ flex-direction: column; margin: 0; padding: 0; + } + } - .skill { - display: flex; - flex-direction: row; - align-items: center; + app-adversary-skills-group { + font-size: 9.6pt; + } - &:hover { - background-color: rgba(0, 0, 0, 0.25); - } + app-actor-skill { + display: flex; + flex-direction: row; + align-items: center; + text-shadow: 0 0 0.2rem black; - [data-action] { - cursor: pointer; - } + &:hover { + text-shadow: 0 0 8px var(--color-shadow-primary); + } - .mod { - width: 2rem; - text-align: center; - margin: 0 0.3rem; - } + [data-action] { + cursor: pointer; + } - .name { - flex: 1; - } + .mod { + width: 2rem; + text-align: center; + margin: 0 0.3rem; - .attribute { - width: 3rem; - text-align: center; - margin: 0 0.3rem; - } + .operator { + opacity: .5; + } - .pip-list { - display: flex; - flex-direction: row; - list-style-type: none; - padding: 0; - margin: 0 0.3rem 0 0; - - .pip { - > div { - width: 0.65rem; - height: 0.65rem; - border: 1px solid; - border-radius: 50%; - margin: 0.1rem; - } + .val { + font-weight: bold; + } + } - &.active { - > div { - background-color: white; - } - } + .name { + flex: 1; + } + + .attribute { + width: 3rem; + text-align: center; + margin: 0 0.3rem; + } + + .pip-list { + display: flex; + flex-direction: row; + list-style-type: none; + padding: 0; + margin: 0 0.3rem 0 0; + + .pip { + > div { + width: 0.65rem; + height: 0.65rem; + border: 1px solid var(--color-dark-6); + border-radius: 50%; + margin: 0.1rem; + } + + &.active { + > div { + background-color: white; } } + + &:not(.active) > div { + opacity: .5; + } } } } - - app-adversary-skills-group { - font-size: 9.6pt; - } } diff --git a/src/style/sheets/item/module.scss b/src/style/sheets/item/module.scss index 18052765..66e9c114 100644 --- a/src/style/sheets/item/module.scss +++ b/src/style/sheets/item/module.scss @@ -1,3 +1,5 @@ +@import './talent-tree.scss'; + .sheet.item { max-height: 800px; @@ -110,11 +112,6 @@ flex-direction: column; } - p.notes, - p.hint { - color: #efe6d8bf; - } - fieldset:not(:first-child) { margin-top: 0.5rem; } @@ -304,6 +301,24 @@ app-item-properties { } } +app-item-details-damage { + .form-group-stacked { + .header { + display: flex; + align-items: center; + + label { + font-weight: bold; + flex: 2; + } + + .controls { + margin-right: 0.5rem; + } + } + } +} + app-item-details-equip { .form-group-stacked { .header { @@ -490,3 +505,23 @@ app-talent-prerequisite-talent-list { text-shadow: 0 0 0.2rem black; } } + +app-goal-rewards-list { + .col.type { + width: 5rem; + } + + .col.description { + flex: 1; + } +} + +app-talent-grant-rules-list { + .col.type { + width: 5rem; + } + + .col.description { + flex: 1; + } +} \ No newline at end of file diff --git a/src/style/sheets/item/talent-tree.scss b/src/style/sheets/item/talent-tree.scss new file mode 100644 index 00000000..32aee979 --- /dev/null +++ b/src/style/sheets/item/talent-tree.scss @@ -0,0 +1,164 @@ +.sheet.item.talent-tree { + .window-header { + .actor-select { + display: flex; + align-items: center; + + select { + margin-left: 1rem; + height: unset; + } + } + } + + .window-content { + padding: 10px + } + + .container { + flex: 1; + display: flex; + position: relative; + + .grid { + display: grid; + flex: 1; + pointer-events: none; + + .cell { + display: flex; + align-items: stretch; + justify-content: stretch; + aspect-ratio: 1; + + .slot { + flex: 1; + margin: 10px; + + border-radius: 0.3rem; + overflow: hidden; + z-index: 1; + + border-width: 1px; + border-style: solid; + border-color: var(--color-light-1); + + outline: 1px solid transparent; + transition: outline-color 0.5s; + + * { + pointer-events: none; + } + + &.empty { + border-style: dotted; + box-shadow: inset 0 0 0.75rem rgba(0, 0, 0, 0.6); + } + + &:not(.empty) { + pointer-events: all; + } + + &.dragover { + outline: 2px solid var(--color-cool-3); + box-shadow: 0 0 5px var(--color-shadow-primary); + } + } + } + } + + .connections { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 0; + + pointer-events: none; + + .connection { + position: absolute; + background: white; + height: 0.3rem; + transform-origin: left; + + display: flex; + align-items: center; + justify-content: center; + + color: #9c0000; + font-size: 1rem; + text-shadow: 0.1rem 0.1rem 0.5rem BLACK; + + &.obtained, &.available { + z-index: 1; + } + } + } + } + + &.mode-edit { + .slot.context { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + box-shadow: inset 0 0 1rem var(--color-light-1); + opacity: 1; + animation: pulseOpacity 2s infinite; + } + } + + .slot:not(.empty) { + cursor: grab; + } + + .connection { + cursor: pointer; + pointer-events: all; + + outline: 1px solid transparent; + transition: outline-color 0.5s; + + &:hover { + box-shadow: 0 0 5px var(--color-shadow-primary); + } + + &.selected { + outline: 2px solid var(--color-cool-3); + } + } + } + + &.actor-selected { + &.mode-view { + .slot:not(.obtained), .connection:not(.obtained):not(.available) { + filter: saturate(.2) contrast(0.85) brightness(0.75); + } + + .slot.available { + border-style: dashed; + border-width: .1rem; + cursor: pointer; + } + } + } +} + +@keyframes pulseOpacity { + 0% { + opacity: 0; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + } +} \ No newline at end of file diff --git a/src/style/sheets/sheet.scss b/src/style/sheets/sheet.scss index 1569af80..fab5a930 100644 --- a/src/style/sheets/sheet.scss +++ b/src/style/sheets/sheet.scss @@ -28,13 +28,6 @@ } .application.sheet { - .value { - text-align: center; - font-size: 18pt; - font-weight: bold; - margin: 0.25ch; - } - .form-group input::placeholder { opacity: 0.5 !important; } diff --git a/src/system.json b/src/system.json index 36ffc67e..eec54a71 100644 --- a/src/system.json +++ b/src/system.json @@ -2,15 +2,15 @@ "id": "cosmere-rpg", "title": "Cosmere Roleplaying Game", "description": "A community-developed system for playing the Cosmere Roleplaying Game, by Dragonsteel Entertainment and Brotherwise Games.", - "version": "0.1.1", + "version": "0.2.0", "compatibility": { "minimum": "12", "verified": "12" }, "authors": [ { - "name": "Stan", - "discord": "stanvdb" + "name": "The Metalworks", + "url": "https://github.com/the-metalworks" } ], "esmodules": ["index.js"], @@ -36,7 +36,12 @@ "action": {}, "injury": {}, - "connection": {} + "connection": {}, + "goal": {}, + + "power": {}, + + "talent_tree": {} } }, "packs": [ @@ -61,7 +66,7 @@ "distance": 5, "units": "ft" }, - "url": "https://github.com/stanavdb/cosmere-rpg", - "manifest": "https://raw.githubusercontent.com/stanavdb/cosmere-rpg/main/src/system.json", - "download": "https://github.com/stanavdb/cosmere-rpg/releases/download/release-0.1.1/cosmere-rpg-release-0.1.1.zip" + "url": "https://github.com/the-metalworks/cosmere-rpg", + "manifest": "https://raw.githubusercontent.com/the-metalworks/cosmere-rpg/main/src/system.json", + "download": "https://github.com/the-metalworks/cosmere-rpg/releases/download/release-0.2.0/cosmere-rpg-release-0.2.0.zip" } diff --git a/src/system/api.ts b/src/system/api.ts index af804212..52ce00e1 100644 --- a/src/system/api.ts +++ b/src/system/api.ts @@ -1,11 +1,125 @@ import { + Skill, EquipmentType, WeaponId, ArmorId, PathType, + PowerType, + ActionType, } from '@system/types/cosmere'; -import { CurrencyConfig } from '@system/types/config'; +import { + CurrencyConfig, + SkillConfig, + PowerTypeConfig, + ActionTypeConfig, +} from '@system/types/config'; + +interface SkillConfigData extends Omit { + /** + * Unique id for the skill. + */ + id: string; +} + +export function registerSkill(data: SkillConfigData, force = false) { + if (!CONFIG.COSMERE) + throw new Error('Cannot access api until after system is initialized.'); + + if (data.id in CONFIG.COSMERE.skills && !force) + throw new Error('Cannot override existing skill config.'); + + if (force) { + console.warn('Registering skill with force=true.'); + } + + // Add to skills config + CONFIG.COSMERE.skills[data.id as Skill] = { + key: data.id, + label: data.label, + attribute: data.attribute, + core: data.core, + hiddenUntilAcquired: data.hiddenUntilAcquired, + }; + + // Add to attribute's skills list + CONFIG.COSMERE.attributes[data.attribute].skills.push(data.id as Skill); +} + +interface PowerTypeConfigData extends PowerTypeConfig { + /** + * Unique id for the power type. + */ + id: string; +} + +export function registerPowerType(data: PowerTypeConfigData, force = false) { + if (!CONFIG.COSMERE) + throw new Error('Cannot access api until after system is initialized.'); + + if (data.id in CONFIG.COSMERE.power.types && !force) + throw new Error('Cannot override existing power type config.'); + + if (force) { + console.warn('Registering power type with force=true.'); + } + + if (data.id === 'none') { + throw new Error('Cannot register power type with id "none".'); + } + + // Add to power types + CONFIG.COSMERE.power.types[data.id as PowerType] = { + label: data.label, + plural: data.plural, + }; +} + +interface PathTypeConfigData { + id: string; + label: string; +} + +export function registerPathType(data: PathTypeConfigData, force = false) { + if (!CONFIG.COSMERE) + throw new Error('Cannot access api until after system is initialized.'); + + if (data.id in CONFIG.COSMERE.armors && !force) + throw new Error('Cannot override existing path type config.'); + + if (force) { + console.warn('Registering path type with force=true.'); + } + + // Add to path config + CONFIG.COSMERE.paths.types[data.id as PathType] = { + label: data.label, + }; +} + +interface ActionTypeConfigData extends ActionTypeConfig { + id: string; +} + +export function registerActionType(data: ActionTypeConfigData, force = false) { + if (!CONFIG.COSMERE) + throw new Error('Cannot access api until after system is initialized.'); + + if (data.id in CONFIG.COSMERE.action.types && !force) + throw new Error('Cannot override existing action type config.'); + + if (force) { + console.warn('Registering action type with force=true.'); + } + + // Add to action types + CONFIG.COSMERE.action.types[data.id as ActionType] = { + label: data.label, + labelPlural: data.labelPlural, + hasMode: data.hasMode, + subtitle: data.subtitle, + }; +} interface EquipmentTypeConfigData { id: string; @@ -130,28 +244,6 @@ export function registerAncestry(data: AncestryConfigData, force = false) { }; } -interface PathTypeConfigData { - id: string; - label: string; -} - -export function registerPathType(data: PathTypeConfigData, force = false) { - if (!CONFIG.COSMERE) - throw new Error('Cannot access api until after system is initialized.'); - - if (data.id in CONFIG.COSMERE.armors && !force) - throw new Error('Cannot override existing path type config.'); - - if (force) { - console.warn('Registering path type with force=true.'); - } - - // Add to path config - CONFIG.COSMERE.paths.types[data.id as PathType] = { - label: data.label, - }; -} - interface CurrencyConfigData extends CurrencyConfig { id: string; } @@ -197,11 +289,14 @@ export function registerCurrency(data: CurrencyConfigData, force = false) { /* --- Default Export --- */ export default { + registerSkill, + registerPowerType, registerEquipmentType, + registerPathType, + registerActionType, registerWeapon, registerArmor, registerCulture, registerAncestry, - registerPathType, registerCurrency, }; diff --git a/src/system/applications/actor/adversary-sheet.ts b/src/system/applications/actor/adversary-sheet.ts index 3acde95a..05333573 100644 --- a/src/system/applications/actor/adversary-sheet.ts +++ b/src/system/applications/actor/adversary-sheet.ts @@ -1,4 +1,5 @@ import { AdversaryActor } from '@system/documents'; +import { SYSTEM_ID } from '@src/system/constants'; // Components import { SearchBarInputEvent } from './components'; @@ -26,7 +27,7 @@ export class AdversarySheet extends BaseActorSheet static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'actor', 'adversary'], + classes: [SYSTEM_ID, 'sheet', 'actor', 'adversary'], position: { width: 850, height: 850, @@ -60,9 +61,7 @@ export class AdversarySheet extends BaseActorSheet } get areSkillsCollapsed(): boolean { - return ( - this.actor.getFlag('cosmere-rpg', 'sheet.skillsCollapsed') ?? false - ); + return this.actor.getFlag(SYSTEM_ID, 'sheet.skillsCollapsed') ?? false; } /* --- Actions --- */ @@ -70,7 +69,7 @@ export class AdversarySheet extends BaseActorSheet private static onToggleSkillsCollapsed(this: AdversarySheet) { // Update the flag void this.actor.setFlag( - 'cosmere-rpg', + SYSTEM_ID, 'sheet.skillsCollapsed', !this.areSkillsCollapsed, ); diff --git a/src/system/applications/actor/base.ts b/src/system/applications/actor/base.ts index 430cad3b..0a0bedd6 100644 --- a/src/system/applications/actor/base.ts +++ b/src/system/applications/actor/base.ts @@ -1,6 +1,7 @@ import { Resource } from '@src/system/types/cosmere'; import { CosmereActor } from '@system/documents/actor'; import { DeepPartial, AnyObject } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Utils import AppUtils from '@system/applications/utils'; @@ -102,7 +103,7 @@ export class BaseActorSheet< /* --- Accessors --- */ public get mode(): ActorSheetMode { - return this.actor.getFlag('cosmere-rpg', 'sheet.mode') ?? 'edit'; + return this.actor.getFlag(SYSTEM_ID, 'sheet.mode') ?? 'edit'; } /* --- Drag drop --- */ diff --git a/src/system/applications/actor/character-sheet.ts b/src/system/applications/actor/character-sheet.ts index e957a0a0..ca0e58f5 100644 --- a/src/system/applications/actor/character-sheet.ts +++ b/src/system/applications/actor/character-sheet.ts @@ -2,6 +2,7 @@ import './components'; import { ItemType } from '@system/types/cosmere'; import { CharacterActor } from '@system/documents'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseActorSheet } from './base'; @@ -15,7 +16,7 @@ export class CharacterSheet extends BaseActorSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'actor', 'character'], + classes: [SYSTEM_ID, 'sheet', 'actor', 'character'], position: { width: 850, height: 1000, diff --git a/src/system/applications/actor/components/actions-list.ts b/src/system/applications/actor/components/actions-list.ts index fd6bdd95..79ce2cf6 100644 --- a/src/system/applications/actor/components/actions-list.ts +++ b/src/system/applications/actor/components/actions-list.ts @@ -3,6 +3,9 @@ import { ItemType, ActivationType, ActionCostType, + ItemConsumeType, + Resource, + PowerType, } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents/item'; import { CosmereActor } from '@system/documents'; @@ -292,6 +295,8 @@ export class ActorActionsListComponent extends HandlebarsApplicationComponent< return [ STATIC_SECTIONS.Weapons, + ...this.preparePowersSections(), + ...paths.map((path) => ({ id: path.system.id, label: game.i18n!.format( @@ -369,6 +374,55 @@ export class ActorActionsListComponent extends HandlebarsApplicationComponent< ]; } + protected preparePowersSections() { + // Get powers + const powers = this.application.actor.powers; + + // Get list of unique power types + const powerTypes = [...new Set(powers.map((p) => p.system.type))]; + + return powerTypes.map((type) => { + // Get config + const config = CONFIG.COSMERE.power.types[type]; + + return { + id: type, + label: game.i18n!.localize(config.plural), + default: false, + filter: (item: CosmereItem) => + item.isPower() && item.system.type === type, + new: (parent: CosmereActor) => + CosmereItem.create( + { + type: ItemType.Power, + name: game.i18n!.format( + 'COSMERE.Item.Type.Power.New', + { + type: game.i18n!.localize(config.label), + }, + ), + system: { + type, + activation: { + type: ActivationType.Utility, + cost: { + type: ActionCostType.Action, + value: 1, + }, + consume: { + type: ItemConsumeType.Resource, + resource: Resource.Investiture, + value: 1, + }, + }, + }, + }, + { parent }, + ) as Promise, + }; + }); + } + protected async prepareSectionsData( sections: ListSection[], items: CosmereItem[], @@ -437,10 +491,9 @@ export class ActorActionsListComponent extends HandlebarsApplicationComponent< public _onInitialize(): void { if (this.application.isEditable) { // Create context menu - AppContextMenu.create( - this as AppContextMenu.Parent, - 'right', - [ + AppContextMenu.create({ + parent: this as AppContextMenu.Parent, + items: [ /** * NOTE: This is a TEMPORARY context menu option * until we can handle recharging properly. @@ -489,8 +542,9 @@ export class ActorActionsListComponent extends HandlebarsApplicationComponent< }, }, ], - 'a[data-action="toggle-actions-controls"]', - ); + selectors: ['a[data-action="toggle-actions-controls"]'], + anchor: 'right', + }); } } } diff --git a/src/system/applications/actor/components/adversary/header.ts b/src/system/applications/actor/components/adversary/header.ts index 2e28619b..bb7589d2 100644 --- a/src/system/applications/actor/components/adversary/header.ts +++ b/src/system/applications/actor/components/adversary/header.ts @@ -4,7 +4,7 @@ import { ConstructorOf } from '@system/types/utils'; import { EditCreatureTypeDialog } from '@system/applications/actor/dialogs/edit-creature-type'; // Utils -import ActorUtils from '@system/util/actor'; +import { getTypeLabel } from '@src/system/utils/actor'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; @@ -65,7 +65,7 @@ export class AdversaryHeaderComponent extends HandlebarsApplicationComponent< roleLabel: CONFIG.COSMERE.adversary.roles[context.actor.system.role].label, sizeLabel: CONFIG.COSMERE.sizes[context.actor.system.size].label, - typeLabel: ActorUtils.getTypeLabel(context.actor.system.type), + typeLabel: getTypeLabel(context.actor.system.type), }); } } diff --git a/src/system/applications/actor/components/character/goals-list.ts b/src/system/applications/actor/components/character/goals-list.ts index cfc554dc..cfa3691f 100644 --- a/src/system/applications/actor/components/character/goals-list.ts +++ b/src/system/applications/actor/components/character/goals-list.ts @@ -1,4 +1,7 @@ +import { ItemType } from '@system/types/cosmere'; +import { GoalItem } from '@system/documents/item'; import { ConstructorOf, MouseButton } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; @@ -32,7 +35,7 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< }; /* eslint-enable @typescript-eslint/unbound-method */ - private contextGoalId: number | null = null; + private contextGoalId: string | null = null; private controlsDropdownExpanded = false; private controlsDropdownPosition?: { top: number; right: number }; @@ -48,7 +51,7 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get goal id const goalId = $(event.currentTarget!) .closest('[data-id]') - .data('id') as number; + .data('id') as string; this.contextGoalId = goalId; @@ -80,22 +83,25 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get goal id const goalId = $(event.currentTarget!) .closest('[data-id]') - .data('id') as number; + .data('id') as string | undefined; + if (!goalId) return; - // Get the goals - const goals = this.application.actor.system.goals; + // Get the goal + const goalItem = this.application.actor.items.get(goalId); + if (!goalItem?.isGoal()) return; - // Modify the goal - goals[goalId].level += incrementBool ? 1 : -1; - goals[goalId].level = Math.max(0, Math.min(3, goals[goalId].level)); + // Get the goal's current level + const currentLevel = goalItem.system.level; - // Adjust the rank - await this.application.actor.update( - { - 'system.goals': goals, - }, - { render: false }, - ); + // Calculate the new level + const newLevel = incrementBool + ? Math.min(currentLevel + 1, 3) + : Math.max(currentLevel - 1, 0); + + // Update the goal + await goalItem.update({ + 'system.level': newLevel, + }); // Render await this.render(); @@ -107,7 +113,7 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get current state const hideCompletedGoals = this.application.actor.getFlag( - 'cosmere-rpg', + SYSTEM_ID, HIDE_COMPLETED_FLAG, ) ?? false; @@ -135,8 +141,14 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Ensure context goal id is set if (this.contextGoalId !== null) { - // Edit the goal - this.editGoal(this.contextGoalId); + // Get the goal + const goalItem = this.application.actor.items.get( + this.contextGoalId, + ); + if (!goalItem?.isGoal()) return; + + // Show item sheet + void goalItem.sheet?.render(true); } } @@ -145,23 +157,15 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Ensure context goal id is set if (this.contextGoalId !== null) { - // Get goals - const goals = this.application.actor.system.goals; - - // Update the goals - goals.splice(this.contextGoalId, 1); - - // Update actor - await this.application.actor.update( - { - 'system.goals': goals, - }, - { render: false }, + // Get the goal + const goalItem = this.application.actor.items.get( + this.contextGoalId, ); - } + if (!goalItem?.isGoal()) return; - // Render - await this.render(); + // Delete the goal + await goalItem.delete(); + } } public static async onAddGoal(this: CharacterGoalsListComponent) { @@ -170,49 +174,47 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< // Get the goals const goals = this.application.actor.system.goals; + if (!goals) return; - // Add new goal - goals.push({ - text: game.i18n!.localize( - 'COSMERE.Actor.Sheet.Details.Goals.NewText', - ), - level: 0, - }); - - // Update the actor - await this.application.actor.update( + // Create goal + const goal = (await Item.create( { - 'system.goals': goals, + type: ItemType.Goal, + name: game.i18n!.localize( + 'COSMERE.Actor.Sheet.Details.Goals.NewText', + ), + system: { + level: 0, + }, }, - { render: false }, - ); - - // Render - await this.render(); + { parent: this.application.actor }, + )) as GoalItem; - // Edit goal - this.editGoal(goals.length - 1); + // Show item sheet + void goal.sheet?.render(true); } /* --- Context --- */ - public _prepareContext( + public async _prepareContext( params: never, context: BaseActorSheetRenderContext, ) { const hideCompletedGoals = this.application.actor.getFlag( - 'cosmere-rpg', + SYSTEM_ID, HIDE_COMPLETED_FLAG, ) ?? false; return Promise.resolve({ ...context, - goals: this.application.actor.system.goals + goals: this.application.actor.goals .map((goal) => ({ - ...goal, - achieved: goal.level === 3, + id: goal.id, + name: goal.name, + level: goal.system.level, + achieved: goal.system.level === 3, })) .filter((goal) => !hideCompletedGoals || !goal.achieved), @@ -224,59 +226,6 @@ export class CharacterGoalsListComponent extends HandlebarsApplicationComponent< }, }); } - - /* --- Helpers --- */ - - private editGoal(index: number) { - // Get goal element - const element = $(this.element!).find(`.goal[data-id="${index}"]`); - - // Get span element - const span = element.find('span.title'); - - // Hide span title - span.addClass('inactive'); - - // Get input element - const input = element.find('input.title'); - - // Show - input.removeClass('inactive'); - - setTimeout(() => { - // Focus input - input.trigger('select'); - - // Add event handler - input.on('focusout', async () => { - // Remove handler - input.off('focusout'); - - // Get the goals - const goals = this.application.actor.system.goals; - - // Modify the goal - goals[index].text = input.val() as string; - - // Update value - await this.application.actor.update({ - 'system.goals': goals, - }); - - // Render - void this.render(); - }); - - input.on('keypress', (event) => { - if (event.which !== 13) return; // Enter key - - event.preventDefault(); - event.stopPropagation(); - - input.trigger('focusout'); - }); - }); - } } // Register diff --git a/src/system/applications/actor/components/character/paths.ts b/src/system/applications/actor/components/character/paths.ts index 85d8b81d..67f92d68 100644 --- a/src/system/applications/actor/components/character/paths.ts +++ b/src/system/applications/actor/components/character/paths.ts @@ -67,6 +67,24 @@ export class CharacterPathsComponent extends HandlebarsApplicationComponent< id: path.id, img: path.img, typeLabel: CONFIG.COSMERE.paths.types[path.system.type].label, + skills: path.system.linkedSkills + .filter( + (skillId) => + this.application.actor.system.skills[skillId] + .unlocked === true, + ) + .map((skillId) => ({ + id: skillId, + label: CONFIG.COSMERE.skills[skillId].label, + attribute: CONFIG.COSMERE.skills[skillId].attribute, + attributeLabel: + CONFIG.COSMERE.attributes[ + CONFIG.COSMERE.skills[skillId].attribute + ].label, + rank: this.application.actor.system.skills[skillId] + .rank, + mod: this.application.actor.system.skills[skillId].mod, + })), level: this.application.actor.system.level.paths[ path.system.id ], diff --git a/src/system/applications/actor/components/details.ts b/src/system/applications/actor/components/details.ts index 1f963c85..46575498 100644 --- a/src/system/applications/actor/components/details.ts +++ b/src/system/applications/actor/components/details.ts @@ -5,9 +5,9 @@ import { ConstructorOf } from '@system/types/utils'; import { ConfigureMovementRateDialog } from '@system/applications/actor/dialogs/configure-movement-rate'; import { ConfigureSensesRangeDialog } from '@system/applications/actor/dialogs/configure-senses-range'; import { ConfigureRecoveryDieDialog } from '@system/applications/actor/dialogs/configure-recovery-die'; +import { ConfigureDeflectDialog } from '@system/applications/actor/dialogs/configure-deflect'; // Component imports -// import { HandlebarsApplicationComponent } from '../../mixins/component-handlebars-application-mixin'; import { HandlebarsApplicationComponent } from '@system/applications/component-system'; import { BaseActorSheet, BaseActorSheetRenderContext } from '../base'; import { CosmereActor } from '@src/system/documents'; @@ -29,6 +29,7 @@ export class ActorDetailsComponent extends HandlebarsApplicationComponent< 'configure-movement-rate': this.onConfigureMovementRate, 'configure-senses-range': this.onConfigureSensesRange, 'configure-recovery': this.onConfigureRecovery, + 'configure-deflect': this.onConfigureDeflect, 'edit-img': this.onEditImg, }; /* eslint-enable @typescript-eslint/unbound-method */ @@ -51,6 +52,10 @@ export class ActorDetailsComponent extends HandlebarsApplicationComponent< void ConfigureSensesRangeDialog.show(this.application.actor); } + private static onConfigureDeflect(this: ActorDetailsComponent) { + void ConfigureDeflectDialog.show(this.application.actor); + } + private static onConfigureRecovery(this: ActorDetailsComponent) { if (this.application.actor.isCharacter()) void ConfigureRecoveryDieDialog.show(this.application.actor); diff --git a/src/system/applications/actor/components/effects-list.ts b/src/system/applications/actor/components/effects-list.ts index 69ba998c..0b874b26 100644 --- a/src/system/applications/actor/components/effects-list.ts +++ b/src/system/applications/actor/components/effects-list.ts @@ -105,10 +105,9 @@ export class ActorEffectsListComponent extends HandlebarsApplicationComponent< public _onInitialize(): void { if (this.application.isEditable) { // Create context menu - AppContextMenu.create( - this as AppContextMenu.Parent, - 'right', - [ + AppContextMenu.create({ + parent: this as AppContextMenu.Parent, + items: [ { name: 'GENERIC.Button.Edit', icon: 'fa-solid fa-pen-to-square', @@ -133,8 +132,9 @@ export class ActorEffectsListComponent extends HandlebarsApplicationComponent< }, }, ], - 'a[data-action="toggle-effect-controls"]', - ); + selectors: ['a[data-action="toggle-effect-controls"]'], + anchor: 'right', + }); } } diff --git a/src/system/applications/actor/components/equipment-list.ts b/src/system/applications/actor/components/equipment-list.ts index 8a59f6b5..a4b75c1c 100644 --- a/src/system/applications/actor/components/equipment-list.ts +++ b/src/system/applications/actor/components/equipment-list.ts @@ -339,10 +339,9 @@ export class ActorEquipmentListComponent extends HandlebarsApplicationComponent< public _onInitialize(): void { if (this.application.isEditable) { // Create context menu - AppContextMenu.create( - this as AppContextMenu.Parent, - 'right', - [ + AppContextMenu.create({ + parent: this as AppContextMenu.Parent, + items: [ { name: 'GENERIC.Button.Edit', icon: 'fa-solid fa-pen-to-square', @@ -374,8 +373,9 @@ export class ActorEquipmentListComponent extends HandlebarsApplicationComponent< }, }, ], - 'a[data-action="toggle-actions-controls"]', - ); + selectors: ['a[data-action="toggle-actions-controls"]'], + anchor: 'right', + }); } } } diff --git a/src/system/applications/actor/components/index.ts b/src/system/applications/actor/components/index.ts index 0e1e66aa..b5b9a2ba 100644 --- a/src/system/applications/actor/components/index.ts +++ b/src/system/applications/actor/components/index.ts @@ -8,6 +8,7 @@ import './injuries-list'; import './resource'; import './search-bar'; import './skills-group'; +import './skill'; import './character'; import './adversary'; diff --git a/src/system/applications/actor/components/resource.ts b/src/system/applications/actor/components/resource.ts index 4ec0b79f..ed81a08c 100644 --- a/src/system/applications/actor/components/resource.ts +++ b/src/system/applications/actor/components/resource.ts @@ -83,7 +83,7 @@ export class ActorResourceComponent extends HandlebarsApplicationComponent< const actor = this.application.actor; // Roll injury - void actor.rollInjuryDuration(); + void actor.rollInjury(); } /* --- Context --- */ diff --git a/src/system/applications/actor/components/skill.ts b/src/system/applications/actor/components/skill.ts new file mode 100644 index 00000000..3747c42d --- /dev/null +++ b/src/system/applications/actor/components/skill.ts @@ -0,0 +1,123 @@ +import { Skill } from '@system/types/cosmere'; +import { ConstructorOf, MouseButton } from '@system/types/utils'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; +import { BaseActorSheet, BaseActorSheetRenderContext } from '../base'; + +// NOTE: Must use a type instead of an interface to match `AnyObject` type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + /** + * The skill to display + */ + skill: Skill; + + /** + * Whether to display the rank pips + * + * @default true + */ + pips?: boolean; + + /** + * Whether the skill is read-only + * + * @default false + */ + readonly?: boolean; +}; + +export class ActorSkillComponent extends HandlebarsApplicationComponent< + ConstructorOf, + Params +> { + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/actors/components/skill.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + 'roll-skill': this.onRollSkill, + 'adjust-skill-rank': { + handler: this.onAdjustSkillRank, + buttons: [MouseButton.Primary, MouseButton.Secondary], + }, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* --- Actions --- */ + + public static onRollSkill(this: ActorSkillComponent, event: Event) { + event.preventDefault(); + + const skillId = $(event.currentTarget!) + .closest('[data-id]') + .data('id') as Skill; + void this.application.actor.rollSkill(skillId); + } + + public static async onAdjustSkillRank( + this: ActorSkillComponent, + event: Event, + ) { + event.preventDefault(); + + const incrementBool: boolean = event.type === 'click' ? true : false; + + // Get skill id + const skillId = $(event.currentTarget!) + .closest('[data-id]') + .data('id') as Skill; + + // Modify skill rank + await this.application.actor.modifySkillRank(skillId, incrementBool); + } + + /* --- Accessors --- */ + + public get readonly() { + return this.params?.readonly === true; + } + + public get pips() { + return this.params?.pips !== false; + } + + /* --- Context --- */ + + public _prepareContext( + params: Params, + context: BaseActorSheetRenderContext, + ) { + // Get skill + const skill = this.application.actor.system.skills[params.skill]; + + // Get skill config + const config = CONFIG.COSMERE.skills[params.skill]; + + // Get attribute config + const attributeConfig = CONFIG.COSMERE.attributes[config.attribute]; + + return Promise.resolve({ + ...context, + + skill: { + ...skill, + id: params.skill, + label: config.label, + attribute: config.attribute, + attributeLabel: attributeConfig.labelShort, + }, + + editable: !this.readonly, + pips: this.pips, + }); + } +} + +// Register the component +ActorSkillComponent.register('app-actor-skill'); diff --git a/src/system/applications/actor/components/skills-group.ts b/src/system/applications/actor/components/skills-group.ts index e4ee409c..b6cf7591 100644 --- a/src/system/applications/actor/components/skills-group.ts +++ b/src/system/applications/actor/components/skills-group.ts @@ -1,5 +1,5 @@ import { AttributeGroup, Skill } from '@system/types/cosmere'; -import { ConstructorOf, MouseButton } from '@system/types/utils'; +import { ConstructorOf } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; @@ -9,6 +9,13 @@ import { BaseActorSheet, BaseActorSheetRenderContext } from '../base'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type Params = { 'group-id': AttributeGroup; + + /** + * Whether or not to display only core skills. + * + * @default true + */ + core?: boolean; }; export class ActorSkillsGroupComponent extends HandlebarsApplicationComponent< @@ -18,48 +25,6 @@ export class ActorSkillsGroupComponent extends HandlebarsApplicationComponent< static TEMPLATE = 'systems/cosmere-rpg/templates/actors/components/skills-group.hbs'; - /** - * NOTE: Unbound methods is the standard for defining actions - * within ApplicationV2 - */ - /* eslint-disable @typescript-eslint/unbound-method */ - static readonly ACTIONS = { - 'roll-skill': this.onRollSkill, - 'adjust-skill-rank': { - handler: this.onAdjustSkillRank, - buttons: [MouseButton.Primary, MouseButton.Secondary], - }, - }; - /* eslint-enable @typescript-eslint/unbound-method */ - - /* --- Actions --- */ - - public static onRollSkill(this: ActorSkillsGroupComponent, event: Event) { - event.preventDefault(); - - const skillId = $(event.currentTarget!) - .closest('[data-id]') - .data('id') as Skill; - void this.application.actor.rollSkill(skillId); - } - - public static async onAdjustSkillRank( - this: ActorSkillsGroupComponent, - event: Event, - ) { - event.preventDefault(); - - const incrementBool: boolean = event.type === 'click' ? true : false; - - // Get skill id - const skillId = $(event.currentTarget!) - .closest('[data-id]') - .data('id') as Skill; - - // Modify skill rank - await this.application.actor.modifySkillRank(skillId, incrementBool); - } - /* --- Context --- */ public _prepareContext( @@ -82,14 +47,28 @@ export class ActorSkillsGroupComponent extends HandlebarsApplicationComponent< id: params['group-id'], skills: skillIds - .map((skillId) => ({ - id: skillId, - config: CONFIG.COSMERE.skills[skillId], - ...this.application.actor.system.skills[skillId], - active: - !CONFIG.COSMERE.skills[skillId].hiddenUntilAcquired || - this.application.actor.system.skills[skillId].rank >= 1, - })) + .map((skillId) => { + // Get skill + const skill = this.application.actor.system.skills[skillId]; + + // Get config + const config = CONFIG.COSMERE.skills[skillId]; + + // Get attribute config + const attrConfig = + CONFIG.COSMERE.attributes[config.attribute]; + + return { + id: skillId, + config: { + ...config, + attrLabel: attrConfig.labelShort, + }, + ...skill, + active: !config.hiddenUntilAcquired || skill.rank >= 1, + }; + }) + .filter((skill) => params.core === false || skill.config.core) // Filter out non-core skills .sort((a, b) => { const _a = a.config.hiddenUntilAcquired ? 1 : 0; const _b = b.config.hiddenUntilAcquired ? 1 : 0; diff --git a/src/system/applications/actor/dialogs/configure-deflect.ts b/src/system/applications/actor/dialogs/configure-deflect.ts new file mode 100644 index 00000000..9baf0dca --- /dev/null +++ b/src/system/applications/actor/dialogs/configure-deflect.ts @@ -0,0 +1,143 @@ +import { AttributeGroup } from '@system/types/cosmere'; +import { CosmereActor } from '@system/documents'; +import { AnyObject } from '@system/types/utils'; + +import { CommonActorData } from '@system/data/actor/common'; +import { Derived } from '@system/data/fields'; + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +export class ConfigureDeflectDialog extends HandlebarsApplicationMixin( + ApplicationV2, +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + window: { + minimizable: false, + positioned: true, + }, + classes: ['dialog', 'configure-deflect'], + tag: 'dialog', + position: { + width: 350, + }, + actions: { + 'update-deflect': this.onUpdateDeflect, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + form: { + template: + 'systems/cosmere-rpg/templates/actors/dialogs/configure-deflect.hbs', + forms: { + form: { + handler: this.onFormEvent, + submitOnChange: true, + }, + }, + }, + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + private data: CommonActorData['deflect']; + private mode: Derived.Mode; + + private constructor(private actor: CosmereActor) { + super({ + id: `${actor.uuid}.Deflect`, + window: { + title: game.i18n!.format('DIALOG.ConfigureDeflect.Title', { + actor: actor.name, + }), + }, + }); + + this.data = actor.system.deflect; + this.data.value ??= 0; + this.data.natural ??= 0; + this.data.override ??= this.data.value ?? 0; + this.data.bonus ??= 0; + this.mode = Derived.getMode(this.data); + } + + /* --- Statics --- */ + + public static show(actor: CosmereActor) { + void new ConfigureDeflectDialog(actor).render(true); + } + + /* --- Actions --- */ + + private static onUpdateDeflect(this: ConfigureDeflectDialog) { + void this.actor.update({ + 'system.deflect': this.data, + }); + void this.close(); + } + + /* --- Form --- */ + + private static onFormEvent( + this: ConfigureDeflectDialog, + event: Event, + form: HTMLFormElement, + formData: FormDataExtended, + ) { + if (event instanceof SubmitEvent) return; + + const target = event.target as HTMLInputElement; + + this.mode = formData.get('mode') as Derived.Mode; + + if (target.name !== 'mode') { + if (this.mode === Derived.Mode.Override) { + this.data.override = Number(formData.object.value ?? 0); + } else { + this.data.natural = Number(formData.object.natural ?? 0); + this.data.bonus = Number(formData.object.bonus ?? 0); + } + } + + if (isNaN(this.data.override!)) this.data.override = 0; + if (isNaN(this.data.natural!)) this.data.natural = 0; + if (isNaN(this.data.bonus!)) this.data.bonus = 0; + + // Assign mode + Derived.setMode(this.data, this.mode); + + // Render + void this.render(true); + } + + /* --- Lifecycle --- */ + + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + + $(this.element).prop('open', true); + } + + /* --- Context --- */ + + protected _prepareContext() { + return Promise.resolve({ + ...this.data, + mode: this.mode, + modes: { + ...Derived.Modes, + [Derived.Mode.Derived]: 'TYPES.Item.armor', + }, + }); + } +} diff --git a/src/system/applications/actor/dialogs/edit-creature-type.ts b/src/system/applications/actor/dialogs/edit-creature-type.ts index b74bf5c8..ff71c87f 100644 --- a/src/system/applications/actor/dialogs/edit-creature-type.ts +++ b/src/system/applications/actor/dialogs/edit-creature-type.ts @@ -5,7 +5,7 @@ import { AnyObject } from '@system/types/utils'; import { CommonActorData } from '@system/data/actor/common'; // Utils -import ActorUtils from '@system/util/actor'; +import { getTypeLabel } from '@src/system/utils/actor'; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; @@ -128,7 +128,7 @@ export class EditCreatureTypeDialog extends HandlebarsApplicationMixin( actor: this.actor, type: this.type, - typeLabel: ActorUtils.getTypeLabel(this.type), + typeLabel: getTypeLabel(this.type), configuredTypes, }); } diff --git a/src/system/applications/combat/combat-tracker.ts b/src/system/applications/combat/combat-tracker.ts index f57074fc..276ff0f1 100644 --- a/src/system/applications/combat/combat-tracker.ts +++ b/src/system/applications/combat/combat-tracker.ts @@ -1,5 +1,6 @@ import { ActorType, TurnSpeed } from '@src/system/types/cosmere'; import { CosmereCombatant } from '@src/system/documents/combatant'; +import { SYSTEM_ID } from '@src/system/constants'; /** * Overrides default tracker template to implement slow/fast buckets and combatant activation button. @@ -35,14 +36,11 @@ export class CosmereCombatTracker extends CombatTracker { const newTurn: CosmereTurn = { ...turn, turnSpeed: combatant.getFlag( - 'cosmere-rpg', + SYSTEM_ID, 'turnSpeed', ) as TurnSpeed, type: combatant.actor.type, - activated: combatant.getFlag( - 'cosmere-rpg', - 'activated', - ) as boolean, + activated: combatant.getFlag(SYSTEM_ID, 'activated') as boolean, }; //strips active player formatting newTurn.css = ''; @@ -122,7 +120,7 @@ export class CosmereCombatTracker extends CombatTracker { li.dataset.combatantId!, {}, ) as CosmereCombatant; - void combatant.setFlag('cosmere-rpg', 'activated', true); + void combatant.setFlag(SYSTEM_ID, 'activated', true); } /** @@ -146,7 +144,7 @@ export class CosmereCombatTracker extends CombatTracker { li.data('combatant-id') as string, {}, ) as CosmereCombatant; - void combatant.setFlag('cosmere-rpg', 'activated', false); + void combatant.setFlag(SYSTEM_ID, 'activated', false); } /** diff --git a/src/system/applications/components/document-drop-list.ts b/src/system/applications/components/document-drop-list.ts new file mode 100644 index 00000000..0c9027ca --- /dev/null +++ b/src/system/applications/components/document-drop-list.ts @@ -0,0 +1,268 @@ +import { ConstructorOf } from '@system/types/utils'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; + +// Mixins +import { DragDropComponentMixin } from '@system/applications/mixins/drag-drop'; + +type DocumentType = (typeof CONST.ALL_DOCUMENT_TYPES)[number]; + +// NOTE: Must use type here instead of interface as an interface doesn't match AnyObject type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + name?: string; + + /** + * An array of document UUID values + */ + value?: string[]; + + /** + * The specific type of document that this component should accept (i.e. 'Item') + */ + type?: DocumentType; + + /** + * The specific subtype of document that this component should accept (i.e. 'Weapon') + */ + subtype?: string; + + /** + * Whether the field is read-only + */ + readonly?: boolean; + + /** + * Placeholder text for the input + */ + placeholder?: string; +}; + +export class DocumentDropListComponent extends DragDropComponentMixin( + HandlebarsApplicationComponent< + ConstructorOf, + Params + >, +) { + static FORM_ASSOCIATED = true; + + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/general/components/document-drop-list.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static ACTIONS = { + 'remove-document': this.onRemoveDocument, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + static DRAG_DROP = [ + { + dropSelector: '*', + }, + ]; + + private _value: string[] = []; + private _name?: string; + + /* --- Accessors --- */ + + public get element(): + | (HTMLElement & { name?: string; value: string[] }) + | undefined { + return super.element as unknown as + | (HTMLElement & { name?: string; value: string[] }) + | undefined; + } + + public get readonly() { + return this.params?.readonly === true; + } + + public get value() { + return this._value; + } + + public set value(value: string[]) { + this._value = value; + + // Set value + this.element!.value = value; + + // Dispatch change event + this.element!.dispatchEvent(new Event('change', { bubbles: true })); + } + + public get name() { + return this._name; + } + + public set name(value: string | undefined) { + this._name = value; + + // Set name + this.element!.name = value; + $(this.element!).attr('name', value ?? ''); + } + + public get placeholder(): string | undefined { + return this.params?.placeholder; + } + + /* --- Actions --- */ + + public static onRemoveDocument( + this: DocumentDropListComponent, + event: Event, + ) { + // Get key + const key = $(event.target!).closest('[data-id]').data('id') as string; + + // Remove document + this.value = this.value.filter((v) => v !== key); + + // Rerender + void this.render(); + } + + /* --- Drag drop --- */ + + protected override _canDragDrop() { + return !this.readonly; + } + + protected override _onDragOver(event: DragEvent) { + if (this.readonly) return; + + $(this.element!).addClass('dragover'); + } + + protected override async _onDrop(event: DragEvent) { + if (this.readonly) return; + + // Remove dragover class + $(this.element!).removeClass('dragover'); + + // Get data + const data = TextEditor.getDragEventData(event) as unknown as { + type: string; + uuid: string; + }; + + // Ensure the document is not already in the list + if (this.value.includes(data.uuid)) { + return ui.notifications.warn( + game.i18n!.format( + 'COMPONENT.DocumentDropListComponent.Warning.DocumentAlreadyInList', + { + type: + this.params!.type ?? + game.i18n!.localize('GENERIC.Document'), + }, + ), + ); + } + + // Validate type + if (this.params!.type && data.type !== this.params!.type) { + return ui.notifications.warn( + game.i18n!.format( + 'COMPONENT.DocumentDropListComponent.Warning.WrongType', + { + type: this.params!.type, + }, + ), + ); + } + + // Validate subtype + if (this.params!.subtype) { + // Get document + const doc = (await fromUuid(data.uuid)) as unknown as { + type: string; + data: { type: string }; + }; + + if (doc.data.type !== this.params!.subtype) { + return ui.notifications.warn( + game.i18n!.format( + 'COMPONENT.DocumentDropListComponent.Warning.WrongSubtype', + { + subtype: this.params!.subtype, + }, + ), + ); + } + } + + // Add document to the list + this.value = [...this.value, data.uuid]; + + // Render + void this.render(); + } + + /* --- Lifecycle --- */ + + protected override _onInitialize(params: Params) { + super._onInitialize(params); + + if (this.params!.value) { + this._value = this.params!.value; + } + } + + public override _onAttachListeners(params: Params) { + super._onAttachListeners(params); + + $(this.element!).on('dragleave', () => { + $(this.element!).removeClass('dragover'); + }); + } + + protected override _onRender(params: Params) { + super._onRender(params); + + // Set name + if (this.params!.name) { + this.name = this.params!.name; + } + + // Set readonly + if (this.params!.readonly) { + $(this.element!).attr('readonly', 'readonly'); + } + } + + /* --- Context --- */ + + public async _prepareContext(params: Params) { + // Look up the documents + const docs = ( + await Promise.all( + this.value.map( + async (uuid) => + (await fromUuid( + uuid, + )) as unknown as ClientDocument | null, + ), + ) + ).filter((v) => !!v); + + return { + ...params, + value: this.value, + documents: docs.map((doc) => ({ + uuid: doc.uuid, + link: doc.toAnchor().outerHTML, + })), + }; + } +} + +// Register the component +DocumentDropListComponent.register('app-document-drop-list'); diff --git a/src/system/applications/components/document-reference-input.ts b/src/system/applications/components/document-reference-input.ts index f2a04a16..628c0b47 100644 --- a/src/system/applications/components/document-reference-input.ts +++ b/src/system/applications/components/document-reference-input.ts @@ -80,7 +80,7 @@ export class DocumentReferenceInputComponent extends DragDropComponentMixin( } public get readonly() { - return this.params?.readonly !== false; + return this.params?.readonly === true; } public get value() { diff --git a/src/system/applications/components/index.ts b/src/system/applications/components/index.ts index 5f47b4cd..d35b85c7 100644 --- a/src/system/applications/components/index.ts +++ b/src/system/applications/components/index.ts @@ -1,3 +1,5 @@ import './id-input'; import './multi-state-toggle'; import './document-reference-input'; +import './multi-value-select'; +import './document-drop-list'; diff --git a/src/system/applications/components/multi-value-select.ts b/src/system/applications/components/multi-value-select.ts new file mode 100644 index 00000000..299d9168 --- /dev/null +++ b/src/system/applications/components/multi-value-select.ts @@ -0,0 +1,195 @@ +import { + ConstructorOf, + AnyObject, + DeepPartial, + EmptyObject, +} from '@system/types/utils'; + +// Component imports +import { + ComponentHandlebarsRenderOptions, + HandlebarsApplicationComponent, +} from '@system/applications/component-system'; + +// NOTE: Must use type here instead of interface as an interface doesn't match AnyObject type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + name?: string; + + /** + * The selected values + */ + value?: string[]; + + /** + * The available options + */ + options?: string[] | Record; + + /** + * Placeholder text for the input + */ + placeholder?: string; + + /** + * Whether the field is read-only + */ + readonly?: boolean; +}; + +export class MultiValueSelectComponent extends HandlebarsApplicationComponent< + ConstructorOf, + Params +> { + static FORM_ASSOCIATED = true; + + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/general/components/multi-value-select.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + remove: this.onRemove, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + private _value: string[] = []; + private _name?: string; + + /* --- Accessors --- */ + + public get element(): + | (HTMLElement & { name?: string; value: string[] }) + | undefined { + return super.element as unknown as + | (HTMLElement & { name?: string; value: string[] }) + | undefined; + } + + public get readonly() { + return this.params?.readonly === true; + } + + public get value() { + return this._value; + } + + public set value(value: string[]) { + this._value = value; + + // Set value + this.element!.value = value; + + // Dispatch change event + this.element!.dispatchEvent(new Event('change', { bubbles: true })); + } + + public get name() { + return this._name; + } + + public set name(value: string | undefined) { + this._name = value; + + // Set name + this.element!.name = value; + $(this.element!).attr('name', value ?? ''); + } + + public get placeholder(): string | undefined { + return this.params?.placeholder; + } + + /* --- Actions --- */ + + public static onRemove(this: MultiValueSelectComponent, event: Event) { + // Get key + const key = $(event.target!).closest('[data-id]').data('id') as string; + + // Remove value + this.value = this.value.filter((value) => value !== key); + + // Rerender + void this.render(); + } + + /* --- Lifecycle --- */ + + protected override _onInitialize() { + if (this.params!.value) { + this._value = this.params!.value ?? []; + } + } + + protected override _onAttachListeners(params: Params) { + super._onAttachListeners(params); + + // Handle select change + $(this.element!) + .find('select') + .on('change', (event) => { + const value = $(event.currentTarget).val() as string; + + // Add value + this.value = [...this.value, value]; + + // Rerender + void this.render(); + }); + } + + protected override _onRender(params: Params) { + super._onRender(params); + + // Set name + if (this.params!.name) { + this.name = this.params!.name; + } + + // Set readonly + if (this.params!.readonly) { + $(this.element!).attr('readonly', 'readonly'); + } + } + + /* --- Context --- */ + + public _prepareContext(params: Params) { + // Default options + params.options ??= []; + + // Prepare options + const options = + foundry.utils.getType(params.options) === 'Object' + ? (foundry.utils.deepClone(params.options) as Record< + string, + string + >) + : (params.options as string[]).reduce( + (acc, key) => ({ ...acc, [key]: key }), + {} as Record, + ); + + // Prepare selected + const selected = this._value.map((key) => ({ + key, + label: options[key], + })); + + // Remove selected from options + this._value.forEach((key) => delete options[key]); + + return Promise.resolve({ + selected, + options, + readonly: this.readonly, + placeholder: this.placeholder, + }); + } +} + +// Register the component +MultiValueSelectComponent.register('app-multi-value-select'); diff --git a/src/system/applications/item/action-sheet.ts b/src/system/applications/item/action-sheet.ts index c2d86617..c3f845d8 100644 --- a/src/system/applications/item/action-sheet.ts +++ b/src/system/applications/item/action-sheet.ts @@ -1,5 +1,6 @@ import { ActionItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class ActionItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'action'], + classes: [SYSTEM_ID, 'sheet', 'item', 'action'], position: { width: 550, }, diff --git a/src/system/applications/item/ancestry-sheet.ts b/src/system/applications/item/ancestry-sheet.ts index ec356115..9ac92856 100644 --- a/src/system/applications/item/ancestry-sheet.ts +++ b/src/system/applications/item/ancestry-sheet.ts @@ -1,5 +1,6 @@ import { AncestryItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; import { BaseItemSheet } from './base'; @@ -7,7 +8,7 @@ export class AncestrySheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'ancestry'], + classes: [SYSTEM_ID, 'sheet', 'item', 'ancestry'], position: { width: 600, height: 550, diff --git a/src/system/applications/item/armor-sheet.ts b/src/system/applications/item/armor-sheet.ts index 7b56eb54..4965db98 100644 --- a/src/system/applications/item/armor-sheet.ts +++ b/src/system/applications/item/armor-sheet.ts @@ -1,19 +1,15 @@ import { ArmorItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; export class ArmorItemSheet extends BaseItemSheet { - /** - * NOTE: Unbound methods is the standard for defining actions and forms - * within ApplicationV2 - */ - static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'armor'], + classes: [SYSTEM_ID, 'sheet', 'item', 'armor'], position: { width: 730, height: 500, diff --git a/src/system/applications/item/base.ts b/src/system/applications/item/base.ts index 26661178..28015de1 100644 --- a/src/system/applications/item/base.ts +++ b/src/system/applications/item/base.ts @@ -5,6 +5,7 @@ import { DeepPartial, AnyObject } from '@system/types/utils'; // Mixins import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; import { TabsApplicationMixin } from '@system/applications/mixins'; +import { getSystemSetting, SETTINGS } from '@src/system/settings'; const { ItemSheetV2 } = foundry.applications.sheets; @@ -286,7 +287,7 @@ export class BaseItemSheet extends TabsApplicationMixin( ).fields, editable: this.isEditable, descHtml: enrichedDescValue, - sideTabs: game.settings!.get('cosmere-rpg', 'itemSheetSideTabs'), + sideTabs: getSystemSetting(SETTINGS.ITEM_SHEET_SIDE_TABS), }; } } diff --git a/src/system/applications/item/components/advancement-talent-list.ts b/src/system/applications/item/components/ancestry/advancement-talent-list.ts similarity index 99% rename from src/system/applications/item/components/advancement-talent-list.ts rename to src/system/applications/item/components/ancestry/advancement-talent-list.ts index c128096f..ed27a0a9 100644 --- a/src/system/applications/item/components/advancement-talent-list.ts +++ b/src/system/applications/item/components/ancestry/advancement-talent-list.ts @@ -2,7 +2,7 @@ import { AnyObject, ConstructorOf } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { AncestrySheet } from '../ancestry-sheet'; +import { AncestrySheet } from '../../ancestry-sheet'; // Mixins import { DragDropComponentMixin } from '@system/applications/mixins/drag-drop'; diff --git a/src/system/applications/item/components/ancestry-bonus-talents.ts b/src/system/applications/item/components/ancestry/ancestry-bonus-talents.ts similarity index 96% rename from src/system/applications/item/components/ancestry-bonus-talents.ts rename to src/system/applications/item/components/ancestry/ancestry-bonus-talents.ts index 479070c4..2bd3fcab 100644 --- a/src/system/applications/item/components/ancestry-bonus-talents.ts +++ b/src/system/applications/item/components/ancestry/ancestry-bonus-talents.ts @@ -2,11 +2,11 @@ import { BonusTalentsRule } from '@system/data/item/ancestry'; import { AnyObject, ConstructorOf } from '@system/types/utils'; // Dialogs -import { EditBonusTalentsRuleDialog } from '../dialogs/edit-bonus-talents-rule'; +import { EditBonusTalentsRuleDialog } from '../../dialogs/talent/edit-bonus-talents-rule'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { AncestrySheet } from '../ancestry-sheet'; +import { AncestrySheet } from '../../ancestry-sheet'; export class AncestryBonusTalentsComponent extends HandlebarsApplicationComponent< ConstructorOf diff --git a/src/system/applications/item/components/ancestry/index.ts b/src/system/applications/item/components/ancestry/index.ts new file mode 100644 index 00000000..1e5e8ec0 --- /dev/null +++ b/src/system/applications/item/components/ancestry/index.ts @@ -0,0 +1,2 @@ +import './advancement-talent-list'; +import './ancestry-bonus-talents'; diff --git a/src/system/applications/item/components/details-damage.ts b/src/system/applications/item/components/details-damage.ts index 4684a651..9d1ce228 100644 --- a/src/system/applications/item/components/details-damage.ts +++ b/src/system/applications/item/components/details-damage.ts @@ -11,6 +11,20 @@ export class DetailsDamageComponent extends HandlebarsApplicationComponent< static TEMPLATE = 'systems/cosmere-rpg/templates/item/components/details-damage.hbs'; + /* eslint-disable @typescript-eslint/unbound-method */ + static ACTIONS = { + 'toggle-graze-collapsed': DetailsDamageComponent.onToggleGrazeCollapsed, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + private grazeOverrideCollapsed = true; + + /* --- Actions --- */ + + private static onToggleGrazeCollapsed(this: DetailsDamageComponent) { + this.grazeOverrideCollapsed = !this.grazeOverrideCollapsed; + void this.render(); + } + /* --- Context --- */ public _prepareContext(params: never, context: BaseItemSheetRenderContext) { @@ -31,9 +45,15 @@ export class DetailsDamageComponent extends HandlebarsApplicationComponent< const hasSkill = hasSkillTest && this.application.item.system.activation.skill; + this.grazeOverrideCollapsed = this.application.item.system.damage + .grazeOverrideFormula + ? this.application.item.system.damage.grazeOverrideFormula === '' + : this.grazeOverrideCollapsed; + return { hasSkillTest, hasSkill, + grazeInputCollapsed: this.grazeOverrideCollapsed, typeSelectOptions: { none: '—', diff --git a/src/system/applications/item/components/details-id.ts b/src/system/applications/item/components/details-id.ts index f2b02164..d60e2263 100644 --- a/src/system/applications/item/components/details-id.ts +++ b/src/system/applications/item/components/details-id.ts @@ -16,16 +16,9 @@ export class DetailsIdComponent extends HandlebarsApplicationComponent< return Promise.resolve({ ...context, hasId: this.application.item.hasId(), - note: game - .i18n!.localize('COSMERE.Item.Sheet.Identifier.Description') - .replace( - '[type]', - game - .i18n!.localize( - `TYPES.Item.${this.application.item.type}`, - ) - .toLowerCase(), - ), + type: game + .i18n!.localize(`TYPES.Item.${this.application.item.type}`) + .toLowerCase(), }); } } diff --git a/src/system/applications/item/components/goal/index.ts b/src/system/applications/item/components/goal/index.ts new file mode 100644 index 00000000..b19625c1 --- /dev/null +++ b/src/system/applications/item/components/goal/index.ts @@ -0,0 +1 @@ +import './rewards-list'; diff --git a/src/system/applications/item/components/goal/rewards-list.ts b/src/system/applications/item/components/goal/rewards-list.ts new file mode 100644 index 00000000..32980b45 --- /dev/null +++ b/src/system/applications/item/components/goal/rewards-list.ts @@ -0,0 +1,142 @@ +import { Skill } from '@system/types/cosmere'; +import { Goal } from '@system/types/item'; +import { CosmereItem, GoalItem, PowerItem } from '@system/documents/item'; +import { ConstructorOf } from '@system/types/utils'; + +// Dialogs +import { EditGoalRewardDialog } from '@system/applications/item/dialogs/goal/edit-reward'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; +import { GoalItemSheet } from '@system/applications/item'; + +// NOTE: Must use a type instead of an interface to match `AnyObject` type +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +type Params = { + rewards: Collection; + editable?: boolean; +}; + +export class RewardsListComponent extends HandlebarsApplicationComponent< + ConstructorOf, + Params +> { + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/item/goal/components/rewards-list.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + 'create-reward': this.onCreateReward, + 'edit-reward': this.onEditReward, + 'remove-reward': this.onRemoveReward, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* --- Actions --- */ + + private static async onCreateReward( + this: RewardsListComponent, + event: Event, + ) { + // Create a new reward + const newReward: Goal.Reward = { + type: Goal.Reward.Type.Items, + items: [], + }; + + // Generate a unique ID + const id = foundry.utils.randomID(); + + // Add the new rule to the item + await this.application.item.update({ + [`system.rewards.${id}`]: newReward, + }); + + // Show the edit dialog + await EditGoalRewardDialog.show(this.application.item, { + _id: id, + ...newReward, + }); + } + + private static async onEditReward( + this: RewardsListComponent, + event: Event, + ) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Get reward + const reward = this.application.item.system.rewards.get(id); + if (!reward) return; + + // Show the edit dialog + await EditGoalRewardDialog.show(this.application.item, { + _id: id, + ...reward, + }); + } + + private static async onRemoveReward( + this: RewardsListComponent, + event: Event, + ) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Remove the reward + await this.application.item.update({ + [`system.rewards.-=${id}`]: null, + }); + } + + /* --- Context --- */ + + public async _prepareContext(params: Params) { + const rewards = await Promise.all( + params.rewards.map(async (reward) => { + if (reward.type !== Goal.Reward.Type.Items) return reward; + + // Look up docs + const docs = await Promise.all( + reward.items.map(async (itemUUID) => { + const doc = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + return { + uuid: doc.uuid, + link: doc.toAnchor().outerHTML, + }; + }), + ); + + return { + ...reward, + items: docs, + }; + }), + ); + + return Promise.resolve({ + ...params, + rewards: rewards.map((reward) => ({ + ...reward, + typeLabel: CONFIG.COSMERE.items.goal.rewards.types[reward.type], + })), + editable: params.editable !== false, + }); + } +} + +// Register the component +RewardsListComponent.register('app-goal-rewards-list'); diff --git a/src/system/applications/item/components/index.ts b/src/system/applications/item/components/index.ts index af51bac1..4260c1d2 100644 --- a/src/system/applications/item/components/index.ts +++ b/src/system/applications/item/components/index.ts @@ -8,7 +8,7 @@ import './details-attack'; import './details-damage'; import './details-modality'; import './properties'; -import './talent-prerequisites'; -import './advancement-talent-list'; -import './ancestry-bonus-talents'; -import './talent-prerequisite-talent-list'; + +import './talent'; +import './ancestry'; +import './goal'; diff --git a/src/system/applications/item/components/talent/grant-rules-list.ts b/src/system/applications/item/components/talent/grant-rules-list.ts new file mode 100644 index 00000000..63a4d9b1 --- /dev/null +++ b/src/system/applications/item/components/talent/grant-rules-list.ts @@ -0,0 +1,134 @@ +import { Talent } from '@system/types/item'; +import { CosmereItem } from '@system/documents/item'; +import { ConstructorOf } from '@system/types/utils'; + +// Dialogs +import { EditTalentGrantRuleDialog } from '../../dialogs/talent/edit-grant-rule'; + +// Component imports +import { HandlebarsApplicationComponent } from '@system/applications/component-system'; +import { TalentItemSheet } from '../../talent-sheet'; +import { BaseItemSheetRenderContext } from '../../base'; + +export class TalentGrantRulesList extends HandlebarsApplicationComponent< + ConstructorOf +> { + static readonly TEMPLATE = + 'systems/cosmere-rpg/templates/item/talent/components/grant-rules-list.hbs'; + + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static readonly ACTIONS = { + 'create-rule': this.onCreateGrantRule, + 'edit-rule': this.onEditGrantRule, + 'delete-rule': this.onDeleteGrantRule, + }; + /* eslint-enable @typescript-eslint/unbound-method */ + + /* --- Actions --- */ + + private static async onCreateGrantRule(this: TalentGrantRulesList) { + // Create a new rule + const newRule: Talent.GrantRule = { + type: Talent.GrantRule.Type.Items, + items: [], + }; + + // Generate a unique ID + const id = foundry.utils.randomID(); + + // Add the new rule to the item + await this.application.item.update({ + [`system.grantRules.${id}`]: newRule, + }); + + // Show the edit dialog + void EditTalentGrantRuleDialog.show(this.application.item, { + _id: id, + ...newRule, + }); + } + + private static onEditGrantRule(this: TalentGrantRulesList, event: Event) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Get rule + const rule = this.application.item.system.grantRules.get(id); + if (!rule) return; + + // Show the edit dialog + void EditTalentGrantRuleDialog.show(this.application.item, { + _id: id, + ...rule, + }); + } + + private static async onDeleteGrantRule( + this: TalentGrantRulesList, + event: Event, + ) { + // Get id + const id = $(event.target!).closest('[data-id]').data('id') as + | string + | undefined; + if (!id) return; + + // Remove the rule + await this.application.item.update({ + [`system.grantRules.-=${id}`]: null, + }); + } + + /* --- Context --- */ + + public async _prepareContext( + params: never, + context: BaseItemSheetRenderContext, + ) { + // Get rules + const rules = this.application.item.system.grantRules; + + return { + ...context, + rules: await Promise.all( + rules.map(this.prepareGrantRuleContext.bind(this)), + ), + }; + } + + private async prepareGrantRuleContext(rule: Talent.GrantRule) { + return { + ...rule, + typeLabel: CONFIG.COSMERE.items.talent.grantRules.types[rule.type], + + ...(rule.type === Talent.GrantRule.Type.Items + ? { + items: await Promise.all( + rule.items.map(async (itemUUID) => { + // Look up the doc + const item = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + + return { + name: item.name, + uuid: item.uuid, + link: item.toAnchor().outerHTML, + }; + }), + ), + } + : {}), + }; + } +} + +// Register the component +TalentGrantRulesList.register('app-talent-grant-rules-list'); diff --git a/src/system/applications/item/components/talent/index.ts b/src/system/applications/item/components/talent/index.ts new file mode 100644 index 00000000..25762add --- /dev/null +++ b/src/system/applications/item/components/talent/index.ts @@ -0,0 +1,3 @@ +import './talent-prerequisite-talent-list'; +import './talent-prerequisites'; +import './grant-rules-list'; diff --git a/src/system/applications/item/components/talent-prerequisite-talent-list.ts b/src/system/applications/item/components/talent/talent-prerequisite-talent-list.ts similarity index 97% rename from src/system/applications/item/components/talent-prerequisite-talent-list.ts rename to src/system/applications/item/components/talent/talent-prerequisite-talent-list.ts index 1a17f6cb..70858846 100644 --- a/src/system/applications/item/components/talent-prerequisite-talent-list.ts +++ b/src/system/applications/item/components/talent/talent-prerequisite-talent-list.ts @@ -4,7 +4,7 @@ import { ConstructorOf } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { EditTalentPrerequisiteDialog } from '../dialogs/edit-talent-prerequisite'; +import { EditTalentPrerequisiteDialog } from '../../dialogs/talent/edit-talent-prerequisite'; // Mixins import { DragDropComponentMixin } from '@system/applications/mixins/drag-drop'; diff --git a/src/system/applications/item/components/talent-prerequisites.ts b/src/system/applications/item/components/talent/talent-prerequisites.ts similarity index 95% rename from src/system/applications/item/components/talent-prerequisites.ts rename to src/system/applications/item/components/talent/talent-prerequisites.ts index ccb7f090..27575eb8 100644 --- a/src/system/applications/item/components/talent-prerequisites.ts +++ b/src/system/applications/item/components/talent/talent-prerequisites.ts @@ -5,12 +5,12 @@ import { ConstructorOf } from '@system/types/utils'; import { Talent } from '@system/types/item'; // Dialogs -import { EditTalentPrerequisiteDialog } from '../dialogs/edit-talent-prerequisite'; +import { EditTalentPrerequisiteDialog } from '../../dialogs/talent/edit-talent-prerequisite'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; -import { TalentItemSheet } from '../talent-sheet'; -import { BaseItemSheetRenderContext } from '../base'; +import { TalentItemSheet } from '../../talent-sheet'; +import { BaseItemSheetRenderContext } from '../../base'; export class TalentPrerequisitesComponent extends HandlebarsApplicationComponent< ConstructorOf diff --git a/src/system/applications/item/connection-sheet.ts b/src/system/applications/item/connection-sheet.ts index 2ef57f44..8f8e51dd 100644 --- a/src/system/applications/item/connection-sheet.ts +++ b/src/system/applications/item/connection-sheet.ts @@ -1,5 +1,6 @@ import { ConnectionItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class ConnectionItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'connection'], + classes: [SYSTEM_ID, 'sheet', 'item', 'connection'], position: { width: 550, height: 500, diff --git a/src/system/applications/item/culture-sheet.ts b/src/system/applications/item/culture-sheet.ts index 93c03823..afbbd228 100644 --- a/src/system/applications/item/culture-sheet.ts +++ b/src/system/applications/item/culture-sheet.ts @@ -1,5 +1,6 @@ import { CultureItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class CultureItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'culture'], + classes: [SYSTEM_ID, 'sheet', 'item', 'culture'], position: { width: 550, height: 500, diff --git a/src/system/applications/item/dialogs/goal/edit-reward.ts b/src/system/applications/item/dialogs/goal/edit-reward.ts new file mode 100644 index 00000000..f7dead43 --- /dev/null +++ b/src/system/applications/item/dialogs/goal/edit-reward.ts @@ -0,0 +1,173 @@ +import { Skill } from '@system/types/cosmere'; +import { GoalItem } from '@system/documents/item'; +import { Goal } from '@system/types/item'; +import { AnyObject } from '@system/types/utils'; + +import { CollectionField } from '@system/data/fields'; + +const { ApplicationV2 } = foundry.applications.api; + +import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; + +type RewardData = { + _id: string; +} & Goal.Reward; + +export class EditGoalRewardDialog extends ComponentHandlebarsApplicationMixin( + ApplicationV2, +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + window: { + title: 'DIALOG.EditGrantRule.Title', + minimizable: false, + resizable: true, + positioned: true, + }, + classes: ['dialog', 'edit-reward'], + tag: 'dialog', + position: { + width: 425, + }, + actions: { + update: this.onUpdateReward, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + form: { + template: + 'systems/cosmere-rpg/templates/item/goal/dialogs/edit-reward.hbs', + forms: { + form: { + handler: this.onFormEvent, + submitOnChange: true, + }, + }, + }, + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + private constructor( + private goal: GoalItem, + private reward: RewardData, + ) { + super({ + id: `${goal.uuid}.Rewards.${reward._id}`, + window: { + title: 'DIALOG.EditGoalReward.Title', + }, + }); + } + + /* --- Statics --- */ + + public static async show(goal: GoalItem, reward: RewardData) { + const dialog = new this(goal, foundry.utils.deepClone(reward)); + await dialog.render(true); + } + + /* --- Actions --- */ + + private static async onUpdateReward(this: EditGoalRewardDialog) { + // Validate + if ( + this.reward.type === Goal.Reward.Type.SkillRanks && + (this.reward.skill === null || this.reward.ranks === null) + ) { + ui.notifications.error( + 'COSMERE.Item.Goal.Reward.Validation.MissingSkillOrRanks', + ); + return; + } else if ( + this.reward.type === Goal.Reward.Type.Items && + this.reward.items === null + ) { + ui.notifications.error( + 'COSMERE.Item.Goal.Reward.Validation.MissingItems', + ); + return; + } + + // Prepare updates + const updates = + this.reward.type === Goal.Reward.Type.SkillRanks + ? { + type: this.reward.type, + skill: this.reward.skill, + ranks: this.reward.ranks, + } + : { + type: this.reward.type, + items: this.reward.items, + }; + + // Perform updates + await this.goal.update({ + [`system.rewards.${this.reward._id}`]: updates, + }); + + // Close + void this.close(); + } + + /* --- Form --- */ + + protected static onFormEvent( + this: EditGoalRewardDialog, + event: Event, + form: HTMLFormElement, + formData: FormDataExtended, + ) { + if (event instanceof SubmitEvent) return; + + // Get type + this.reward.type = formData.get('type') as Goal.Reward.Type; + + if ( + this.reward.type === Goal.Reward.Type.SkillRanks && + formData.has('skill') + ) { + this.reward.skill = formData.get('skill') as Skill; + this.reward.ranks = parseInt(formData.get('ranks') as string, 10); + } else if ( + this.reward.type === Goal.Reward.Type.Items && + formData.has('items') + ) { + this.reward.items = formData.object.items as unknown as string[]; + } + + // Render + void this.render(true); + } + + /* --- Lifecycle --- */ + + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + + $(this.element).prop('open', true); + } + + /* --- Context --- */ + + public _prepareContext() { + return Promise.resolve({ + goal: this.goal, + ...this.reward, + + schema: (this.goal.system.schema.fields.rewards as CollectionField) + .model, + }); + } +} diff --git a/src/system/applications/item/dialogs/talent-tree/configure-talent-tree.ts b/src/system/applications/item/dialogs/talent-tree/configure-talent-tree.ts new file mode 100644 index 00000000..0008e932 --- /dev/null +++ b/src/system/applications/item/dialogs/talent-tree/configure-talent-tree.ts @@ -0,0 +1,144 @@ +import { TalentTreeItem } from '@system/documents/item'; +import { TalentTreeItemData } from '@system/data/item/talent-tree'; +import { AnyObject } from '@system/types/utils'; + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +type DialogData = Pick; + +export class ConfigureTalentTreeDialog extends HandlebarsApplicationMixin( + ApplicationV2, +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + window: { + minimizable: false, + resizable: true, + positioned: true, + }, + classes: ['dialog', 'configure-talent-tree'], + tag: 'dialog', + position: { + width: 350, + }, + actions: { + update: this.onSubmit, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + form: { + template: + 'systems/cosmere-rpg/templates/item/talent-tree/dialogs/configure.hbs', + forms: { + form: { + handler: this.onFormEvent, + }, + }, + }, + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + private data: DialogData; + private submitted = false; + + private constructor( + private item: TalentTreeItem, + private resolve: (value: boolean) => void, + ) { + super({ + window: { + title: game.i18n!.format('DIALOG.ConfigureTalentTree.Title', { + name: item.name, + }), + }, + }); + + this.data = foundry.utils.deepClone(item.system); + } + + /* --- Statics --- */ + + public static show(item: TalentTreeItem): Promise { + return new Promise((resolve) => { + void new this(item, resolve).render(true); + }); + } + + /* --- Form --- */ + + protected static onFormEvent( + this: ConfigureTalentTreeDialog, + event: Event, + ) { + event.preventDefault(); + } + + /* --- Actions --- */ + + protected static async onSubmit(this: ConfigureTalentTreeDialog) { + const form = this.element.querySelector('form')! as HTMLFormElement & { + width: HTMLInputElement; + height: HTMLInputElement; + }; + + this.data.width = parseInt(form.width.value, 10); + this.data.height = parseInt(form.height.value, 10); + + // Ensure the talent tree doesn't have any nodes outside the new bounds + const nodes = this.item.system.nodes; + if ( + nodes.some( + (node) => + node.position.row >= this.data.height || + node.position.column >= this.data.width, + ) + ) { + ui.notifications.error( + game.i18n!.localize( + 'DIALOG.ConfigureTalentTree.Warning.OutOfBounds', + ), + ); + } else { + // Update the item + await this.item.update({ + system: this.data, + }); + + this.resolve(true); + this.submitted = true; + void this.close(); + } + } + + /* --- Lifecycle --- */ + + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + + $(this.element).prop('open', true); + } + + protected _onClose() { + if (!this.submitted) this.resolve(false); + } + + /* --- Context --- */ + + protected _prepareContext() { + return Promise.resolve({ + ...this.data, + schema: this.item.system.schema, + }); + } +} diff --git a/src/system/applications/item/dialogs/edit-bonus-talents-rule.ts b/src/system/applications/item/dialogs/talent/edit-bonus-talents-rule.ts similarity index 100% rename from src/system/applications/item/dialogs/edit-bonus-talents-rule.ts rename to src/system/applications/item/dialogs/talent/edit-bonus-talents-rule.ts diff --git a/src/system/applications/item/dialogs/talent/edit-grant-rule.ts b/src/system/applications/item/dialogs/talent/edit-grant-rule.ts new file mode 100644 index 00000000..f87a2fa8 --- /dev/null +++ b/src/system/applications/item/dialogs/talent/edit-grant-rule.ts @@ -0,0 +1,130 @@ +import { TalentItem } from '@system/documents/item'; +import { Talent } from '@system/types/item'; +import { AnyObject } from '@system/types/utils'; + +import { CollectionField } from '@system/data/fields'; + +const { ApplicationV2 } = foundry.applications.api; + +import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; + +type GrantRuleData = { _id: string } & Talent.GrantRule; + +export class EditTalentGrantRuleDialog extends ComponentHandlebarsApplicationMixin( + ApplicationV2, +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + window: { + title: 'DIALOG.EditGrantRule.Title', + minimizable: false, + resizable: true, + positioned: true, + }, + classes: ['dialog', 'edit-grant-rule'], + tag: 'dialog', + position: { + width: 350, + }, + actions: { + update: this.onUpdateGrantRule, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + form: { + template: + 'systems/cosmere-rpg/templates/item/talent/dialogs/edit-grant-rule.hbs', + forms: { + form: { + handler: this.onFormEvent, + submitOnChange: true, + }, + }, + }, + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + private constructor( + private talent: TalentItem, + private rule: GrantRuleData, + ) { + super({ + id: `${talent.uuid}.GrantRule.${rule._id}`, + }); + } + + /* --- Statics --- */ + + public static async show(talent: TalentItem, rule: GrantRuleData) { + const dialog = new this(talent, rule); + await dialog.render(true); + } + + /* --- Actions --- */ + + private static onUpdateGrantRule(this: EditTalentGrantRuleDialog) { + void this.talent.update({ + [`system.grantRules.${this.rule._id}`]: this.rule, + }); + void this.close(); + } + + /* --- Form --- */ + + protected static onFormEvent( + this: EditTalentGrantRuleDialog, + event: Event, + form: HTMLFormElement, + formData: FormDataExtended, + ) { + event.preventDefault(); + + if (event instanceof SubmitEvent) return; + + // Get type + this.rule.type = formData.get('type') as Talent.GrantRule.Type; + + if ( + this.rule.type === Talent.GrantRule.Type.Items && + formData.has('items') + ) { + this.rule.items = formData.object.items as unknown as string[]; + } + + // Render + void this.render(true); + } + + /* --- Lifecycle --- */ + + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + + $(this.element).prop('open', true); + } + + /* --- Context --- */ + + public _prepareContext() { + return Promise.resolve({ + editable: true, + talent: this.talent, + schema: ( + this.talent.system.schema.fields + .grantRules as CollectionField + ).model, + ...this.rule, + }); + } +} diff --git a/src/system/applications/item/dialogs/edit-talent-prerequisite.ts b/src/system/applications/item/dialogs/talent/edit-talent-prerequisite.ts similarity index 92% rename from src/system/applications/item/dialogs/edit-talent-prerequisite.ts rename to src/system/applications/item/dialogs/talent/edit-talent-prerequisite.ts index c5ad5a05..ac88701f 100644 --- a/src/system/applications/item/dialogs/edit-talent-prerequisite.ts +++ b/src/system/applications/item/dialogs/talent/edit-talent-prerequisite.ts @@ -124,6 +124,11 @@ export class EditTalentPrerequisiteDialog extends ComponentHandlebarsApplication this.data.talents ??= []; } else if (this.data.type === Talent.Prerequisite.Type.Connection) { this.data.description = formData.get('description') as string; + } else if ( + this.data.type === Talent.Prerequisite.Type.Level && + formData.has('level') + ) { + this.data.level = parseInt(formData.get('level') as string); } // Render @@ -132,7 +137,9 @@ export class EditTalentPrerequisiteDialog extends ComponentHandlebarsApplication /* --- Lifecycle --- */ - protected _onRender(): void { + protected _onRender(context: AnyObject, options: AnyObject): void { + super._onRender(context, options); + $(this.element).prop('open', true); $(this.element) @@ -146,6 +153,10 @@ export class EditTalentPrerequisiteDialog extends ComponentHandlebarsApplication return Promise.resolve({ editable: true, rootTalent: this.talent, + schema: this.talent.system.schema._getField([ + 'prerequisites', + 'model', + ]), ...this.data, typeSelectOptions: this.talent.system.prerequisiteTypeSelectOptions, diff --git a/src/system/applications/item/equipment-sheet.ts b/src/system/applications/item/equipment-sheet.ts index 74e06188..d0b45822 100644 --- a/src/system/applications/item/equipment-sheet.ts +++ b/src/system/applications/item/equipment-sheet.ts @@ -1,5 +1,6 @@ import { EquipmentItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class EquipmentItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'equipment'], + classes: [SYSTEM_ID, 'sheet', 'item', 'equipment'], position: { width: 730, height: 500, diff --git a/src/system/applications/item/goal-sheet.ts b/src/system/applications/item/goal-sheet.ts new file mode 100644 index 00000000..86975238 --- /dev/null +++ b/src/system/applications/item/goal-sheet.ts @@ -0,0 +1,59 @@ +import { GoalItem } from '@system/documents/item'; +import { DeepPartial } from '@system/types/utils'; + +import { SYSTEM_ID } from '@src/system/constants'; + +// Base +import { BaseItemSheet } from './base'; + +export class GoalItemSheet extends BaseItemSheet { + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + classes: [SYSTEM_ID, 'sheet', 'item', 'armor'], + position: { + width: 550, + height: 500, + }, + window: { + resizable: true, + positioned: true, + }, + }, + ); + + static TABS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.TABS), + { + details: { + label: 'COSMERE.Item.Sheet.Tabs.Details', + icon: '', + sortIndex: 15, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + 'sheet-content': { + template: + 'systems/cosmere-rpg/templates/item/goal/parts/sheet-content.hbs', + }, + }, + ); + + get item(): GoalItem { + return super.document; + } + + /* --- Context --- */ + + public async _prepareContext( + options: DeepPartial, + ) { + return { + ...(await super._prepareContext(options)), + }; + } +} diff --git a/src/system/applications/item/index.ts b/src/system/applications/item/index.ts index b810de7c..d18c454d 100644 --- a/src/system/applications/item/index.ts +++ b/src/system/applications/item/index.ts @@ -13,3 +13,6 @@ export * from './action-sheet'; export * from './talent-sheet'; export * from './equipment-sheet'; export * from './weapon-sheet'; +export * from './goal-sheet'; +export * from './power-sheet'; +export * from './talent-tree-sheet'; diff --git a/src/system/applications/item/injury-sheet.ts b/src/system/applications/item/injury-sheet.ts index d910ae51..8497d34e 100644 --- a/src/system/applications/item/injury-sheet.ts +++ b/src/system/applications/item/injury-sheet.ts @@ -1,6 +1,7 @@ import { InjuryType } from '@system/types/cosmere'; import { InjuryItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -9,7 +10,7 @@ export class InjuryItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'injury'], + classes: [SYSTEM_ID, 'sheet', 'item', 'injury'], position: { width: 550, height: 500, diff --git a/src/system/applications/item/loot-sheet.ts b/src/system/applications/item/loot-sheet.ts index 10f83ee5..9b0b9209 100644 --- a/src/system/applications/item/loot-sheet.ts +++ b/src/system/applications/item/loot-sheet.ts @@ -1,5 +1,6 @@ import { LootItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class LootItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'loot'], + classes: [SYSTEM_ID, 'sheet', 'item', 'loot'], position: { width: 730, height: 500, diff --git a/src/system/applications/item/path-sheet.ts b/src/system/applications/item/path-sheet.ts index 0acfe66a..b3597473 100644 --- a/src/system/applications/item/path-sheet.ts +++ b/src/system/applications/item/path-sheet.ts @@ -1,5 +1,6 @@ import { PathItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class PathItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'path'], + classes: [SYSTEM_ID, 'sheet', 'item', 'path'], position: { width: 550, height: 500, @@ -36,7 +37,7 @@ export class PathItemSheet extends BaseItemSheet { { 'sheet-content': { template: - 'systems/cosmere-rpg/templates/item/parts/sheet-content.hbs', + 'systems/cosmere-rpg/templates/item/path/parts/sheet-content.hbs', }, }, ); @@ -50,8 +51,21 @@ export class PathItemSheet extends BaseItemSheet { public async _prepareContext( options: DeepPartial, ) { + // Get non-core (locked) skills + const linkedSkillsOptions = Object.entries(CONFIG.COSMERE.skills) + .filter(([key, config]) => !config.core) + .reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ); + return { ...(await super._prepareContext(options)), + + linkedSkillsOptions, }; } } diff --git a/src/system/applications/item/power-sheet.ts b/src/system/applications/item/power-sheet.ts new file mode 100644 index 00000000..4ea6f1d1 --- /dev/null +++ b/src/system/applications/item/power-sheet.ts @@ -0,0 +1,59 @@ +import { PowerItem } from '@system/documents/item'; +import { DeepPartial } from '@system/types/utils'; + +import { SYSTEM_ID } from '@src/system/constants'; + +// Base +import { BaseItemSheet } from './base'; + +export class PowerItemSheet extends BaseItemSheet { + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + classes: [SYSTEM_ID, 'sheet', 'item', 'armor'], + position: { + width: 550, + height: 500, + }, + window: { + resizable: true, + positioned: true, + }, + }, + ); + + static TABS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.TABS), + { + details: { + label: 'COSMERE.Item.Sheet.Tabs.Details', + icon: '', + sortIndex: 15, + }, + }, + ); + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + 'sheet-content': { + template: + 'systems/cosmere-rpg/templates/item/power/parts/sheet-content.hbs', + }, + }, + ); + + get item(): PowerItem { + return super.document; + } + + /* --- Context --- */ + + public async _prepareContext( + options: DeepPartial, + ) { + return { + ...(await super._prepareContext(options)), + }; + } +} diff --git a/src/system/applications/item/specialty-sheet.ts b/src/system/applications/item/specialty-sheet.ts index 9ee7ccdc..6064e13c 100644 --- a/src/system/applications/item/specialty-sheet.ts +++ b/src/system/applications/item/specialty-sheet.ts @@ -1,5 +1,6 @@ import { SpecialtyItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class SpecialtyItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'specialty'], + classes: [SYSTEM_ID, 'sheet', 'item', 'specialty'], position: { width: 550, height: 500, diff --git a/src/system/applications/item/talent-sheet.ts b/src/system/applications/item/talent-sheet.ts index 0b56d340..484b2ea1 100644 --- a/src/system/applications/item/talent-sheet.ts +++ b/src/system/applications/item/talent-sheet.ts @@ -1,6 +1,7 @@ import { Talent } from '@system/types/item'; import { TalentItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -14,7 +15,7 @@ export class TalentItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'talent'], + classes: [SYSTEM_ID, 'sheet', 'item', 'talent'], position: { width: 550, }, @@ -82,6 +83,7 @@ export class TalentItemSheet extends BaseItemSheet { ...(await super._prepareContext(options)), isPathTalent: this.item.system.type === Talent.Type.Path, isAncestryTalent: this.item.system.type === Talent.Type.Ancestry, + isPowerTalent: this.item.system.type === Talent.Type.Power, hasModality: this.item.system.modality !== null, }; } diff --git a/src/system/applications/item/talent-tree-sheet.ts b/src/system/applications/item/talent-tree-sheet.ts new file mode 100644 index 00000000..1fc98c87 --- /dev/null +++ b/src/system/applications/item/talent-tree-sheet.ts @@ -0,0 +1,1141 @@ +import { Talent, TalentTree } from '@system/types/item'; +import { + CosmereItem, + TalentItem, + TalentTreeItem, +} from '@system/documents/item'; +import { CosmereActor } from '@system/documents/actor'; +import { AnyObject, DeepPartial, MouseButton } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; + +// Context menu +import { AppContextMenu } from '@system/applications/utils/context-menu'; + +// Mixins +import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; +import { DragDropApplicationMixin, EditModeApplicationMixin } from '../mixins'; + +// Dialogs +import { ConfigureTalentTreeDialog } from '@system/applications/item/dialogs/talent-tree/configure-talent-tree'; + +const { ItemSheetV2 } = foundry.applications.sheets; + +// Constants +const ROW_HEIGHT = 65; +const COLUMN_WIDTH = 65; +const HEADER_HEIGHT = 36; +const PADDING = 10; +const DOCUMENT_UUID_REGEX = /@UUID\[.+\]\{(.*)\}/g; + +interface ExtendedNode extends TalentTree.Node { + item: TalentItem; + obtained: boolean; + available: boolean; +} + +export class TalentTreeItemSheet extends EditModeApplicationMixin( + DragDropApplicationMixin(ComponentHandlebarsApplicationMixin(ItemSheetV2)), +) { + /** + * NOTE: Unbound methods is the standard for defining actions and forms + * within ApplicationV2 + */ + /* eslint-disable @typescript-eslint/unbound-method */ + static DEFAULT_OPTIONS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.DEFAULT_OPTIONS), + { + classes: [SYSTEM_ID, 'sheet', 'item', 'talent-tree'], + window: { + positioned: true, + resizable: false, + }, + actions: { + configure: this.onConfigure, + 'pick-talent': this.onPickTalent, + }, + dragDrop: [ + { + dropSelector: '.container', + }, + { + dragSelector: '.slot:not(.empty)', + dropSelector: '.slot.empty', + }, + ], + }, + ); + /* eslint-enable @typescript-eslint/unbound-method */ + + static PARTS = foundry.utils.mergeObject( + foundry.utils.deepClone(super.PARTS), + { + 'sheet-content': { + template: + 'systems/cosmere-rpg/templates/item/talent-tree/parts/sheet-content.hbs', + }, + }, + ); + + private contextMenu?: AppContextMenu; + + private _contextActor?: CosmereActor; + private _dragging = false; + private _draggingNodeId?: string; + private _contextNodeIds = new Set(); + + private nodes = new Collection(); + + constructor( + options: foundry.applications.api.DocumentSheetV2.Configuration, + ) { + const tree = options.document as unknown as TalentTreeItem; + + super( + foundry.utils.mergeObject(options, { + window: { + title: tree.name, + }, + position: calculatePosition(tree), + }), + ); + + // Get all characters owned by the current user + const characters = (game.actors as CosmereActor[]).filter( + (actor) => + actor.isCharacter() && + actor.testUserPermission( + game.user as unknown as foundry.documents.BaseUser, + 'OWNER', + ), + ); + + // Get user character + const userCharacter = game.user!.character as CosmereActor | undefined; + + if (userCharacter || characters.length === 1) + this.contextActor = userCharacter ?? characters[0]; + } + + /* --- Accessors --- */ + + get item(): TalentTreeItem { + return super.document; + } + + private get dragging(): boolean { + return this._dragging; + } + + private set dragging(value: boolean) { + this._dragging = value; + $(this.element) + .find('.grid') + .css('pointer-events', !value ? 'none' : 'auto'); + } + + private get contextNodes(): TalentTree.Node[] { + return Array.from(this._contextNodeIds).map( + (id) => this.item.system.nodes.get(id)!, + ); + } + + private set draggingNode(value: TalentTree.Node | undefined) { + this._draggingNodeId = value?.id; + } + + private get draggingNode(): TalentTree.Node | undefined { + return this.item.system.nodes.get(this._draggingNodeId!); + } + + private set contextActor(actor: CosmereActor | undefined) { + if (actor !== this._contextActor) { + if (this._contextActor) { + delete this._contextActor.apps[this.id]; + } + } + + this._contextActor = actor; + + if (actor) { + $(this.element).addClass('actor-selected'); + + // Register this sheet with the actor + actor.apps[this.id] = this; + } else { + $(this.element).removeClass('actor-selected'); + } + } + + private get contextActor(): CosmereActor | undefined { + return this._contextActor; + } + + /* --- Actions --- */ + + private static async onConfigure(this: TalentTreeItemSheet) { + if (await ConfigureTalentTreeDialog.show(this.item)) { + // Update position + foundry.utils.mergeObject( + this.position, + calculatePosition(this.item), + ); + + // Close and re-open + await this.close(); + + // Re-render + void this.render(true); + } + } + + private static async onPickTalent(this: TalentTreeItemSheet, event: Event) { + event.preventDefault(); + + // Get slot element + const slotEl = $(event.target!).closest('.slot'); + + // Get id + const id = slotEl.data('id') as string; + + // Get node + const node = this.nodes.get(id); + if (!node) return; + + // Ensure node is available + if (!node.available) return; + + // Get item + const item = node.item; + + // Add item to context actor + await this.contextActor!.createEmbeddedDocuments('Item', [ + item.toObject(), + ]); + + // Create notification + ui.notifications.info( + game.i18n!.format( + 'DIALOG.ConfigureTalentTree.Notification.TalentPicked', + { + talent: item.name, + actor: this.contextActor!.name, + }, + ), + ); + + // Render + void this.render(true); + } + + /* --- Drag Drop --- */ + + protected override _canDragStart(selector: string): boolean { + return this.isEditMode; + } + + protected override _canDragDrop(selector: string): boolean { + return this.isEditMode; + } + + protected override _onDragStart(event: DragEvent) { + // Hide context menu + this.contextMenu?.hide(); + + // Get slot element + const slot = $(event.target!).closest('.slot'); + + // Get id + const id = slot.data('id') as string; + + // Get node + const node = this.item.system.nodes.get(id); + if (!node) return; + + // Prepare data + const data = { + type: 'Item', + uuid: node.uuid, + }; + + // Set data transfer + event.dataTransfer!.setData('text/plain', JSON.stringify(data)); + event.dataTransfer!.setData('document/item', ''); // Mark the type + event.dataTransfer!.setData('source/talent-tree', this.item.id); // Metadata + event.dataTransfer!.setData('source/node', node.id); // Metadata + + // Set dragging node + this.draggingNode = node; + } + + protected override _onDragOver(event: DragEvent) { + // Check if dragging over container or slot + if ($(event.target!).hasClass('container')) { + if (!this.dragging) { + this.dragging = true; + } + } + } + + protected override async _onDrop(event: DragEvent) { + if ($(event.target!).hasClass('container')) return; + + event.stopImmediatePropagation(); + + try { + // Hide context menu + this.contextMenu?.hide(); + + // Get element + const slotEl = $(event.target!).closest('.slot'); + + // Ensure cell element was found + if (!slotEl.length) return; + + // Remove dragover class + slotEl.removeClass('dragover'); + + // Get cell element + const cellEl = slotEl.closest('.cell'); + + const data = TextEditor.getDragEventData(event) as unknown as { + type: string; + uuid: string; + }; + + // Ensure type is correct + if (data.type !== 'Item') return; + + // Get the item + const item = (await fromUuid(data.uuid)) as CosmereItem | null; + + // Validate item + if (!item?.isTalent()) return; + + // Get the item ids for all the nodes in the tree + const itemIds = ( + await Promise.all( + this.item.system.nodes.map(async (node) => { + const item = (await fromUuid( + node.uuid, + )) as CosmereItem | null; + return item?.system.id; + }), + ) + ).filter((id) => !!id); + + // Ensure the item isn't already present in the tree + if (itemIds.includes(item.system.id) && !this.draggingNode) { + return ui.notifications.warn( + game.i18n!.format('GENERIC.Warning.ItemAlreadyInTree', { + itemId: item.system.id, + name: this.item.name, + }), + ); + } + + // Get target cell position + const row = cellEl.data('row') as number; + const column = cellEl.data('column') as number; + + // Ensure position is valid + if (row < 0 || row >= this.item.system.height) return; + if (column < 0 || column >= this.item.system.width) return; + + // Check if we should create a new node + const shouldCreateNode = + !event.dataTransfer!.types.includes('source/node'); + + // Get node id (create new one if this isn't an existing node) + const nodeId = + event.dataTransfer!.getData('source/talent-tree') === + this.item.id && + event.dataTransfer!.types.includes('source/node') + ? event.dataTransfer!.getData('source/node') + : foundry.utils.randomID(); + + if (shouldCreateNode) { + // Create new node + await this.item.update( + { + [`system.nodes.${nodeId}`]: { + id: nodeId, + type: TalentTree.Node.Type.Icon, + uuid: data.uuid, + connections: [], + position: { + row, + column, + }, + }, + }, + { render: false }, + ); + } else { + // Update node position + await this.item.update( + { + [`system.nodes.${nodeId}.position`]: { + row, + column, + }, + }, + { render: false }, + ); + } + + // Check if user can modify the item + const hasPermission = item.testUserPermission( + game.user as unknown as foundry.documents.BaseUser, + CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER, + ); + + // Check if the item isn't part of a locked compendium + const inLockedCompendium = item.compendium?.locked ?? false; + + // If we have a context node, connect the two + if ( + hasPermission && + !inLockedCompendium && + this.contextNodes.length > 0 + ) { + // Look up context items + const contextItems = ( + await Promise.all( + this.contextNodes.map( + async (node) => + (await fromUuid( + node.uuid, + )) as CosmereItem | null, + ), + ) + ).filter((item) => !!item && item.isTalent()); + + // Get item talent prerequisites + const talentPrerequisites = + item.system.prerequisitesArray.filter( + (prerequisite) => + prerequisite.type === + Talent.Prerequisite.Type.Talent, + ); + + // Find an "Any Of" rule + let anyOfRule = talentPrerequisites.find( + (rule) => + rule.mode === Talent.Prerequisite.Mode.AnyOf || + (rule.talents.length === 1 && !rule.mode), + ); + + // If there isn't one, create it + if (!anyOfRule) { + anyOfRule = { + id: foundry.utils.randomID(), + type: Talent.Prerequisite.Type.Talent, + mode: Talent.Prerequisite.Mode.AnyOf, + talents: [], + }; + + // Update the item + await item.update( + { + [`system.prerequisites.${anyOfRule.id}`]: anyOfRule, + }, + { render: false }, + ); + } + + // Add the context items to the rule + anyOfRule.talents.push( + ...contextItems.map((item) => ({ + id: item.system.id, + uuid: item.uuid, + label: item.name, + })), + ); + + // Update the rule + await item.update( + { + [`system.prerequisites.${anyOfRule.id}`]: anyOfRule, + }, + { diff: false }, + ); + + // Clear context nodes + this.clearContextNodes(); + } + + // Bind to item if it's a new node + if (!this.draggingNode) { + item.apps[this.id] = this; + } + } finally { + // Reset dragging + this.dragging = false; + this.draggingNode = undefined; + + // Render + void this.render(true); + } + } + + /* --- Lifecycle --- */ + + protected _getHeaderControls(): foundry.applications.api.ApplicationV2.HeaderControlsEntry[] { + const controls = super._getHeaderControls(); + + if (!controls.some((control) => control.action === 'configure')) { + // Add edit button + controls.unshift({ + action: 'configure', + label: 'GENERIC.Configure', + icon: 'fa-solid fa-edit', + ownership: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER, + }); + } + + return controls; + } + + protected override async _renderFrame( + options: AnyObject, + ): Promise { + const frame = await super._renderFrame(options); + + // Get all characters owned by the current user + const characters = (game.actors as CosmereActor[]).filter( + (actor) => + actor.isCharacter() && + actor.testUserPermission( + game.user as unknown as foundry.documents.BaseUser, + 'OWNER', + ), + ); + + // Get user character + const userCharacter = game.user!.character as CosmereActor | undefined; + + if (characters.length > 1) { + $(this.window.title!).after(` +
+ + +
+ `); + } + + // Bind to select + $(this.window.title!) + .parent() + .find('.actor-select select') + .on('change', (event) => { + // Get selected actor + const actorId = $(event.target).val() as string; + + // Get actor + const actor = + actorId === 'none' + ? undefined + : (game.actors as Collection).get( + actorId, + ); + + // Set context actor + this.contextActor = actor; + + // Render + void this.render(true); + }); + + if (this.contextActor) { + $(frame).addClass('actor-selected'); + } + + return frame; + } + + protected override _onRender(context: AnyObject, options: AnyObject) { + super._onRender(context, options); + + // Bind dragleave event + $(this.element) + .find('.container') + .on('dragleave', (event) => { + // Check if target element is child of the container + if ($(event.target).closest('.container').length) return; + + this.dragging = false; + }); + + $(this.element) + .find('.slot') + .on('dragleave', (event) => { + // Get element + const el = $(event.target).closest('.slot'); + + // Remove dragover class + el.removeClass('dragover'); + }) + .on('dragenter', (event) => { + // Get element + const el = $(event.target).closest('.slot'); + + // Add dragover class + el.addClass('dragover'); + + if (el.hasClass('empty')) return; + + // Get id + const id = el.data('id') as string; + + if (this.draggingNode?.id === id) return; + + // Check if node is already in context + if (!this.hasContextNode(id)) { + // Add context node + this.addContextNode(id); + } else { + // Remove context node + this.removeContextNode(id); + } + }); + + $(this.element).on('click', (event) => { + // Ensure event didn't originate from context menu element + if ( + this.contextMenu?.element && + $(this.contextMenu.element).has(event.target).length + ) + return; + + // Hide context menu + this.contextMenu?.hide(); + }); + + setTimeout(async () => { + // Render connections + await this.renderConnections(); + + // Bind context menu + this.contextMenu?.bind( + ['.slot:not(.empty)', '.connection'], + MouseButton.Secondary, + ); + }); + } + + protected override async _preFirstRender( + context: AnyObject, + options: AnyObject, + ) { + await super._preFirstRender(context, options); + + // Bind to items + await Promise.all( + this.item.system.nodes.map(async (node) => { + // Get item + const item = (await fromUuid(node.uuid)) as CosmereItem | null; + if (!item) return; + + item.apps[this.id] = this; + }), + ); + + // Create context menu + this.contextMenu ??= AppContextMenu.create({ + parent: this, + items: (element) => [ + ...($(element).hasClass('slot') + ? [ + { + name: 'GENERIC.Button.Edit', + icon: 'fa-solid fa-edit', + callback: async () => { + // Get id + const id = $(element).data('id') as string; + + // Get node + const node = this.item.system.nodes.get(id); + if (!node) return; + + // Get item + const item = (await fromUuid( + node.uuid, + )) as CosmereItem | null; + if (!item) return; + + // Edit the item + item.sheet?.render(true); + }, + }, + ] + : []), + + { + name: 'GENERIC.Button.Remove', + icon: 'fa-solid fa-trash', + callback: async () => { + if ($(element).hasClass('slot')) { + // Get id + const id = $(element).data('id') as string; + + // Remove the node (and its connections) + this.item.system.nodes.delete(id); + + // Update + void this.item.update({ + [`system.nodes.-=${id}`]: null, + }); + } else { + // Get from and to ids + const fromId = $(element).data('from') as string; + const toId = $(element).data('to') as string; + + // Get from nodes + const toNode = this.item.system.nodes.get(toId); + const fromNode = this.item.system.nodes.get(fromId); + if (!toNode || !fromNode) return; + + // Get items + const fromItem = (await fromUuid( + fromNode.uuid, + )) as CosmereItem | null; + const toItem = (await fromUuid( + toNode.uuid, + )) as CosmereItem | null; + if (!fromItem || !toItem) return; + if (!fromItem.isTalent() || !toItem.isTalent()) + return; + + // Get talent prerequisites + const talentPrerequisites = + toItem.system.prerequisitesArray.filter( + (prerequisite) => + prerequisite.type === + Talent.Prerequisite.Type.Talent, + ); + + // Find the prerequisite rule that requires the from node talent + const ruleIndex = talentPrerequisites.findIndex( + (rule) => + rule.talents.some( + (ref) => ref.id === fromItem.system.id, + ), + ); + + // Get the rule + const rule = talentPrerequisites[ruleIndex]; + + // Remove the talent from the rule + rule.talents = rule.talents.filter( + (ref) => ref.id !== fromItem.system.id, + ); + + // If the rule is now empty, remove it + if (rule.talents.length === 0) { + await toItem.update({ + [`system.prerequisites.-=${rule.id}`]: null, + }); + } else { + await toItem.update({ + [`system.prerequisites.${rule.id}.talents`]: + rule.talents, + }); + } + + // Render + void this.render(true); + } + }, + }, + ], + anchor: 'cursor', + mouseButton: MouseButton.Secondary, + }); + + // Set active + this.contextMenu.setActive(this.isEditMode); + } + + protected override _onModeChange() { + super._onModeChange(); + + // Update context menu + this.contextMenu?.setActive(this.isEditMode); + } + + protected override _onClose(options?: AnyObject) { + super._onClose(options); + + // Unbind from context actor + if (this.contextActor) { + delete this.contextActor.apps[this.id]; + } + + // Unbind from items + this.item.system.nodes.forEach(async (node) => { + const item = (await fromUuid(node.uuid)) as CosmereItem | null; + if (!item) return; + + delete item.apps[this.id]; + }); + + // Destroy context menu + this.contextMenu?.destroy(); + } + + /* --- Context --- */ + + public async _prepareContext( + options: DeepPartial, + ) { + const rows = this.item.system.height; + const columns = this.item.system.width; + + // Prepare grid template data + const gridTemplate = { + columns: `${(100 / columns).toFixed(3)}% `.repeat(columns).trim(), + }; + + // Extend node data + await this.extendNodeData(); + + return { + ...(await super._prepareContext(options)), + item: this.item, + isEditMode: this.isEditMode, + editable: this.isEditable, + + rows, + columns, + cells: this.prepareCells(rows, columns), + gridTemplate, + enrichedDescriptions: await this.prepareNodeDescriptions(), + }; + } + + private prepareCells(rows: number, columns: number) { + const cells = new Array(rows) + .fill(null) + .map(() => + new Array(columns).fill(null), + ) as (ExtendedNode | null)[][]; + + this.nodes.forEach((node) => { + cells[node.position.row][node.position.column] = node; + }); + + return cells; + } + + private async prepareNodeDescriptions() { + const descriptions: Record = {}; + + await Promise.all( + this.item.system.nodes.map(async (node) => { + // Look up item + const item = (await fromUuid(node.uuid)) as CosmereItem | null; + if (!item?.isTalent()) return; + + // Get html + let html = await TextEditor.enrichHTML( + item.system.description?.value ?? '', + { + documents: false, + }, + ); + + // Replace UUIDs + const matches = [...html.matchAll(DOCUMENT_UUID_REGEX)]; + matches.forEach( + (match) => (html = html.replace(match[0], match[1])), + ); + + // Store + descriptions[node.id] = html; + }), + ); + + return descriptions; + } + + /* --- Helpers --- */ + + private addContextNode(node: TalentTree.Node): void; + private addContextNode(nodeId: string): void; + private addContextNode(param: TalentTree.Node | string): void { + // Get node id + const id = typeof param === 'string' ? param : param.id; + + // Add to context + this._contextNodeIds.add(id); + + // Get element + const el = $(this.element).find(`.slot[data-id="${id}"]`); + + // Add context class + el.addClass('context'); + } + + private removeContextNode(node: TalentTree.Node): void; + private removeContextNode(nodeId: string): void; + private removeContextNode(param: TalentTree.Node | string): void { + // Get node id + const id = typeof param === 'string' ? param : param.id; + + // Remove from context + this._contextNodeIds.delete(id); + + // Get element + const el = $(this.element).find(`.slot[data-id="${id}"]`); + + // Remove context class + el.removeClass('context'); + } + + private clearContextNodes() { + // Clear context class + $(this.element).find('.slot.context').removeClass('context'); + + // Clear context nodes + this._contextNodeIds.clear(); + } + + private hasContextNode(nodeId: string): boolean { + return this._contextNodeIds.has(nodeId); + } + + private async renderConnections() { + // Get connections + const connections = await this.getConnections(); + + // Render connections + this.nodes + .filter( + (node) => + connections.has(node.id) && + connections.get(node.id)!.size > 0, + ) + .forEach((node) => + connections.get(node.id)!.forEach((connectionId) => { + // Get connected node + const connectedNode = this.nodes.get(connectionId); + if (!connectedNode) return; + + this.renderConnection(node, connectedNode); + }), + ); + } + + private renderConnection(fromNode: ExtendedNode, toNode: ExtendedNode) { + // Get container element + const container = $(this.element).find('.container'); + + // Get connections element + const connections = container.find('.connections'); + + // Get grid positions + const startPos = fromNode.position; + const endPos = toNode.position; + + // Get elements + const startEl = $(this.element).find( + `[data-row="${startPos.row}"][data-column="${startPos.column}"]`, + ); + const endEl = $(this.element).find( + `[data-row="${endPos.row}"][data-column="${endPos.column}"]`, + ); + + // Get true positions + const start = { + x: + startEl.offset()!.left - + container.offset()!.left + + startEl.width()! / 2, + y: + startEl.offset()!.top - + container.offset()!.top + + startEl.height()! / 2, + }; + const end = { + x: + endEl.offset()!.left - + container.offset()!.left + + endEl.width()! / 2, + y: + endEl.offset()!.top - + container.offset()!.top + + endEl.height()! / 2, + }; + + // Calculate angle + const angle = Math.atan2(end.y - start.y, end.x - start.x); + + // Calculate distance + const distance = Math.sqrt( + Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2), + ); + + // Create connection + const connection = $( + `
`, + ); + + // Set position + connection.css({ + position: 'absolute', + left: start.x, + top: start.y, + }); + + // Set size + connection.width(distance); + + // Set angle + connection.css('transform', `rotate(${angle}rad)`); + + // Add available class + if (fromNode.obtained && toNode.available) { + connection.addClass('available'); + } + + // Add obtained class + if (fromNode.obtained && toNode.obtained) { + connection.addClass('obtained'); + } + + // Append to connections + connections.append(connection); + } + + private async getConnections(): Promise>> { + // Get the nodes + const nodes = this.nodes; + + // Prepare nodes id map + const nodeIdMap = new Map(); // Maps item system id to node id + + // Prepare connections + const connections = new Map>(); + + // Process nodes + await Promise.all( + nodes.map(async (node) => { + // Get item + const item = (await fromUuid(node.uuid)) as CosmereItem | null; + if (!item?.isTalent()) return; + + // Set node id + nodeIdMap.set(item.system.id, node.id); + + // Get talent prerequisites + const talentPrerequisites = + item.system.prerequisitesArray.filter( + (prerequisite) => + prerequisite.type === + Talent.Prerequisite.Type.Talent, + ); + + // Process prerequisites + await Promise.all( + talentPrerequisites.map(async (rule) => { + // Get required talents + const requiredTalents = ( + await Promise.all( + rule.talents.map( + async (ref) => + fromUuid( + ref.uuid, + ) as Promise, + ), + ) + ).filter((v) => !!v); + + // Set connections + requiredTalents.forEach((talent) => { + const talentConnections = + connections.get(talent.system.id) ?? + connections + .set(talent.system.id, new Set()) + .get(talent.system.id)!; + + talentConnections.add(item.system.id); + }); + }), + ); + }), + ); + + // Convert system ids to node ids + return new Map( + Array.from(connections.entries()) + .filter(([id]) => nodeIdMap.has(id)) + .map(([id, nodeItemIds]) => { + // Look up node id + const nodeId = nodeIdMap.get(id)!; + + // Look up connected node ids + const nodeIds = new Set( + Array.from(nodeItemIds).map((id) => nodeIdMap.get(id)!), + ); + + // Return + return [nodeId, nodeIds] as [string, Set]; + }), + ); + } + + private async extendNodeData(): Promise { + this.nodes = new Collection( + ( + await Promise.all( + this.item.system.nodes.map(async (node) => { + // Get item + const item = (await fromUuid( + node.uuid, + )) as TalentItem | null; + if (!item) return null; + + // Check if the context actor has the talent + const obtained = + this.contextActor?.hasTalent(item.system.id) ?? + false; + + // Check if the context actor can obtain the talent + const available = + !obtained && + (this.contextActor?.hasTalentPrerequisites(item) ?? + false); + + return [ + node.id, + { + ...node, + item, + obtained, + available, + }, + ] as [string, ExtendedNode]; + }), + ) + ).filter((v) => !!v), + ); + } +} + +function calculatePosition(tree: TalentTreeItem) { + return { + width: tree.system.width * COLUMN_WIDTH + PADDING * 2, + height: tree.system.height * ROW_HEIGHT + HEADER_HEIGHT + PADDING * 2, + }; +} diff --git a/src/system/applications/item/trait-sheet.ts b/src/system/applications/item/trait-sheet.ts index d8ec1f75..ceadb2bb 100644 --- a/src/system/applications/item/trait-sheet.ts +++ b/src/system/applications/item/trait-sheet.ts @@ -1,5 +1,6 @@ import { TraitItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class TraitItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'trait'], + classes: [SYSTEM_ID, 'sheet', 'item', 'trait'], position: { width: 550, height: 500, diff --git a/src/system/applications/item/weapon-sheet.ts b/src/system/applications/item/weapon-sheet.ts index 32fd202e..4c26d707 100644 --- a/src/system/applications/item/weapon-sheet.ts +++ b/src/system/applications/item/weapon-sheet.ts @@ -1,5 +1,6 @@ import { WeaponItem } from '@system/documents/item'; import { DeepPartial } from '@system/types/utils'; +import { SYSTEM_ID } from '@src/system/constants'; // Base import { BaseItemSheet } from './base'; @@ -8,7 +9,7 @@ export class WeaponItemSheet extends BaseItemSheet { static DEFAULT_OPTIONS = foundry.utils.mergeObject( foundry.utils.deepClone(super.DEFAULT_OPTIONS), { - classes: ['cosmere-rpg', 'sheet', 'item', 'weapon'], + classes: [SYSTEM_ID, 'sheet', 'item', 'weapon'], position: { width: 730, }, diff --git a/src/system/applications/mixins/edit-mode.ts b/src/system/applications/mixins/edit-mode.ts new file mode 100644 index 00000000..2e930257 --- /dev/null +++ b/src/system/applications/mixins/edit-mode.ts @@ -0,0 +1,101 @@ +import { ConstructorOf } from '@system/types/utils'; + +import { SYSTEM_ID } from '@system/constants'; + +export type SheetMode = 'view' | 'edit'; + +/** + * Mixin that adds an edit mode to an ApplicationV2 + */ +export function EditModeApplicationMixin< + T extends ConstructorOf< + // NOTE: Use of any as the mixin doesn't care about the types + // and we don't want to interfere with the final type + // eslint-disable-next-line @typescript-eslint/no-explicit-any + foundry.applications.api.DocumentSheetV2 + >, +>(base: T) { + return class mixin extends base { + /* --- Accessors --- */ + + public get mode(): SheetMode { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return this.document.getFlag(SYSTEM_ID, 'sheet.mode') ?? 'view'; + } + + public get isEditMode(): boolean { + return this.mode === 'edit' && this.isEditable; + } + + /* --- Public Functions --- */ + + public async setMode(mode: SheetMode) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await this.document.setFlag(SYSTEM_ID, 'sheet.mode', mode); + + // Get toggle + const toggle = $(this.element).find('#mode-toggle'); + + // Update checked status + toggle.find('input').prop('checked', this.mode === 'edit'); + + // Set mode class + $(this.element) + .removeClass(`mode-${mode === 'view' ? 'edit' : 'view'}`) + .addClass(`mode-${mode}`); + + // Call mode change event + this._onModeChange(); + } + + /* --- Lifecycle --- */ + + protected async _renderFrame( + options: Partial, + ): Promise { + const frame = await super._renderFrame(options); + + // Insert mode toggle + if (this.isEditable) { + $(this.window.title!).before(` + + `); + + $(this.window.header!) + .find('#mode-toggle') + .on('click', this.onToggleMode.bind(this)); + } + + // Set mode class + $(frame).addClass(`mode-${this.mode}`); + + return frame; + } + + /* --- Handlers --- */ + + private async onToggleMode(event: Event) { + if (!(event.target instanceof HTMLInputElement)) return; + + event.preventDefault(); + event.stopPropagation(); + + // Toggle mode + await this.setMode(this.mode === 'view' ? 'edit' : 'view'); + } + + /* --- Lifecylce events --- */ + + /** + * Gets called after the mode has been changed + */ + protected _onModeChange() {} + }; +} diff --git a/src/system/applications/mixins/index.ts b/src/system/applications/mixins/index.ts index 7198aa46..49006902 100644 --- a/src/system/applications/mixins/index.ts +++ b/src/system/applications/mixins/index.ts @@ -1,2 +1,3 @@ export * from './tabs'; export * from './drag-drop'; +export * from './edit-mode'; diff --git a/src/system/applications/utils/context-menu.ts b/src/system/applications/utils/context-menu.ts index 2c54e7b5..a6e33c08 100644 --- a/src/system/applications/utils/context-menu.ts +++ b/src/system/applications/utils/context-menu.ts @@ -1,3 +1,5 @@ +import { MouseButton } from '@system/types/utils'; + export namespace AppContextMenu { export interface Item { name: string; @@ -14,9 +16,49 @@ export namespace AppContextMenu { ) => void; } - export type Anchor = 'left' | 'right'; + export type Anchor = 'left' | 'right' | 'cursor'; + + export interface Config { + /** + * The host for the context menu. + * This is generally either an Application or an Application Component. + */ + parent: Parent; + + /** + * The items to display in the context menu. + */ + items: Item[] | ((element: HTMLElement) => Item[]); + + /** + * The selectors to bind the context menu to. + */ + selectors?: string[]; + + /** + * Where the context menu should be anchored. + * + * - `left`: Anchored to the left of the element. + * - `right`: Anchored to the right of the element. + * - `cursor`: Anchored to the location of the cursor when the context menu was opened. + * + * @default 'left' + */ + anchor?: Anchor; + + /** + * The mouse button that should trigger the context menu. + * + * @default MouseButton.Primary + */ + mouseButton?: MouseButton; + } } +type Positioning = { + top: number; +} & ({ right: number } | { left: number }); + // Constants const TEMPLATE = '/systems/cosmere-rpg/templates/general/context-menu.hbs'; @@ -24,21 +66,40 @@ export class AppContextMenu { /** * The root element of the context menu. */ - private element?: HTMLElement; + private _element?: HTMLElement; /** * The element that was clicked to open the context menu. */ private contextElement?: HTMLElement; private expanded = false; - private bound = false; + private rendered = false; + + private _active = true; - public constructor( + private items?: AppContextMenu.Item[]; + private itemsFn?: (element: HTMLElement) => AppContextMenu.Item[]; + + private constructor( private parent: AppContextMenu.Parent, private anchor: AppContextMenu.Anchor, - private items: AppContextMenu.Item[], + items: + | AppContextMenu.Item[] + | ((element: HTMLElement) => AppContextMenu.Item[]), ) { - void this.render(); + if (typeof items === 'function') { + this.itemsFn = items; + } else { + this.items = items; + } + } + + public get element(): HTMLElement | undefined { + return this._element; + } + + public get active(): boolean { + return this._active; } /** @@ -48,119 +109,160 @@ export class AppContextMenu { * * This function takes care of re-binding on render. */ - public static create( - parent: AppContextMenu.Parent, - anchor: AppContextMenu.Anchor, - items: AppContextMenu.Item[], - ...selectors: string[] - ): AppContextMenu { + public static create(config: AppContextMenu.Config): AppContextMenu { + // Destructure config + const { + parent, + items, + selectors, + anchor = 'left', + mouseButton, + } = config; + // Create context menu const menu = new AppContextMenu(parent, anchor, items); // Add event listener - parent.addEventListener('render', async () => { - await menu.render(); - menu.bind(...selectors); - }); + if (selectors) { + parent.addEventListener('render', () => { + menu.bind(selectors, mouseButton); + }); + } return menu; } - public bind(...selectors: string[]): void; - public bind(...elements: HTMLElement[]): void; - public bind(...params: string[] | HTMLElement[]): void { - if (this.bound) return; - if (params.length === 0) return; + public bind(selectors: string[], mouseButton?: MouseButton): void; + public bind(elements: HTMLElement[], mouseButton?: MouseButton): void; + public bind( + param1: string[] | HTMLElement[], + mouseButton: MouseButton = MouseButton.Primary, + ): void { + if (param1.length === 0) return; const elements: HTMLElement[] = []; - if (typeof params[0] === 'string') { + if (typeof param1[0] === 'string') { elements.push( - ...params + ...param1 .map((selector) => $(this.parent.element).find(selector).toArray(), ) .flat(), ); } else { - elements.push(...(params as HTMLElement[])); + elements.push(...(param1 as HTMLElement[])); } + // Get the event to bind to + const event = + mouseButton === MouseButton.Primary ? 'click' : 'contextmenu'; + // Attach listeners elements.forEach((element) => { - element.addEventListener('click', () => { + element.addEventListener(event, (event) => { const shouldShow = !this.expanded || this.contextElement !== element; if (this.expanded) this.hide(); - if (shouldShow) { + if (shouldShow && this._active) { + const rootBounds = + this.parent.element.getBoundingClientRect(); + const positioning = + this.anchor === 'cursor' + ? { + top: event.clientY - rootBounds.top, + left: event.clientX - rootBounds.left, + } + : undefined; + setTimeout(() => { - this.show(element); + void this.show(element, positioning); }); } }); }); - - // Set as bound - this.bound = true; } - private show(element: HTMLElement) { + public async show(element: HTMLElement, positioning?: Positioning) { + // If the context element is different and items are dynamic, always re-render + if (element !== this.contextElement && this.itemsFn) + this.rendered = false; + // Set the context element this.contextElement = element; - // Get element bounds - const elementBounds = element.getBoundingClientRect(); - const rootBounds = this.parent.element.getBoundingClientRect(); - - // Figure out positioning with anchor - const positioning = { - top: elementBounds.bottom - rootBounds.top, - - ...(this.anchor === 'right' - ? { - right: rootBounds.right - elementBounds.right, - } - : { - left: elementBounds.left - rootBounds.left, - }), - }; + // Get items + if (this.itemsFn) this.items = this.itemsFn(this.contextElement); + + // If not rendered yet, render now + if (!this.rendered) await this.render(); + + if (!positioning) { + // Get element bounds + const elementBounds = element.getBoundingClientRect(); + const rootBounds = this.parent.element.getBoundingClientRect(); + + // Figure out positioning with anchor + positioning = { + top: elementBounds.bottom - rootBounds.top, + + ...(this.anchor === 'right' + ? { + right: rootBounds.right - elementBounds.right, + } + : { + left: elementBounds.left - rootBounds.left, + }), + }; + } // Set positioning - $(this.element!).css('top', `${positioning.top}px`); + $(this._element!).css('top', `${positioning.top}px`); if ('right' in positioning) - $(this.element!).css('right', `${positioning.right}px`); - else $(this.element!).css('left', `${positioning.left}px`); + $(this._element!).css('right', `${positioning.right}px`); + else $(this._element!).css('left', `${positioning.left}px`); // Remove hidden - $(this.element!).addClass('expanded'); - $(this.element!).removeClass('hidden'); + $(this._element!).addClass('expanded'); + $(this._element!).removeClass('hidden'); + + if (this.anchor === 'cursor' && positioning) { + $(this._element!).addClass('free'); + } // Set expanded this.expanded = true; } - private hide() { + public hide() { // Hide - $(this.element!).removeClass('expanded'); - $(this.element!).addClass('hidden'); - - // Clear context - this.contextElement = undefined; + $(this._element!).removeClass('expanded'); + $(this._element!).addClass('hidden'); // Unset expanded this.expanded = false; + + if (this.itemsFn) this.items = undefined; + } + + public setActive(active: boolean) { + this._active = active; + + if (!this._active) { + this.hide(); + } } public async render(): Promise { // Render the element - this.element = await this.renderElement(); + this._element = await this.renderElement(); // Add hidden class - $(this.element).addClass('hidden'); + $(this._element).addClass('hidden'); // Attach listeners - $(this.element) + $(this._element) .find('button[data-item]') .on('click', (event) => { // Get the index @@ -169,7 +271,7 @@ export class AppContextMenu { ); // Get the item - const item = this.items[index]; + const item = this.items![index]; // Trigger the callback if (item.callback) item.callback(this.contextElement!); @@ -179,15 +281,19 @@ export class AppContextMenu { }); // Add element to parent - this.parent.element.appendChild(this.element); + this.parent.element.appendChild(this._element); + } - // Set as not bound - this.bound = false; + public destroy(): void { + if (this._element) { + this._element.remove(); + this._element = undefined; + } } private async renderElement(): Promise { const htmlStr = await renderTemplate(TEMPLATE, { - items: this.items.map((item) => ({ + items: this.items!.map((item) => ({ ...item, cssClasses: item.classes?.join(' ') ?? '', })), diff --git a/src/system/config.ts b/src/system/config.ts index 3bdc3ce5..2eb58be4 100644 --- a/src/system/config.ts +++ b/src/system/config.ts @@ -30,10 +30,12 @@ import { PathType, EquipHand, EquipmentType, + PowerType, + Theme, } from './types/cosmere'; import { AdvantageMode } from './types/roll'; -import { Talent } from './types/item'; +import { Talent, Goal } from './types/item'; const COSMERE: CosmereRPGConfig = { sizes: { @@ -75,6 +77,11 @@ const COSMERE: CosmereRPGConfig = { }, }, + themes: { + [Theme.Default]: 'COSMERE.Theme.Default', + [Theme.Stormlight]: 'COSMERE.Theme.Stormlight', + }, + conditions: { [Condition.Afflicted]: { label: 'COSMERE.Conditions.Afflicted', @@ -256,111 +263,111 @@ const COSMERE: CosmereRPGConfig = { key: Skill.Agility, label: 'COSMERE.Actor.Skill.Agility', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Athletics]: { key: Skill.Athletics, label: 'COSMERE.Actor.Skill.Athletics', attribute: Attribute.Strength, - attrLabel: 'COSMERE.Actor.Attribute.Strength.short', + core: true, }, [Skill.HeavyWeapons]: { key: Skill.HeavyWeapons, label: 'COSMERE.Actor.Skill.HeavyWeapons', attribute: Attribute.Strength, - attrLabel: 'COSMERE.Actor.Attribute.Strength.short', + core: true, }, [Skill.LightWeapons]: { key: Skill.LightWeapons, label: 'COSMERE.Actor.Skill.LightWeapons', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Stealth]: { key: Skill.Stealth, label: 'COSMERE.Actor.Skill.Stealth', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Thievery]: { key: Skill.Thievery, label: 'COSMERE.Actor.Skill.Thievery', attribute: Attribute.Speed, - attrLabel: 'COSMERE.Actor.Attribute.Speed.short', + core: true, }, [Skill.Crafting]: { key: Skill.Crafting, label: 'COSMERE.Actor.Skill.Crafting', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Deduction]: { key: Skill.Deduction, label: 'COSMERE.Actor.Skill.Deduction', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Discipline]: { key: Skill.Discipline, label: 'COSMERE.Actor.Skill.Discipline', attribute: Attribute.Willpower, - attrLabel: 'COSMERE.Actor.Attribute.Willpower.short', + core: true, }, [Skill.Intimidation]: { key: Skill.Intimidation, label: 'COSMERE.Actor.Skill.Intimidation', attribute: Attribute.Willpower, - attrLabel: 'COSMERE.Actor.Attribute.Willpower.short', + core: true, }, [Skill.Lore]: { key: Skill.Lore, label: 'COSMERE.Actor.Skill.Lore', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Medicine]: { key: Skill.Medicine, label: 'COSMERE.Actor.Skill.Medicine', attribute: Attribute.Intellect, - attrLabel: 'COSMERE.Actor.Attribute.Intellect.short', + core: true, }, [Skill.Deception]: { key: Skill.Deception, label: 'COSMERE.Actor.Skill.Deception', attribute: Attribute.Presence, - attrLabel: 'COSMERE.Actor.Attribute.Presence.short', + core: true, }, [Skill.Insight]: { key: Skill.Insight, label: 'COSMERE.Actor.Skill.Insight', attribute: Attribute.Awareness, - attrLabel: 'COSMERE.Actor.Attribute.Awareness.short', + core: true, }, [Skill.Leadership]: { key: Skill.Leadership, label: 'COSMERE.Actor.Skill.Leadership', attribute: Attribute.Presence, - attrLabel: 'COSMERE.Actor.Attribute.Presence.short', + core: true, }, [Skill.Perception]: { key: Skill.Perception, label: 'COSMERE.Actor.Skill.Perception', attribute: Attribute.Awareness, - attrLabel: 'COSMERE.Actor.Attribute.Awareness.short', + core: true, }, [Skill.Persuasion]: { key: Skill.Persuasion, label: 'COSMERE.Actor.Skill.Persuasion', attribute: Attribute.Presence, - attrLabel: 'COSMERE.Actor.Attribute.Presence.short', + core: true, }, [Skill.Survival]: { key: Skill.Survival, label: 'COSMERE.Actor.Skill.Survival', attribute: Attribute.Awareness, - attrLabel: 'COSMERE.Actor.Attribute.Awareness.short', + core: true, }, }, @@ -442,6 +449,20 @@ const COSMERE: CosmereRPGConfig = { desc_placeholder: 'COSMERE.Item.Type.Connection.desc_placeholder', }, + [ItemType.Goal]: { + label: 'COSMERE.Item.Type.Goal.label', + labelPlural: 'COSMERE.Item.Type.Goal.label_plural', + desc_placeholder: 'COSMERE.Item.Type.Goal.desc_placeholder', + }, + [ItemType.Power]: { + label: 'COSMERE.Item.Type.Power.label', + labelPlural: 'COSMERE.Item.Type.Power.label_plural', + desc_placeholder: 'COSMERE.Item.Type.Power.desc_placeholder', + }, + [ItemType.TalentTree]: { + label: 'COSMERE.Item.Type.TalentTree.label', + labelPlural: 'COSMERE.Item.Type.TalentTree.label_plural', + }, }, activation: { types: { @@ -516,31 +537,52 @@ const COSMERE: CosmereRPGConfig = { }, }, }, + goal: { + rewards: { + types: { + [Goal.Reward.Type.Items]: + 'COSMERE.Item.Goal.Reward.Type.Items', + [Goal.Reward.Type.SkillRanks]: + 'COSMERE.Item.Goal.Reward.Type.SkillRanks', + }, + }, + }, talent: { types: { [Talent.Type.Ancestry]: { - label: 'COSMERE.Talent.Type.Ancestry', + label: 'COSMERE.Item.Talent.Type.Ancestry', }, [Talent.Type.Path]: { - label: 'COSMERE.Talent.Type.Path', + label: 'COSMERE.Item.Talent.Type.Path', + }, + [Talent.Type.Power]: { + label: 'COSMERE.Item.Talent.Type.Power', }, }, prerequisite: { types: { [Talent.Prerequisite.Type.Talent]: - 'COSMERE.Talent.Prerequisite.Type.Talent', + 'COSMERE.Item.Talent.Prerequisite.Type.Talent', [Talent.Prerequisite.Type.Attribute]: - 'COSMERE.Talent.Prerequisite.Type.Attribute', + 'COSMERE.Item.Talent.Prerequisite.Type.Attribute', [Talent.Prerequisite.Type.Skill]: - 'COSMERE.Talent.Prerequisite.Type.Skill', + 'COSMERE.Item.Talent.Prerequisite.Type.Skill', [Talent.Prerequisite.Type.Connection]: - 'COSMERE.Talent.Prerequisite.Type.Connection', + 'COSMERE.Item.Talent.Prerequisite.Type.Connection', + [Talent.Prerequisite.Type.Level]: + 'COSMERE.Item.Talent.Prerequisite.Type.Level', }, modes: { [Talent.Prerequisite.Mode.AnyOf]: - 'COSMERE.Talent.Prerequisite.Mode.AnyOf', + 'COSMERE.Item.Talent.Prerequisite.Mode.AnyOf', [Talent.Prerequisite.Mode.AllOf]: - 'COSMERE.Talent.Prerequisite.Mode.AllOf', + 'COSMERE.Item.Talent.Prerequisite.Mode.AllOf', + }, + }, + grantRules: { + types: { + [Talent.GrantRule.Type.Items]: + 'COSMERE.Item.Talent.GrantRule.Type.Items', }, }, }, @@ -657,6 +699,9 @@ const COSMERE: CosmereRPGConfig = { [ArmorTraitId.Presentable]: { label: 'COSMERE.Item.Armor.Trait.Presentable', }, + [ArmorTraitId.Unique]: { + label: 'COSMERE.Item.Armor.Trait.Unique', + }, }, }, @@ -723,6 +768,15 @@ const COSMERE: CosmereRPGConfig = { }, }, + power: { + types: { + [PowerType.None]: { + label: 'COSMERE.Item.Type.Power.label', + plural: 'COSMERE.Item.Type.Power.label_plural', + }, + }, + }, + damageTypes: { [DamageType.Energy]: { label: 'COSMERE.DamageTypes.Energy', diff --git a/src/system/constants.ts b/src/system/constants.ts index bc49f96f..b20fde70 100644 --- a/src/system/constants.ts +++ b/src/system/constants.ts @@ -1,3 +1,13 @@ +/** + * String identifier for the module used throughout other scripts. + */ +export const SYSTEM_ID = 'cosmere-rpg'; + +/** + * Full title string of the module. + */ +export const SYSTEM_NAME = 'Cosmere Roleplaying Game'; + export const IMPORTED_RESOURCES = { PLOT_DICE_BLANK_BUMP: 'https://dl.dropboxusercontent.com/scl/fi/r3vsouxcj96cexs3zy1gx/Bl_bump.png?rlkey=65ig8615u0nb7n71lppyxnjom&st=8il45as4&raw=1&t=.png', @@ -7,20 +17,17 @@ export const IMPORTED_RESOURCES = { 'https://dl.dropboxusercontent.com/scl/fi/770dktjbo09h6il3y8ab4/Bl.png?rlkey=ilw734db8l39jnz70bc6b3svs&st=15daew07&raw=1&t=.png', PLOT_DICE_C2_BUMP: 'https://dl.dropboxusercontent.com/scl/fi/1te3mq6nrezbq9k00wuob/C2_bump.png?rlkey=i3j5kxwq983428lupocbbur5x&st=dptzdfqt&raw=1&t=.png', - PLOT_DICE_C2_IN_CHAT: - 'https://dl.dropboxusercontent.com/scl/fi/2mpovuig8lkabe9i2zsmv/C2_inCHAT.png?rlkey=ez3g2aveldiiytd64wbq6pbut&st=e6w81lej&raw=1&t=.png', + PLOT_DICE_C2_IN_CHAT: `https://dl.dropboxusercontent.com/scl/fi/q05rzx18fi7zo1aatg390/complication2.svg?rlkey=lb003obs65krz3j435ug0c8pu&st=6mjdjcrt&raw=1&t=.svg`, PLOT_DICE_C2: 'https://dl.dropboxusercontent.com/scl/fi/40qesn7zjy3rai9dfcwau/C2.png?rlkey=96terb8lmr3mwvr7jsu8of7xv&st=3t0liw8t&raw=1&t=.png', PLOT_DICE_C4_BUMP: 'https://dl.dropboxusercontent.com/scl/fi/ywrm6nrtx6rds7v44yd0n/C4_bump.png?rlkey=zbbpbz77s465fomkn6ddm1o0f&st=kem4ak9i&raw=1&t=.png', - PLOT_DICE_C4_IN_CHAT: - 'https://dl.dropboxusercontent.com/scl/fi/thq63h3fogm602o7j1iwv/C4_inCHAT.png?rlkey=y2cvopo7p8mbh8gosltheydbr&st=s81ffa4w&raw=1&t=.png', + PLOT_DICE_C4_IN_CHAT: `https://dl.dropboxusercontent.com/scl/fi/95ucp8mvwq3a04of43vxo/complication4.svg?rlkey=9werjynmmwliztfsagklrub99&st=gpbz5d4d&raw=1&t=.svg`, PLOT_DICE_C4: 'https://dl.dropboxusercontent.com/scl/fi/q0gcvsxuzu7l8gkinr2l0/C4.png?rlkey=7mgiwilc2whw5yffn83gkzuy1&st=e6xfa5wb&raw=1&t=.png', PLOT_DICE_OP_BUMP: 'https://dl.dropboxusercontent.com/scl/fi/mm59iyru3i7d20xepp99b/Op_bump.png?rlkey=azrpwksrcipzacsneki25uxa4&st=gj5g2qkq&raw=1&t=.png', - PLOT_DICE_OP_IN_CHAT: - 'https://dl.dropboxusercontent.com/scl/fi/sldas3yapt8f139fe1ioi/Op_inCHAT.png?rlkey=qwf84kra0l1nlp2lzpb8fekih&st=6kukji6d&raw=1&t=.png', + PLOT_DICE_OP_IN_CHAT: `https://dl.dropboxusercontent.com/scl/fi/f1933ru90fybmv7mnjswk/opportunity.svg?rlkey=hz26jaxma6hyg3eiyzf29vs62&st=vzqy6z9h&raw=1&t=.svg`, PLOT_DICE_OP: 'https://dl.dropboxusercontent.com/scl/fi/cdswnywdr57sp79l4f9zd/Op.png?rlkey=4v6pgtxcrmwuzgazjgcpbs4xq&st=qnzdxl58&raw=1&t=.png', }; diff --git a/src/system/data/actor/character.ts b/src/system/data/actor/character.ts index 199cc44c..c01b20a1 100644 --- a/src/system/data/actor/character.ts +++ b/src/system/data/actor/character.ts @@ -23,7 +23,7 @@ export interface CharacterActorData extends CommonActorData { /* --- Goals, Connections, Purpose, and Obstacle --- */ purpose: string; obstacle: string; - goals: GoalData[]; + goals?: GoalData[]; connections: ConnectionData[]; } @@ -76,8 +76,8 @@ export class CharacterActorDataModel extends CommonActorDataModel { + /** + * The natural deflect value for this actor. + * This value is used when deflect cannot be derived from its source, or + * when the natural value is higher than the derived value. + */ + natural?: number; + + /** + * The source of the deflect value + */ source?: DeflectSource; } @@ -74,7 +83,18 @@ export interface CommonActorData { >; skills: Record< Skill, - { attribute: Attribute; rank: number; mod: Derived } + { + attribute: Attribute; + rank: number; + mod: Derived; + + /** + * Derived field describing whether this skill is unlocked or not. + * This field is only present for non-core skills. + * Core skills are always unlocked. + */ + unlocked?: boolean; + } >; injuries: Derived; injuryRollBonus: number; @@ -175,6 +195,14 @@ export class CommonActorDataModel< }), { additionalFields: { + natural: new foundry.data.fields.NumberField({ + required: false, + nullable: true, + integer: true, + initial: 0, + label: 'COSMERE.Deflect.Natural.Label', + hint: 'COSMERE.Deflect.Natural.Hint', + }), source: new foundry.data.fields.StringField({ initial: DeflectSource.Armor, choices: Object.keys( @@ -388,6 +416,18 @@ export class CommonActorDataModel< initial: 0, }), ), + + // Only present for non-core skills + ...(!skills[key].core + ? { + unlocked: + new foundry.data.fields.BooleanField({ + required: true, + nullable: false, + initial: false, + }), + } + : {}), }); return schemas; @@ -539,22 +579,44 @@ export class CommonActorDataModel< this.skills[skill].mod.value = attrValue + rank; }); + // Derive non-core skill unlocks + (Object.keys(this.skills) as Skill[]).forEach((skill) => { + if (CONFIG.COSMERE.skills[skill].core) return; + + // Check if the actor has a power that unlocks this skill + const unlocked = this.parent.powers.some( + (power) => power.system.skill === skill, + ); + + // Set unlocked status + this.skills[skill].unlocked = unlocked; + }); + // Get deflect source, defaulting to armor const source = this.deflect.source ?? DeflectSource.Armor; // Derive deflect value if (source === DeflectSource.Armor) { - // Find equipped armor + // Get natural deflect value + const natural = this.deflect.natural ?? 0; + + // Find equipped armor with the highest deflect value const armor = this.parent.items - .filter((item) => item.type === ItemType.Armor) - .map( - (item) => - item as unknown as CosmereItem, - ) - .find((item) => item.system.equipped); + .filter((item) => item.isArmor()) + .filter((item) => item.system.equipped) + .reduce( + (highest, item) => + !highest || item.system.deflect > highest.system.deflect + ? item + : highest, + null as ArmorItem | null, + ); + + // Get armor deflect value + const armorDeflect = armor?.system.deflect ?? 0; // Derive deflect - this.deflect.value = armor?.system.deflect ?? 0; + this.deflect.value = Math.max(natural, armorDeflect); } // Movement diff --git a/src/system/data/fields/collection.ts b/src/system/data/fields/collection.ts new file mode 100644 index 00000000..4777728b --- /dev/null +++ b/src/system/data/fields/collection.ts @@ -0,0 +1,314 @@ +export type CollectionFieldOptions = foundry.data.fields.DataFieldOptions; + +/** + * A collection that is backed by a record object instead of a Map. + * This allows us to persit it properly and update items easily, + * while still having the convenience of a collection. + */ +export class RecordCollection implements Collection { + /** + * NOTE: Must use `any` here as we need the RecordCollection + * to be backing record object itself. This ensures its stored + * properly. + */ + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ + constructor(entries?: [string, T][]) { + if (entries) { + entries.forEach(([key, value]) => { + (this as any)[key] = value; + }); + } + } + + get contents(): T[] { + return Object.entries(this).map(([key, value]) => ({ + ...value, + _id: key, + })); + } + + public find( + condition: (e: T, index: number, collection: Collection) => e is S, + ): S | undefined; + public find( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T | undefined; + public find( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T | undefined { + return Object.entries(this).find(([key, value], index) => + condition({ ...value, _id: key }, index, this), + )?.[1]; + } + + public filter( + condition: (e: T, index: number, collection: Collection) => e is S, + ): S[]; + public filter( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T[]; + public filter( + condition: (e: T, index: number, collection: Collection) => boolean, + ): T[] { + return Object.entries(this) + .filter(([key, value], index) => + condition({ ...value, _id: key }, index, this), + ) + .map(([key, value]) => value); + } + + public has(key: string): boolean { + return key in this; + } + + public get(key: string, options: { strict: true }): T; + public get(key: string, options?: { strict: false }): T | undefined; + public get( + key: string, + options: { strict: boolean } = { strict: false }, + ): T | undefined { + if (!this.has(key)) { + if (options.strict) throw new Error(`key ${key} not found`); + return undefined; + } + return (this as any)[key]; + } + + public getName(name: string, options: { strict: true }): T; + public getName(name: string, options?: { strict: false }): T | undefined; + public getName( + name: string, + options: { strict: boolean } = { strict: false }, + ): T | undefined { + const record = this.contents.find( + (value) => + value && + typeof value === 'object' && + 'name' in value && + value.name === name, + ); + if (!record) { + if (options.strict) throw new Error(`name ${name} not found`); + return undefined; + } + return record; + } + + public map( + transformer: (entity: T, index: number, collection: Collection) => M, + ): M[] { + return Object.entries(this).map(([key, value], index) => + transformer({ ...value, _id: key }, index, this), + ); + } + + public reduce( + evaluator: ( + accumulator: A, + value: T, + index: number, + collection: Collection, + ) => A, + initialValue: A, + ): A { + return Object.entries(this).reduce( + (accumulator, [key, value], index) => + evaluator(accumulator, { ...value, _id: key }, index, this), + initialValue, + ); + } + + public some( + condition: ( + value: T, + index: number, + collection: Collection, + ) => boolean, + ): boolean { + return Object.entries(this).some(([key, value], index) => + condition({ ...value, _id: key }, index, this), + ); + } + + public set(key: string, value: T): this { + (this as any)[key] = value; + return this; + } + + public delete(key: string): boolean { + if (!this.has(key)) return false; + delete (this as any)[key]; + return true; + } + + public clear(): void { + Object.keys(this).forEach((key) => delete (this as any)[key]); + } + + public get size(): number { + return Object.keys(this).length; + } + + public entries(): IterableIterator<[string, T]> { + return Object.entries(this) as unknown as IterableIterator<[string, T]>; + } + + public keys(): IterableIterator { + return Object.keys(this)[Symbol.iterator](); + } + + public values(): IterableIterator { + return Object.keys(this) + .map((key) => ({ + ...this.get(key)!, + _id: key, + })) + [Symbol.iterator](); + } + + public forEach( + callbackfn: (value: T, key: string, map: this) => void, + thisArg?: any, + ): void { + Object.entries(this).forEach(([key, value]) => + callbackfn.call(thisArg, value, key, this), + ); + } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } + + // NOTE: This is implicitly readonly as we don't have a way to set it. + // eslint-disable-next-line @typescript-eslint/class-literal-property-style + get [Symbol.toStringTag]() { + return 'RecordCollection'; + } + + public toJSON(): (T extends { toJSON: (...args: any[]) => infer U } + ? U + : T)[] { + return this.contents.map((value) => { + if (value && typeof value === 'object' && 'toJSON' in value) { + return { ...(value as any).toJSON(), _id: (value as any)._id }; + } + return value; + }); + } + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment */ +} + +export class CollectionField< + ElementField extends + foundry.data.fields.DataField = foundry.data.fields.DataField, +> extends foundry.data.fields.ObjectField { + constructor( + public readonly model: ElementField, + options: CollectionFieldOptions = {}, + context?: foundry.data.fields.DataFieldContext, + private CollectionClass: typeof RecordCollection = RecordCollection, + ) { + super(options, context); + } + + protected override _cleanType( + value: RecordCollection, + options?: object, + ) { + Array.from(value.entries()).forEach(([id, v]) => { + value.set(id, this.model.clean(v, options)); + }); + + return value; + } + + protected override _validateType( + value: unknown, + options?: foundry.data.fields.DataFieldValidationOptions, + ): boolean | foundry.data.fields.DataModelValidationFailure | void { + if (!(value instanceof this.CollectionClass)) + throw new Error('must be a RecordCollection'); + const errors = this._validateValues(value, options); + if (!foundry.utils.isEmpty(errors)) { + // Create validatior failure + const failure = + new foundry.data.validation.DataModelValidationFailure(); + + // Set fields + failure.fields = errors; + + // Throw error + throw new foundry.data.validation.DataModelValidationError(failure); + } + } + + protected _validateValues( + value: RecordCollection, + options?: foundry.data.fields.DataFieldValidationOptions, + ) { + const errors: Record< + string, + foundry.data.validation.DataModelValidationFailure + > = {}; + Array.from(value.entries()).forEach(([id, v]) => { + const error = this.model.validate( + v, + options, + ) as foundry.data.validation.DataModelValidationFailure | null; + if (error) { + errors[id] = error; + } + }); + + return errors; + } + + protected override _cast(value: object) { + const result = + value instanceof this.CollectionClass + ? value + : foundry.utils.getType(value) === 'Map' + ? new this.CollectionClass( + Array.from((value as Map).entries()), + ) + : foundry.utils.getType(value) === 'Object' + ? new this.CollectionClass(Object.entries(value)) + : foundry.utils.getType(value) === 'Array' + ? new this.CollectionClass( + (value as { _id?: string; id?: string }[]).map( + (v, i) => [v._id ?? v.id ?? i.toString(), v], + ), + ) + : new this.CollectionClass(); + + return result; + } + + public override getInitialValue() { + return new this.CollectionClass(); + } + + public override initialize(value: RecordCollection) { + if (!value) return new this.CollectionClass(); + return foundry.utils.deepClone(value); + } + + public override toObject(value: RecordCollection) { + const result = Array.from(value.entries()).reduce( + (acc, [id, v]) => ({ + ...acc, + [id]: this.model.toObject(v) as unknown, + }), + {}, + ); + return result; + } + + public override _getField(path: string[]): foundry.data.fields.DataField { + if (path.length === 0) return this; + else if (path.length === 1) return this.model; + + path.shift(); + return this.model._getField(path); + } +} diff --git a/src/system/data/fields/index.ts b/src/system/data/fields/index.ts index 8abd3b2e..d8e16209 100644 --- a/src/system/data/fields/index.ts +++ b/src/system/data/fields/index.ts @@ -1,2 +1,3 @@ export * from './derived-value-field'; export * from './mapping-field'; +export * from './collection'; diff --git a/src/system/data/fields/mapping-field.ts b/src/system/data/fields/mapping-field.ts index 21819a76..3065eca6 100644 --- a/src/system/data/fields/mapping-field.ts +++ b/src/system/data/fields/mapping-field.ts @@ -25,8 +25,17 @@ export class MappingField< if (foundry.utils.getType(value) !== 'Object') throw new Error('must be an Object'); const errors = this._validateValues(value, options); - if (!foundry.utils.isEmpty(errors)) - throw new foundry.data.validation.DataModelValidationError(errors); + if (!foundry.utils.isEmpty(errors)) { + // Create validatior failure + const failure = + new foundry.data.validation.DataModelValidationFailure(); + + // Set fields + failure.fields = errors; + + // Throw error + throw new foundry.data.validation.DataModelValidationError(failure); + } } protected _validateValues( @@ -35,10 +44,13 @@ export class MappingField< ) { const errors: Record< string, - foundry.data.fields.DataModelValidationFailure + foundry.data.validation.DataModelValidationFailure > = {}; Object.entries(value).forEach(([key, v]) => { - const error = this.model.validate(v, options); + const error = this.model.validate( + v, + options, + ) as foundry.data.validation.DataModelValidationFailure | null; if (error) errors[key] = error; }); return errors; diff --git a/src/system/data/item/fields/talent-tree-node-collection.ts b/src/system/data/item/fields/talent-tree-node-collection.ts new file mode 100644 index 00000000..7b0fbea7 --- /dev/null +++ b/src/system/data/item/fields/talent-tree-node-collection.ts @@ -0,0 +1,94 @@ +import { CosmereItem } from '@system/documents'; +import { TalentTree } from '@system/types/item'; + +import { + CollectionField, + RecordCollection, + CollectionFieldOptions, +} from '@system/data/fields'; + +export class TalentTreeNodeCollectionField extends CollectionField { + constructor( + options?: CollectionFieldOptions, + context?: foundry.data.fields.DataFieldContext, + ) { + super( + new TalentTreeNodeField({ + nullable: true, + }), + options, + context, + NodeRecordCollection as typeof RecordCollection, + ); + } +} + +class NodeRecordCollection extends RecordCollection { + public override set(id: string, value: TalentTree.Node): this { + // Ensure the node id matches the record id + if (value) { + value.id = id; + } + + // Set the record + return super.set(id, value); + } +} + +class TalentTreeNodeField extends foundry.data.fields.SchemaField { + constructor( + options?: foundry.data.fields.DataFieldOptions, + context?: foundry.data.fields.DataFieldContext, + ) { + options ??= {}; + options.gmOnly = true; + + super( + { + id: new foundry.data.fields.DocumentIdField({ + required: true, + nullable: false, + blank: false, + gmOnly: true, + }), + type: new foundry.data.fields.StringField({ + required: false, + nullable: true, + blank: false, + initial: TalentTree.Node.Type.Icon, + choices: [ + TalentTree.Node.Type.Icon, + TalentTree.Node.Type.Text, + ], + gmOnly: true, + }), + uuid: new foundry.data.fields.DocumentUUIDField({ + required: true, + nullable: false, + gmOnly: true, + }), + position: new foundry.data.fields.SchemaField( + { + row: new foundry.data.fields.NumberField({ + required: true, + nullable: false, + gmOnly: true, + }), + column: new foundry.data.fields.NumberField({ + required: true, + nullable: false, + gmOnly: true, + }), + }, + { + required: true, + nullable: false, + gmOnly: true, + }, + ), + }, + options, + context, + ); + } +} diff --git a/src/system/data/item/goal.ts b/src/system/data/item/goal.ts new file mode 100644 index 00000000..a7e933fa --- /dev/null +++ b/src/system/data/item/goal.ts @@ -0,0 +1,135 @@ +import { Goal } from '@system/types/item'; +import { CosmereItem } from '@system/documents'; + +import { CollectionField } from '@system/data/fields'; + +// Mixins +import { DataModelMixin } from '../mixins'; +import { IdItemMixin, IdItemData } from './mixins/id'; +import { + DescriptionItemMixin, + DescriptionItemData, +} from './mixins/description'; + +export interface GoalItemData extends IdItemData, DescriptionItemData { + /** + * The progress level of the goal + */ + level: number; + + /** + * The rewards for completing the goal + */ + rewards: Collection; +} + +export class GoalItemDataModel extends DataModelMixin< + GoalItemData, + CosmereItem +>( + IdItemMixin({ + initialFromName: true, + }), + DescriptionItemMixin({ + value: 'COSMERE.Item.Type.Goal.desc_placeholder', + }), +) { + static defineSchema() { + return foundry.utils.mergeObject(super.defineSchema(), { + level: new foundry.data.fields.NumberField({ + required: true, + nullable: false, + integer: true, + min: 0, + max: 3, + initial: 0, + label: 'COSMERE.Item.Goal.Level.Label', + }), + + rewards: new CollectionField( + new foundry.data.fields.SchemaField( + { + type: new foundry.data.fields.StringField({ + required: true, + nullable: false, + initial: Goal.Reward.Type.Items, + label: 'COSMERE.Item.Goal.Reward.Type.Label', + choices: CONFIG.COSMERE.items.goal.rewards.types, + }), + + // Skill ranks reward + skill: new foundry.data.fields.StringField({ + required: false, + nullable: true, + blank: false, + initial: null, + label: 'COSMERE.Item.Goal.Reward.Skill.Label', + choices: Object.entries( + CONFIG.COSMERE.skills, + ).reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ), + }), + ranks: new foundry.data.fields.NumberField({ + required: false, + nullable: true, + initial: 0, + integer: true, + min: 0, + label: 'COSMERE.Item.Goal.Reward.Ranks.Label', + }), + + // Items reward + items: new foundry.data.fields.ArrayField( + new foundry.data.fields.DocumentUUIDField({ + blank: false, + }), + { + required: false, + nullable: true, + initial: [], + label: 'COSMERE.Item.Goal.Reward.Items.Label', + }, + ), + }, + { + nullable: true, + validate: (value: Goal.Reward) => { + if (value.type === Goal.Reward.Type.SkillRanks) { + if (!('skill' in value)) + throw new Error( + `Field "skill" is required for reward type "${Goal.Reward.Type.SkillRanks}"`, + ); + if (!('ranks' in value)) + throw new Error( + `Field "ranks" is required for reward type "${Goal.Reward.Type.SkillRanks}"`, + ); + } else if (value.type === Goal.Reward.Type.Items) { + if (!('items' in value)) + throw new Error( + `Field "items" is required for reward type "${Goal.Reward.Type.Items}"`, + ); + if (!Array.isArray(value.items)) + throw new Error( + `Field "items" must be an array for reward type "${Goal.Reward.Type.Items}"`, + ); + if ( + value.items.some( + (i) => typeof i !== 'string', + ) + ) + throw new Error( + `Field "items" must be an array of strings for reward type "${Goal.Reward.Type.Items}"`, + ); + } + }, + }, + ), + ), + }); + } +} diff --git a/src/system/data/item/index.ts b/src/system/data/item/index.ts index 6345b7b4..42cb895d 100644 --- a/src/system/data/item/index.ts +++ b/src/system/data/item/index.ts @@ -17,6 +17,11 @@ import { ActionItemDataModel } from './action'; import { InjuryItemDataModel } from './injury'; import { ConnectionItemDataModel } from './connection'; +import { GoalItemDataModel } from './goal'; + +import { PowerItemDataModel } from './power'; + +import { TalentTreeItemDataModel } from './talent-tree'; export const config: Record< ItemType, @@ -41,6 +46,11 @@ export const config: Record< [ItemType.Injury]: InjuryItemDataModel, [ItemType.Connection]: ConnectionItemDataModel, + [ItemType.Goal]: GoalItemDataModel, + + [ItemType.Power]: PowerItemDataModel, + + [ItemType.TalentTree]: TalentTreeItemDataModel, }; export * from './weapon'; @@ -56,3 +66,6 @@ export * from './action'; export * from './injury'; export * from './connection'; export * from './trait'; +export * from './goal'; +export * from './power'; +export * from './talent-tree'; diff --git a/src/system/data/item/mixins/damaging.ts b/src/system/data/item/mixins/damaging.ts index b3dafea5..5c1d8063 100644 --- a/src/system/data/item/mixins/damaging.ts +++ b/src/system/data/item/mixins/damaging.ts @@ -5,6 +5,7 @@ export interface DamagingItemData { damage: { formula?: string; type?: DamageType; + grazeOverrideFormula?: string; skill?: Skill; attribute?: Attribute; }; @@ -22,6 +23,10 @@ export function DamagingItemMixin

() { nullable: true, blank: false, }), + grazeOverrideFormula: + new foundry.data.fields.StringField({ + nullable: true, + }), type: new foundry.data.fields.StringField({ nullable: true, choices: Object.keys(CONFIG.COSMERE.damageTypes), diff --git a/src/system/data/item/mixins/equippable.ts b/src/system/data/item/mixins/equippable.ts index fb64b1ac..963f6757 100644 --- a/src/system/data/item/mixins/equippable.ts +++ b/src/system/data/item/mixins/equippable.ts @@ -72,6 +72,14 @@ export function EquippableItemMixin

( }), }); } + + public prepareDerivedData() { + super.prepareDerivedData(); + + if (this.alwaysEquipped) { + this.equipped = true; + } + } }; }; } diff --git a/src/system/data/item/mixins/id.ts b/src/system/data/item/mixins/id.ts index 5221651f..df1ececd 100644 --- a/src/system/data/item/mixins/id.ts +++ b/src/system/data/item/mixins/id.ts @@ -7,6 +7,8 @@ interface IdItemMixinOptions { | Type[] | Record | (() => Type[] | Record); + label?: string; + hint?: string; } export interface IdItemData { @@ -50,6 +52,12 @@ export function IdItemMixin< initial ?? (options.initialFromName ? '' : undefined), choices, + label: + options.label ?? + 'COSMERE.Item.Sheet.Identifier.Label', + hint: + options.hint ?? + 'COSMERE.Item.Sheet.Identifier.Hint', }), }); } diff --git a/src/system/data/item/path.ts b/src/system/data/item/path.ts index ffbfe4c3..ce5333a0 100644 --- a/src/system/data/item/path.ts +++ b/src/system/data/item/path.ts @@ -1,4 +1,4 @@ -import { PathType } from '@system/types/cosmere'; +import { PathType, Skill } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents/item'; // Mixins @@ -13,7 +13,13 @@ import { export interface PathItemData extends IdItemData, TypedItemData, - DescriptionItemData {} + DescriptionItemData { + /** + * The non-core skills linked to this path. + * These skills are displayed with the path in the sheet. + */ + linkedSkills: Skill[]; +} export class PathItemDataModel extends DataModelMixin< PathItemData, @@ -38,6 +44,31 @@ export class PathItemDataModel extends DataModelMixin< ) { static defineSchema() { return foundry.utils.mergeObject(super.defineSchema(), { + linkedSkills: new foundry.data.fields.ArrayField( + new foundry.data.fields.StringField({ + required: true, + nullable: false, + blank: false, + choices: () => + Object.entries(CONFIG.COSMERE.skills) + .filter(([key, skill]) => !skill.core) + .reduce( + (acc, [key, skill]) => ({ + ...acc, + [key]: skill.label, + }), + {}, + ), + }), + { + required: true, + nullable: false, + initial: [], + label: 'COSMERE.Item.Path.LinkedSkills.Label', + hint: 'COSMERE.Item.Path.LinkedSkills.Hint', + }, + ), + // TODO: Advancements }); } diff --git a/src/system/data/item/power.ts b/src/system/data/item/power.ts new file mode 100644 index 00000000..0dfe6b61 --- /dev/null +++ b/src/system/data/item/power.ts @@ -0,0 +1,97 @@ +import { Skill, PowerType } from '@system/types/cosmere'; +import { CosmereItem } from '@system/documents'; + +// Mixins +import { DataModelMixin } from '../mixins'; +import { IdItemMixin, IdItemData } from './mixins/id'; +import { TypedItemMixin, TypedItemData } from './mixins/typed'; +import { + ActivatableItemData, + ActivatableItemMixin, +} from './mixins/activatable'; +import { + DescriptionItemMixin, + DescriptionItemData, +} from './mixins/description'; + +export interface PowerItemData + extends IdItemData, + TypedItemData, + DescriptionItemData { + /** + * Wether to a custom skill is used, or + * the skill is derived from the power's id. + */ + customSkill: boolean; + + /** + * The skill associated with this power. + * This cannot be a core skill. + * If `customSkill` is `false`, the skill with the same id as the power is used. + */ + skill: Skill | null; +} + +export class PowerItemDataModel extends DataModelMixin< + PowerItemData, + CosmereItem +>( + IdItemMixin({ + initialFromName: true, + hint: 'COSMERE.Item.Power.Identifier.Hint', + }), + TypedItemMixin({ + initial: () => Object.keys(CONFIG.COSMERE.power.types)[0], + choices: () => + Object.entries(CONFIG.COSMERE.power.types).reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ), + }), + ActivatableItemMixin(), + DescriptionItemMixin({ + value: 'COSMERE.Item.Type.Power.desc_placeholder', + }), +) { + static defineSchema() { + return foundry.utils.mergeObject(super.defineSchema(), { + customSkill: new foundry.data.fields.BooleanField({ + required: true, + initial: false, + label: 'COSMERE.Item.Power.CustomSkill.Label', + hint: 'COSMERE.Item.Power.CustomSkill.Hint', + }), + + skill: new foundry.data.fields.StringField({ + required: true, + nullable: true, + blank: false, + label: 'COSMERE.Item.Power.Skill.Label', + hint: 'COSMERE.Item.Power.Skill.Hint', + initial: null, + choices: () => + Object.entries(CONFIG.COSMERE.skills) + .filter(([key, skill]) => !skill.core) + .reduce( + (acc, [key, skill]) => ({ + ...acc, + [key]: skill.label, + }), + {}, + ), + }), + }); + } + + public prepareDerivedData() { + super.prepareDerivedData(); + + if (!this.customSkill) { + const validId = this.id in CONFIG.COSMERE.skills; + this.skill = validId ? (this.id as Skill) : null; + } + } +} diff --git a/src/system/data/item/talent-tree.ts b/src/system/data/item/talent-tree.ts new file mode 100644 index 00000000..f7fad6a9 --- /dev/null +++ b/src/system/data/item/talent-tree.ts @@ -0,0 +1,58 @@ +import { ItemType } from '@system/types/cosmere'; +import { CosmereItem } from '@system/documents'; +import { TalentTree, Talent } from '@system/types/item'; + +import { TalentTreeNodeCollectionField } from './fields/talent-tree-node-collection'; + +// Mixins +import { DataModelMixin } from '../mixins'; + +export interface TalentTreeItemData { + /** + * The list of nodes in the tree + */ + nodes: Collection; + + /** + * The available width of the tree + */ + width: number; + + /** + * The available height of the tree + */ + height: number; +} + +export class TalentTreeItemDataModel extends DataModelMixin< + TalentTreeItemData, + CosmereItem +>() { + static defineSchema() { + return foundry.utils.mergeObject(super.defineSchema(), { + nodes: new TalentTreeNodeCollectionField({ + required: true, + nullable: false, + gmOnly: true, + }), + width: new foundry.data.fields.NumberField({ + required: true, + nullable: false, + gmOnly: true, + initial: 7, + min: 3, + integer: true, + label: 'COSMERE.Item.TalentTree.Width.Label', + }), + height: new foundry.data.fields.NumberField({ + required: true, + nullable: false, + gmOnly: true, + initial: 7, + min: 3, + integer: true, + label: 'COSMERE.Item.TalentTree.Height.Label', + }), + }); + } +} diff --git a/src/system/data/item/talent.ts b/src/system/data/item/talent.ts index a151768a..d8fc9b8e 100644 --- a/src/system/data/item/talent.ts +++ b/src/system/data/item/talent.ts @@ -1,7 +1,7 @@ import { Talent } from '@system/types/item'; import { CosmereItem } from '@system/documents'; -import { MappingField } from '@system/data/fields'; +import { MappingField, CollectionField } from '@system/data/fields'; // Mixins import { DataModelMixin } from '../mixins'; @@ -58,6 +58,17 @@ export interface TalentItemData */ hasAncestry?: boolean; + /** + * The id of the Power this Talent belongs to. + */ + power?: string; + /** + * Derived value that indicates whether or not the parent + * Actor has the required power. If no power is defined for this + * Talent, this value will be undefined. + */ + hasPower?: boolean; + prerequisites: Record; readonly prerequisitesArray: ({ id: string } & Talent.Prerequisite)[]; readonly prerequisiteTypeSelectOptions: Record< @@ -75,6 +86,12 @@ export interface TalentItemData * they're just plain strings. */ prerequisitesMet: boolean; + + /** + * Rules that are executed when this talent is + * obtained by an actor. + */ + grantRules: Collection; } export class TalentItemDataModel extends DataModelMixin< @@ -122,6 +139,14 @@ export class TalentItemDataModel extends DataModelMixin< initial: null, }), hasAncestry: new foundry.data.fields.BooleanField(), + power: new foundry.data.fields.StringField({ + required: false, + nullable: true, + initial: null, + label: 'COSMERE.Item.Talent.Power.Label', + hint: 'COSMERE.Item.Talent.Power.Hint', + }), + hasPower: new foundry.data.fields.BooleanField(), prerequisites: new MappingField( new foundry.data.fields.SchemaField( @@ -205,61 +230,69 @@ export class TalentItemDataModel extends DataModelMixin< choices: CONFIG.COSMERE.items.talent.prerequisite.modes, }), + + // Level + level: new foundry.data.fields.NumberField({ + min: 0, + initial: 0, + label: 'COSMERE.Item.Talent.Prerequisite.Level.Label', + }), + }, + { + nullable: true, + }, + ), + ), + prerequisitesMet: new foundry.data.fields.BooleanField(), + + grantRules: new CollectionField( + new foundry.data.fields.SchemaField( + { + type: new foundry.data.fields.StringField({ + required: true, + nullable: false, + blank: false, + choices: + CONFIG.COSMERE.items.talent.grantRules.types, + label: 'COSMERE.Item.Talent.GrantRule.Type.Label', + }), + + // Items + items: new foundry.data.fields.ArrayField( + new foundry.data.fields.DocumentUUIDField({ + blank: false, + label: 'COSMERE.Item.Talent.GrantRule.Items.Label', + }), + { + required: false, + nullable: true, + initial: null, + }, + ), }, { nullable: true, - validate: (value?: Partial) => { - if (!value) return; - switch (value.type) { - case Talent.Prerequisite.Type.Talent: - if (!value.talents) - throw new Error( - 'Field "talents" is required for prerequisite rule of type "Talent"', - ); - break; - case Talent.Prerequisite.Type.Attribute: - if ( - !value.attribute || - value.attribute.length === 0 - ) - throw new Error( - 'Field "attribute" is required for prerequisite rule of type "Attribute"', - ); - if (!value.value) - throw new Error( - 'Field "value" is required for prerequisite rule of type "Attribute"', - ); - break; - case Talent.Prerequisite.Type.Skill: - if ( - !value.skill || - value.skill.length === 0 - ) - throw new Error( - 'Field "skill" is required for prerequisite rule of type "Skill"', - ); - if (!value.rank) - throw new Error( - 'Field "rank" is required for prerequisite rule of type "Skill"', - ); - break; - case Talent.Prerequisite.Type.Connection: - if ( - !value.description || - value.description.length === 0 - ) - throw new Error( - 'Field "description" is required for prerequisite rule of type "Connection"', - ); - break; - default: - return false; + validate: (value: Talent.GrantRule) => { + if (value.type === Talent.GrantRule.Type.Items) { + if (!value.items) + throw new Error( + 'Field "items" is required for grant rule of type "Items"', + ); + } else { + throw new Error( + `Invalid grant rule type "${(value as { type: string }).type}"`, + ); } }, }, ), + { + required: true, + nullable: false, + label: 'COSMERE.Item.Talent.GrantRule.Label', + hint: 'COSMERE.Item.Talent.GrantRule.Hint', + }, ), - prerequisitesMet: new foundry.data.fields.BooleanField(), }); } @@ -317,6 +350,13 @@ export class TalentItemDataModel extends DataModelMixin< ) ?? false; } + if (this.power) { + this.hasPower = + actor?.items.some( + (item) => item.isPower() && item.id === this.power, + ) ?? false; + } + if (!actor) { this.prerequisitesMet = false; } else { diff --git a/src/system/dice/d20-roll.ts b/src/system/dice/d20-roll.ts index 29df84bc..d9a1d6ce 100644 --- a/src/system/dice/d20-roll.ts +++ b/src/system/dice/d20-roll.ts @@ -7,6 +7,8 @@ import { RollConfigurationDialog } from '@system/applications/dialogs/roll-confi import { PlotDie } from './plot-die'; import { RollMode } from './types'; +import { hasKey } from '../utils/generic'; +import { renderSystemTemplate, TEMPLATES } from '../utils/templates'; // Constants const CONFIGURATION_DIALOG_TEMPLATE = @@ -73,18 +75,19 @@ export interface D20RollOptions * The attribute that is used for the roll by default */ defaultAttribute?: Attribute; + + data?: D20RollData; } export class D20Roll extends foundry.dice.Roll { declare options: D20RollOptions & { configured: boolean }; public constructor( - protected parts: string[], + protected parts: string, data: D20RollData, options: D20RollOptions = {}, ) { - const formula = ['1d20'].concat(parts).join(' + '); - super(formula, data, options); + super(parts, data, options); if (!this.options.configured) { this.configureModifiers(); @@ -245,11 +248,12 @@ export class D20Roll extends foundry.dice.Roll { // Show the dialog const result = await RollConfigurationDialog.show({ ...data, - parts: ['1d20', ...this.parts], + parts: [this.parts], }); if (!result) return null; if (result.attribute !== this.options.defaultAttribute) { + this.data.skill.attribute = result.attribute; const skill = this.data.skill; const attribute = this.data.attributes[result.attribute]; this.terms[2] = new foundry.dice.terms.NumericTerm({ @@ -290,6 +294,120 @@ export class D20Roll extends foundry.dice.Roll { return super.toMessage(messageData, options); } + public async getHTML() { + const OPPORTUNITY = 'opportunity'; + const COMPLICATION = 'complication'; + + if (!this.validD20Roll) return; + + // Process bonuses beyond the base d20s into a single roll. + const bonusTerms = this.terms.slice(1); + + for (const term of bonusTerms) { + // Terms throw an error if already evaluated. We can ignore them if so. + try { + await term.evaluate(); + } catch (err) { + continue; + } + } + + const bonusRoll = + bonusTerms && bonusTerms.length > 0 + ? Roll.fromTerms(bonusTerms) + : null; + const d20Dice = this.dice.find((d) => d.faces === 20); + + if (!d20Dice) return; + + const plot = []; + + if (this.hasPlotDie) { + const plotDice = this.terms.filter((r) => r instanceof PlotDie); + for (const plotDie of plotDice) { + if (plotDie.rolledOpportunity) plot.push(OPPORTUNITY); + if (plotDie.rolledComplication) plot.push(COMPLICATION); + } + } + + const entries = []; + for (let i = 0; i < d20Dice.results.length; i++) { + const tmpResults = []; + tmpResults.push(foundry.utils.duplicate(d20Dice.results[i])); + + while ( + d20Dice?.results[i]?.rerolled && + !d20Dice?.results[i]?.count + ) { + if (i + 1 >= d20Dice.results.length) { + break; + } + + i++; + tmpResults.push(foundry.utils.duplicate(d20Dice.results[i])); + } + + // Die terms must have active results or the base roll total of the generated roll is 0. + // This does not apply to dice that have been rerolled (unless they are replaced by a fixer value eg. for reliable talent). + tmpResults.forEach((r) => { + r.active = !(r.rerolled && !r.count); + }); + + const modifiers = new Array< + keyof (typeof foundry.dice.terms.Die)['MODIFIERS'] + >(); + for (const mod of d20Dice.modifiers) { + if (hasKey(foundry.dice.terms.Die.MODIFIERS, mod)) { + modifiers.push(mod); + } + } + + const baseTerm = new foundry.dice.terms.Die({ + number: 1, + faces: 20, + results: tmpResults, + modifiers, + }); + const baseRoll = D20Roll.fromTerms([baseTerm]); + + const total = (baseRoll?.total ?? 0) + (bonusRoll?.total ?? 0); + + const plotD20 = [...plot]; + for (let o = 0; o < baseRoll.opportunitiesCount; o++) { + plotD20.push(OPPORTUNITY); + } + for (let c = 0; c < baseRoll.complicationsCount; c++) { + plotD20.push(COMPLICATION); + } + + entries.push({ + roll: baseRoll, + total: total, + ignored: tmpResults.some((r) => r.discarded) ? true : undefined, + plotType: plotD20.some((p) => p === OPPORTUNITY) + ? OPPORTUNITY + : plotD20.some((p) => p === COMPLICATION) + ? COMPLICATION + : undefined, + plotDice: plotD20, + }); + } + + return renderSystemTemplate(TEMPLATES.CHAT_ROLL_D20, { + formula: this.formula, + tooltip: await this.getTooltip(), + entries, + }); + } + + /** + * Recalculates the roll total from the current (potentially modified) terms. + * @returns {number} The new total of the roll. + */ + public resetTotal(): number { + return (this._total = this._evaluateTotal()); + } + /* --- Internal Functions --- */ private configureModifiers() { diff --git a/src/system/dice/damage-roll.ts b/src/system/dice/damage-roll.ts index 2d9bb2c2..97522ead 100644 --- a/src/system/dice/damage-roll.ts +++ b/src/system/dice/damage-roll.ts @@ -1,6 +1,7 @@ import { DamageType, Skill, Attribute } from '@system/types/cosmere'; import { CosmereActorRollData } from '@system/documents/actor'; import { AdvantageMode } from '@system/types/roll'; +import RollTerm from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/client-esm/dice/terms/term.mjs'; export type DamageRollData< ActorRollData extends CosmereActorRollData = CosmereActorRollData, @@ -15,6 +16,11 @@ export type DamageRollData< attribute: Attribute; }; attribute?: number; + damage?: { + total: DamageRoll; + unmodded: DamageRoll; + dice: DamageRoll; + }; }; export interface DamageRollOptions @@ -34,6 +40,16 @@ export interface DamageRollOptions * @default AdvantageMode.None */ advantageMode?: AdvantageMode; + + /** + * Where did this damage come from? + */ + source?: string; + + /** + * Nested Roll item for graze damage + */ + graze?: DamageRoll; } export class DamageRoll extends foundry.dice.Roll { @@ -61,6 +77,18 @@ export class DamageRoll extends foundry.dice.Roll { return this.options.mod; } + get source(): string | undefined { + return this.options.source; + } + + get graze(): DamageRoll | undefined { + return this.options.graze; + } + + set graze(roll: DamageRoll) { + this.options.graze = roll; + } + public get hasMod() { return this.options.mod !== undefined; } @@ -79,8 +107,52 @@ export class DamageRoll extends foundry.dice.Roll { return this.options.advantageMode === AdvantageMode.Disadvantage; } + /* --- Helper Functions --- */ + + public removeTermSafely( + conditional: ( + value: RollTerm, + index: number, + obj: RollTerm[], + ) => boolean, + ) { + this.terms.findSplice(conditional); + this.cleanUpTerms(); + } + + public filterTermsSafely( + condition: (value: RollTerm, index: number, obj: RollTerm[]) => boolean, + ) { + this.terms = this.terms.filter(condition); + this.cleanUpTerms(); + } + + public replaceDieResults(sourceDicePool: foundry.dice.terms.DiceTerm[]) { + sourceDicePool.forEach((die) => { + let numDiceToAlter = die.number ?? 0; + while (numDiceToAlter > 0) { + const nextDie = this.dice.find( + (newDie) => newDie.faces === die.faces, + ); + if (!nextDie) return; + nextDie.results = die.results; + numDiceToAlter--; + } + }); + this._total = this._evaluateTotal(); + } + /* --- Internal Functions --- */ + private cleanUpTerms() { + while ( + this.terms[this.terms.length - 1] instanceof + foundry.dice.terms.OperatorTerm + ) + this.terms.pop(); + this.resetFormula(); + } + private configureModifiers() { // Find the first die term const dieTerm = this.terms.find( diff --git a/src/system/dice/index.ts b/src/system/dice/index.ts index 08868eed..5d90da6b 100644 --- a/src/system/dice/index.ts +++ b/src/system/dice/index.ts @@ -2,7 +2,8 @@ import { Attribute } from '@system/types/cosmere'; import { D20Roll, D20RollOptions, D20RollData } from './d20-roll'; import { DamageRoll, DamageRollOptions, DamageRollData } from './damage-roll'; -import { RollMode } from './types'; +import { determineConfigurationMode } from '../utils/generic'; +import { AdvantageMode } from '../types/roll'; export * from './d20-roll'; export * from './damage-roll'; @@ -52,10 +53,7 @@ export interface D20RollConfigration extends D20RollOptions { */ defaultAttribute?: Attribute; - /** - * The roll mode that should be selected by default - */ - defaultRollMode?: RollMode; + messageData?: object; } export interface DamageRollConfiguration extends DamageRollOptions { @@ -73,34 +71,53 @@ export interface DamageRollConfiguration extends DamageRollOptions { export async function d20Roll( config: D20RollConfigration, ): Promise { - // Roll parameters - const defaultRollMode = - config.rollMode ?? game.settings!.get('core', 'rollMode'); + // Handle key modifiers + const { fastForward, advantageMode, plotDie } = determineConfigurationMode( + config.configurable, + config.advantageMode + ? config.advantageMode === AdvantageMode.Advantage + : undefined, + config.advantageMode + ? config.advantageMode === AdvantageMode.Disadvantage + : undefined, + config.plotDie, + ); + + // Replace config values with key modified values + config.advantageMode = advantageMode; + config.plotDie = plotDie; // Construct the roll - const roll = new D20Roll(config.parts ?? [], config.data, { - ...config, - }); - - // Prompt dialog to configure the d20 roll - const configured = - config.configurable !== false - ? await roll.configureDialog({ - title: config.title, - plotDie: config.plotDie, - defaultRollMode, - defaultAttribute: - config.defaultAttribute ?? config.data.skill.attribute, - data: config.data, - }) - : roll; - if (configured === null) return null; + const roll = new D20Roll( + ['1d20'].concat(config.parts ?? []).join(' + '), + config.data, + { ...config }, + ); + + if (!fastForward) { + // Prompt dialog to configure the d20 roll + const configured = + config.configurable !== false + ? await roll.configureDialog({ + title: config.title, + plotDie: config.plotDie, + defaultRollMode: + config.rollMode ?? + game.settings!.get('core', 'rollMode'), + defaultAttribute: + config.defaultAttribute ?? + config.data.skill.attribute, + data: config.data, + }) + : roll; + if (configured === null) return null; + } // Evaluate the configure roll await roll.evaluate(); if (roll && config.chatMessage !== false) { - await roll.toMessage(); + await roll.toMessage(config.messageData, config); } return roll; @@ -117,6 +134,7 @@ export async function damageRoll( allowStrings: config.allowStrings, maximize: config.maximize, minimize: config.minimize, + source: config.source, }); // Evaluate the roll diff --git a/src/system/dice/plot-die.ts b/src/system/dice/plot-die.ts index fe04405a..a0ba0422 100644 --- a/src/system/dice/plot-die.ts +++ b/src/system/dice/plot-die.ts @@ -1,12 +1,12 @@ import { IMPORTED_RESOURCES } from '@system/constants'; const SIDES: Record = { - 1: ``, - 2: ``, - 3: ``, - 4: ``, - 5: ``, - 6: ``, + 1: ``, + 2: ``, + 3: ' ', + 4: ' ', + 5: ``, + 6: ``, }; export interface PlotDieData @@ -81,7 +81,9 @@ export class PlotDie extends foundry.dice.terms.DiceTerm { return rollResult; } - getResultLabel(result: foundry.dice.terms.DiceTerm.Result): string { + override getResultLabel( + result: foundry.dice.terms.DiceTerm.Result, + ): string { return SIDES[result.result]; } } diff --git a/src/system/documents/actor.ts b/src/system/documents/actor.ts index f57f0b57..09869566 100644 --- a/src/system/documents/actor.ts +++ b/src/system/documents/actor.ts @@ -9,6 +9,7 @@ import { Resource, InjuryType, } from '@system/types/cosmere'; +import { Talent } from '@system/types/item'; import { CosmereItem, CosmereItemData, @@ -16,19 +17,27 @@ import { CultureItem, PathItem, TalentItem, + GoalItem, + PowerItem, } from '@system/documents/item'; + import { CommonActorData, CommonActorDataModel, } from '@system/data/actor/common'; import { CharacterActorDataModel } from '@system/data/actor/character'; import { AdversaryActorDataModel } from '@system/data/actor/adversary'; -import { Derived } from '@system/data/fields'; +import { PowerItemData } from '@system/data/item'; + +import { Derived } from '@system/data/fields'; +import { SYSTEM_ID } from '../constants'; import { d20Roll, D20Roll, D20RollData, DamageRoll } from '@system/dice'; // Dialogs import { ShortRestDialog } from '@system/applications/actor/dialogs/short-rest'; +import { MESSAGE_TYPES } from './chat-message'; +import { getTargetDescriptors } from '../utils/generic'; export type CharacterActor = CosmereActor; export type AdversaryActor = CosmereActor; @@ -93,6 +102,13 @@ export type CosmereActorRollData = skills: Record; }; +// Constants +/** + * Item types of which only a single instance can be + * embedded in an actor. + */ +const SINGLETON_ITEM_TYPES = [ItemType.Ancestry]; + export class CosmereActor< T extends CommonActorDataModel = CommonActorDataModel, SystemType extends CommonActorData = T extends CommonActorDataModel @@ -119,11 +135,11 @@ export class CosmereActor< public get favorites(): CosmereItem[] { return this.items - .filter((i) => i.getFlag('cosmere-rpg', 'favorites.isFavorite')) + .filter((i) => i.getFlag(SYSTEM_ID, 'favorites.isFavorite')) .sort( (a, b) => - a.getFlag('cosmere-rpg', 'favorites.sort') - - b.getFlag('cosmere-rpg', 'favorites.sort'), + a.getFlag(SYSTEM_ID, 'favorites.sort') - + b.getFlag(SYSTEM_ID, 'favorites.sort'), ); } @@ -145,6 +161,18 @@ export class CosmereActor< return this.items.filter((i) => i.isPath()); } + public get goals(): GoalItem[] { + return this.items.filter((i) => i.isGoal()); + } + + public get powers(): PowerItem[] { + return this.items.filter((i) => i.isPower()); + } + + public get talents(): TalentItem[] { + return this.items.filter((i) => i.isTalent()); + } + /* --- Type Guards --- */ public isCharacter(): this is CharacterActor { @@ -157,6 +185,13 @@ export class CosmereActor< /* --- Lifecycle --- */ + protected override _initialize(options?: object) { + super._initialize(options); + + // Migrate goals + void this.migrateGoals(); + } + public override async _preCreate( data: object, options: object, @@ -186,38 +221,12 @@ export class CosmereActor< data: object[], opertion?: Partial, ): Promise { - const postCreateActions = new Array<() => void>(); - - if (embeddedName === 'Item') { - const itemData = data as CosmereItemData[]; - - // Get the first ancestry item - const ancestryItem = itemData.find( - (d) => d.type === ItemType.Ancestry, - ); - - // Filter out any ancestry items beyond the first - data = itemData.filter( - (d) => d.type !== ItemType.Ancestry || d === ancestryItem, - ); - - // If an ancestry item was present, replace the current (after create) - if (ancestryItem) { - // Get current ancestry item - const currentAncestryItem = this.items.find( - (i) => i.type === ItemType.Ancestry, - ); - - // Remove existing ancestry after create, if present - if (currentAncestryItem) { - postCreateActions.push(() => { - void this.deleteEmbeddedDocuments('Item', [ - currentAncestryItem.id, - ]); - }); - } - } - } + // Pre create actions + if ( + this.preCreateEmbeddedDocuments(embeddedName, data, opertion) === + false + ) + return []; // Perform create const result = await super.createEmbeddedDocuments( @@ -227,7 +236,7 @@ export class CosmereActor< ); // Post create actions - postCreateActions.forEach((func) => func()); + this.postCreateEmbeddedDocuments(embeddedName, result); // Return result return result; @@ -273,10 +282,145 @@ export class CosmereActor< } } + /* --- Handlers --- */ + + protected preCreateEmbeddedDocuments( + embeddedName: string, + data: object[], + opertion?: Partial, + ): boolean | void { + if (embeddedName === 'Item') { + const itemData = data as CosmereItemData[]; + + // Check for singleton items + SINGLETON_ITEM_TYPES.forEach((type) => { + // Get the first item of this type + const item = itemData.find((d) => d.type === type); + + // Filter out any other items of this type + data = item + ? itemData.filter((d) => d.type !== type || d === item) + : itemData; + }); + + // Pre add powers + itemData.forEach((d, i) => { + if (d.type === ItemType.Power) { + if ( + this.preAddPower( + d as CosmereItemData, + ) === false + ) { + itemData.splice(i, 1); + } + } + }); + } + } + + protected preAddPower( + data: CosmereItemData, + ): boolean | void { + // Ensure a power with the same id does not already exist + if ( + this.powers.some( + (i) => i.hasId() && i.system.id === data.system?.id, + ) + ) { + ui.notifications.error( + game.i18n!.format( + 'COSMERE.Item.Power.Notification.PowerExists', + { + actor: this.name, + identifier: data.system!.id, + }, + ), + ); + return false; + } + } + + protected postCreateEmbeddedDocuments( + embeddedName: string, + documents: foundry.abstract.Document[], + ): void { + documents.forEach((doc) => { + if (embeddedName === 'Item') { + const item = doc as CosmereItem; + + if (item.isAncestry()) { + this.onAncestryAdded(item); + } else if (item.isTalent()) { + this.onTalentAdded(item); + } + } + }); + } + + protected onAncestryAdded(item: AncestryItem) { + // Find any other ancestry items + const otherAncestries = this.items.filter( + (i) => i.isAncestry() && i.id !== item.id, + ); + + // Remove other ancestries + otherAncestries.forEach((i) => { + void i.delete(); + }); + } + + protected onTalentAdded(item: TalentItem) { + // Check if the talent has grant rules + if (item.system.grantRules.size > 0) { + // Execute grant rules + item.system.grantRules.forEach((rule) => { + if (rule.type === Talent.GrantRule.Type.Items) { + rule.items.forEach(async (itemUUID) => { + // Get document + const doc = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + + // Get id + const id = doc.hasId() ? doc.system.id : null; + + // Ensure the item is not already present + if ( + !id || + this.items.some( + (i) => i.hasId() && i.system.id === id, + ) + ) + return; + + // Add the item to the actor + await this.createEmbeddedDocuments('Item', [ + doc.toObject(), + ]); + + // Notification + ui.notifications.info( + game.i18n!.format( + 'GENERIC.Notification.AddedItem', + { + type: game.i18n!.localize( + `TYPES.Item.${doc.type}`, + ), + item: doc.name, + actor: this.name, + }, + ), + ); + }); + } + }); + } + } + /* --- Functions --- */ public async setMode(modality: string, mode: string) { - await this.setFlag('cosmere-rpg', `mode.${modality}`, mode); + await this.setFlag(SYSTEM_ID, `mode.${modality}`, mode); // Get all effects for this modality const effects = this.applicableEffects.filter( @@ -305,7 +449,7 @@ export class CosmereActor< } public async clearMode(modality: string) { - await this.unsetFlag('cosmere-rpg', `mode.${modality}`); + await this.unsetFlag(SYSTEM_ID, `mode.${modality}`); // Get all effects for this modality const effects = this.effects.filter( @@ -321,7 +465,7 @@ export class CosmereActor< } } - public async rollInjuryDuration() { + public async rollInjury() { // Get roll table const table = (await fromUuid( CONFIG.COSMERE.injury.durationTable, @@ -344,20 +488,20 @@ export class CosmereActor< // NOTE: Draw function type definition is wrong, must use `any` type as a workaround /* eslint-disable @typescript-eslint/no-explicit-any */ - const results = ( - await table.draw({ - roll, - } as any) - ).results as TableResult[]; + const draw = await table.draw({ + roll, + displayChat: false, + } as any); /* eslint-Enable @typescript-eslint/no-explicit-any */ // Get result - const result = results[0]; + const result = draw.results[0] as TableResult; // Get injury data const data: { type: InjuryType; durationFormula: string } = - result.getFlag('cosmere-rpg', 'injury-data'); + result.getFlag(SYSTEM_ID, 'injury-data'); + const rolls = []; if ( data.type !== InjuryType.Death && data.type !== InjuryType.PermanentInjury @@ -365,22 +509,29 @@ export class CosmereActor< // Roll duration const durationRoll = new foundry.dice.Roll(data.durationFormula); await durationRoll.evaluate(); + rolls.push(durationRoll); + } - // Get speaker - const speaker = ChatMessage.getSpeaker({ - actor: this, - }) as ChatSpeakerData; + const flags = {} as Record; + flags[SYSTEM_ID] = { + message: { + type: MESSAGE_TYPES.INJURY, + }, + injury: { + details: result, + roll: draw.roll, + }, + }; - // Chat message - await ChatMessage.create({ - user: game.user!.id, - speaker, - content: `

${game.i18n!.localize( - 'COSMERE.ChatMessage.InjuryDuration', - )} (${game.i18n!.localize('GENERIC.Units.Days')})

`, - rolls: [durationRoll], - }); - } + // Chat message + await ChatMessage.create({ + user: game.user!.id, + speaker: ChatMessage.getSpeaker({ + actor: this, + }) as ChatSpeakerData, + flags, + rolls, + }); } /** @@ -507,7 +658,7 @@ export class CosmereActor< attributeOverride ?? CONFIG.COSMERE.skills[skill].attribute; // Get skill rank - const rank = this.system.skills[skill].rank; + const rank = this.system.skills[skill]?.rank ?? 0; // Get attribute value const attrValue = this.getAttributeMod(attributeId); @@ -545,32 +696,31 @@ export class CosmereActor< const rollData = foundry.utils.mergeObject( { data: data as D20RollData, - title: `${flavor}: ${this.name}`, - chatMessage: false, + title: flavor, defaultAttribute: options.attribute ?? skill.attribute, + messageData: { + speaker: + options.speaker ?? + (ChatMessage.getSpeaker({ + actor: this, + }) as ChatSpeakerData), + flags: {} as Record, + }, }, options, ); + rollData.parts = [`@mod`].concat(options.parts ?? []); + rollData.messageData.flags[SYSTEM_ID] = { + message: { + type: MESSAGE_TYPES.SKILL, + targets: getTargetDescriptors(), + }, + }; // Perform roll const roll = await d20Roll(rollData); - if (roll) { - // Get the speaker - const speaker = - options.speaker ?? - (ChatMessage.getSpeaker({ actor: this }) as ChatSpeakerData); - - // Create chat message - await ChatMessage.create({ - user: game.user!.id, - speaker, - content: `

${flavor}

`, - rolls: [roll], - }); - } - // Return roll return roll; } @@ -585,24 +735,40 @@ export class CosmereActor< return item.roll({ ...options, actor: this }); } + /** + * Utility function to modify a skill value + */ + public async modifySkillRank( + skillId: Skill, + change: number, + render?: boolean, + ): Promise; /** * Utility function to increment/decrement a skill value */ public async modifySkillRank( skillId: Skill, - incrementBool = true, + increment: boolean, + render?: boolean, + ): Promise; + public async modifySkillRank( + skillId: Skill, + param1: boolean | number = true, render = true, ) { + const incrementBool = typeof param1 === 'boolean' ? param1 : true; + const changeAmount = typeof param1 === 'number' ? param1 : 1; + const skillpath = `system.skills.${skillId}.rank`; const skill = this.system.skills[skillId]; if (incrementBool) { await this.update( - { [skillpath]: Math.clamp(skill.rank + 1, 0, 5) }, + { [skillpath]: Math.clamp(skill.rank + changeAmount, 0, 5) }, { render }, ); } else { await this.update( - { [skillpath]: Math.clamp(skill.rank - 1, 0, 5) }, + { [skillpath]: Math.clamp(skill.rank - changeAmount, 0, 5) }, { render }, ); } @@ -614,7 +780,9 @@ export class CosmereActor< public async useItem( item: CosmereItem, options?: Omit, - ): Promise { + ): Promise { + // Checks for relevant Active Effects triggers/manual toggles will go here + // E.g. permanent/conditional: attack bonuses, damage riders, auto opportunity/complications, etc. return item.use({ ...options, actor: this }); } @@ -776,4 +944,62 @@ export class CosmereActor< ) ?? false ); } + + /** + * Utility function to determine if an actor has a given talent + */ + public hasTalent(id: string): boolean { + return this.talents.some((talent) => talent.system.id === id); + } + + public hasTalentPrerequisites(talent: TalentItem): boolean { + return talent.system.prerequisitesArray.every((prereq) => { + switch (prereq.type) { + case Talent.Prerequisite.Type.Talent: + return prereq.mode === Talent.Prerequisite.Mode.AllOf + ? prereq.talents.every((ref) => this.hasTalent(ref.id)) + : prereq.talents.some((ref) => this.hasTalent(ref.id)); + case Talent.Prerequisite.Type.Attribute: + return ( + this.getAttributeMod(prereq.attribute) >= prereq.value + ); + case Talent.Prerequisite.Type.Skill: + return this.getSkillMod(prereq.skill) >= prereq.rank; + case Talent.Prerequisite.Type.Level: // TEMP: Until leveling is implemented + default: + return true; + } + }); + } + + /* --- Helpers --- */ + + /** + * Migrate goals from the system object to individual items. + * + */ + private async migrateGoals() { + if (!this.isCharacter() || !this.system.goals) return; + + const goals = this.system.goals; + + // Remove goals from data + await this.update({ + 'system.goals': null, + }); + + // Create goal items + goals.forEach((goalData) => { + void Item.create( + { + type: ItemType.Goal, + name: goalData.text, + system: { + level: goalData.level, + }, + }, + { parent: this }, + ); + }); + } } diff --git a/src/system/documents/chat-message.ts b/src/system/documents/chat-message.ts index c6ed41f4..75e87447 100644 --- a/src/system/documents/chat-message.ts +++ b/src/system/documents/chat-message.ts @@ -1,33 +1,30 @@ -import { DamageType } from '@system/types/cosmere'; +import { DamageType, InjuryType } from '@system/types/cosmere'; import { D20Roll } from '@system/dice/d20-roll'; import { DamageRoll } from '@system/dice/damage-roll'; -import { AnyObject } from '@system/types/utils'; import { CosmereActor } from './actor'; -import { CosmereItem } from './item'; - -// Constants -const CHAT_CARD_HEADER_TEMPLATE = - 'systems/cosmere-rpg/templates/chat/parts/chat-card-header.hbs'; -const CHAT_CARD_ROLLS_TEMPLATE = - 'systems/cosmere-rpg/templates/chat/parts/chat-card-rolls.hbs'; -const CHAT_CARD_ACTIONS_TEMPLATE = - 'systems/cosmere-rpg/templates/chat/parts/chat-card-actions.hbs'; - -const ACTIVITY_CARD_TEMPLATE = - 'systems/cosmere-rpg/templates/chat/activity-card.hbs'; -const ACTIVITY_CARD_MAX_HEIGHT = 1040; -const ACTIVITY_CARD_TOTAL_TRANSITION_DURATION = 0.9; - -interface ChatMessageAction { - name: string; - icon: string; - callback?: () => void; -} +import { renderSystemTemplate, TEMPLATES } from '../utils/templates'; +import { SYSTEM_ID } from '../constants'; +import { AdvantageMode } from '../types/roll'; +import { getSystemSetting, SETTINGS } from '../settings'; +import { + getApplyTargets, + getConstantFromRoll, + TargetDescriptor, +} from '../utils/generic'; + +export const MESSAGE_TYPES = { + SKILL: 'skill', + ACTION: 'action', + INJURY: 'injury', +} as Record; export class CosmereChatMessage extends ChatMessage { - /* --- Accessors --- */ + private useGraze = false; + private totalDamageNormal = 0; + private totalDamageGraze = 0; + /* --- Accessors --- */ public get associatedActor(): CosmereActor | null { // NOTE: game.scenes resolves to any type /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-member-access */ @@ -56,308 +53,653 @@ export class CosmereChatMessage extends ChatMessage { return this.damageRolls.length > 0; } - /* --- Rendering --- */ + public get hasInjury(): boolean { + return this.getFlag(SYSTEM_ID, 'injury') !== undefined; + } + /* --- Rendering --- */ public override async getHTML(): Promise { const html = await super.getHTML(); // Enrich the chat card - await this.enrichChatCard(html); + await this.enrichCardHeader(html); + await this.enrichCardContent(html); + + html.find('.collapsible').on('click', (event) => + this.onClickCollapsible(event), + ); return html; } - protected async enrichChatCard(html: JQuery) { + protected async enrichCardHeader(html: JQuery) { const actor = this.associatedActor; - const name = this.isContentVisible ? this.alias : this.author.name; + let img; + let name; - // Render header - const header = await renderTemplate(CHAT_CARD_HEADER_TEMPLATE, { - img: this.isContentVisible - ? (actor?.img ?? this.author.avatar) - : this.author.avatar, - name, - subtitle: name !== this.author.name ? this.author.name : undefined, - timestamp: html.find('.message-timestamp').text(), - }); + if (this.isContentVisible) { + img = actor?.img ?? this.author.avatar; + name = this.alias; + } else { + img = this.author.avatar; + name = this.author.name; + } - // Replace header - html.find('.message-header').replaceWith(header); - - // Get flags - const rolltable = this.getFlag('core', 'RollTable') as - | string - | undefined; - - // Render rolls - if (!rolltable) await this.renderRolls(html); - - // Render actions - await this.renderActions(html); - - // Attach activity card listeners - html.find('.chat-card.activity .header.description').on( - 'click', - (event) => { - // Get element - const element = $(event.target).closest('.header.description'); - - // Check if the description is collapsed - const isCollapsed = element.hasClass('collapsed'); - - // Toggle collapsed - if (isCollapsed) { - element.removeClass('collapsed'); - } else { - // Get the description element - const descriptionEl = element.find('.description'); - - // Get the height - const height = descriptionEl.height(); - - // Calculate transition duration - const duration = - (ACTIVITY_CARD_TOTAL_TRANSITION_DURATION / - ACTIVITY_CARD_MAX_HEIGHT) * - height! * - 2; - - // Set max height to height and transition to duration - descriptionEl - .css('margin-top', `.3rem`) - .css('max-height', `${height}px`) - .css('transition', `0s`); - - setTimeout(() => { - // Change transition - descriptionEl - .css('margin-top', '') - .css('max-height', `0`) - .css('transition', `${duration}s`); - - // Add collapsed class - element.addClass('collapsed'); - - setTimeout(() => { - // Remove max height and transition - descriptionEl - .css('max-height', '') - .css('transition', ''); - }, duration * 1000); - }); - } + const headerHTML = await renderSystemTemplate( + TEMPLATES.CHAT_CARD_HEADER, + { + img, + name, + subtitle: + name !== this.author.name ? this.author.name : undefined, + timestamp: html.find('.message-timestamp').text(), + canRepeat: this.hasSkillTest || this.hasDamage, }, ); + + // Replace header + html.find('.message-header').replaceWith(headerHTML); + + const deleteButton = html + .find('.message-metadata') + .find('.message-delete'); + if (!game.user!.isGM) deleteButton?.remove(); } - protected async renderRolls(html: JQuery) { + protected async enrichCardContent(html: JQuery) { if (!this.isContentVisible) return; - const d20Rolls = this.rolls.filter((r) => r instanceof D20Roll); - const damageRolls = this.rolls.filter((r) => r instanceof DamageRoll); - const remainingRolls = this.rolls.filter( - (r) => !(r instanceof D20Roll) && !(r instanceof DamageRoll), + const type = this.getFlag(SYSTEM_ID, 'message.type') as string; + if (!type || !Object.values(MESSAGE_TYPES).includes(type)) return; + + const content = $( + await renderSystemTemplate(TEMPLATES.CHAT_CARD_CONTENT, {}), ); - // Render d20 rolls - const rollsHtml = await renderTemplate(CHAT_CARD_ROLLS_TEMPLATE, { - rolls: [...d20Rolls, ...remainingRolls], - damageRolls, + this.enrichDescription(content); + await this.enrichSkillTest(content); + await this.enrichDamage(content); + await this.enrichInjury(content); + await this.enrichTestTargets(content); + + // Replace content + html.find('.message-content').replaceWith(content); + + // Setup hover buttons when the message is actually hovered(for optimisation). + let hoverSetupComplete = false; + content.on('mouseenter', async () => { + if (!hoverSetupComplete) { + hoverSetupComplete = true; + await this.enrichCardOverlay(content); + } + this.onOverlayHoverStart(content); }); - // Remove existing rolls - html.find('.message-content .dice-roll').remove(); + content.on('mouseleave', () => { + this.onOverlayHoverEnd(content); + }); - // Append rolls - html.find('.message-content').append(rollsHtml); + // Run hover end once to ensure all hover buttons are in the correct state. + this.onOverlayHoverEnd(content); + } - // Attach listeners - html.find('.dice-total').on('click', (event) => { - // Get element - const element = $(event.target).closest('.dice-total'); + protected enrichDescription(html: JQuery) { + const description = this.getFlag( + SYSTEM_ID, + 'message.description', + ) as string; + if (!description) return; - // Check if dice total has collapsed class - if (element.hasClass('collapsed')) element.removeClass('collapsed'); - else element.addClass('collapsed'); - }); + html.find('.chat-card').append(description); + } - html.find('[data-action="undo-damage"]').on('click', (event) => { - // Get element - const element = $(event.target).closest( - '[data-action="undo-damage"]', - ); + protected async enrichSkillTest(html: JQuery) { + if (!this.hasSkillTest) return; - // Get the actor - const actor = this.associatedActor; - if (!actor) return; + const d20Roll = this.d20Rolls[0]; + const skill = d20Roll?.options?.data?.skill; - // Get the amount - const amount = Number(element.data('amount')); + if (!skill) return; - // Undo damage - void actor.applyDamage( - { amount: amount, type: DamageType.Healing }, - { chatMessage: false }, - ); + const sectionHTML = await renderSystemTemplate( + TEMPLATES.CHAT_CARD_SECTION, + { + type: 'skill', + icon: 'fa-regular fa-dice-d20', + title: game.i18n!.localize('GENERIC.SkillTest'), + subtitle: { + skill: CONFIG.COSMERE.skills[skill.id].label, + attribute: + CONFIG.COSMERE.attributes[skill.attribute].labelShort, + }, + content: await d20Roll.getHTML(), + }, + ); - // Strikethrough the damage - element - .closest('.damage-notification') - .css('text-decoration', 'line-through'); + const section = $(sectionHTML as unknown as HTMLElement); + const tooltip = section.find('.dice-tooltip'); + this.enrichD20Tooltip(d20Roll, tooltip[0]); + tooltip.prepend(section.find('.dice-formula')); - // Remove the action - element.remove(); + html.find('.chat-card').append(section); + } + + protected async enrichTestTargets(html: JQuery) { + if (!this.hasSkillTest) return; + + const targets = this.getFlag( + SYSTEM_ID, + 'message.targets', + ) as TargetDescriptor[]; + if (!targets || targets.length === 0) return; + + const d20Roll = this.d20Rolls[0]; + + const success = ''; + const failure = ''; + + const targetData = []; + for (const target of targets) { + targetData.push({ + name: target.name, + uuid: target.uuid, + phyDef: target.def.phy, + phyIcon: + (d20Roll.total ?? 0) >= target.def.phy ? success : failure, + cogDef: target.def.cog, + cogIcon: + (d20Roll.total ?? 0) >= target.def.cog ? success : failure, + spiDef: target.def.spi, + spiIcon: + (d20Roll.total ?? 0) >= target.def.spi ? success : failure, + }); + } + + const trayHTML = await renderSystemTemplate( + TEMPLATES.CHAT_CARD_TRAY_TARGETS, + { + targets: targetData, + }, + ); + + const tray = $(trayHTML as unknown as HTMLElement); + + tray.find('li.target').on('click', (event) => { + void this.onClickTarget(event); }); + + html.find('.chat-card').append(tray); } - protected async renderActions(html: JQuery) { - if (!this.isContentVisible) return; + protected async enrichDamage(html: JQuery) { + if (!this.hasDamage) return; - const hasActions = this.hasDamage; - if (!hasActions) return; - - const groups = [] as ChatMessageAction[][]; - - if (this.hasDamage) { - if (this.isAuthor && this.hasSkillTest) { - groups.push([ - { - name: game.i18n!.localize( - 'COSMERE.ChatMessage.Action.Graze', - ), - icon: 'fa-solid fa-droplet-slash', - callback: this.onDoGraze.bind(this), - }, - ]); + const damageRolls = this.damageRolls; + + this.totalDamageNormal = 0; + this.totalDamageGraze = 0; + + let tooltipNormalHTML = ''; + let tooltipGrazeHTML = ''; + + const partsNormal = []; + const partsGraze = []; + const types = new Set(); + + for (const rollNormal of damageRolls) { + const type = rollNormal.damageType + ? game.i18n!.localize( + CONFIG.COSMERE.damageTypes[rollNormal.damageType].label, + ) + : ''; + + types.add(type); + + this.totalDamageNormal += rollNormal.total ?? 0; + partsNormal.push(rollNormal.formula); + const tooltipNormal = $(await rollNormal.getTooltip()); + this.enrichDamageTooltip(rollNormal, type, tooltipNormal); + tooltipNormalHTML += + tooltipNormal.find('.tooltip-part')[0].outerHTML; + + if (rollNormal.options.graze) { + const rollGraze = DamageRoll.fromData( + rollNormal.options + .graze as unknown as foundry.dice.Roll.Data, + ); + + this.totalDamageGraze += rollGraze.total ?? 0; + partsGraze.push(rollGraze.formula); + const tooltipGraze = $(await rollGraze.getTooltip()); + this.enrichDamageTooltip(rollGraze, type, tooltipGraze); + tooltipGrazeHTML += + tooltipGraze.find('.tooltip-part')[0].outerHTML; } + } - groups.push([ - { - name: game.i18n!.localize( - 'COSMERE.ChatMessage.Action.ApplyDamage', - ), - icon: 'fa-solid fa-heart-crack', - callback: this.onApplyDamage.bind(this), - }, + const damageHTML = await renderSystemTemplate( + TEMPLATES.CHAT_ROLL_DAMAGE, + { + formulaNormal: partsNormal.join(' + '), + formulaGraze: partsGraze.join(' + '), + tooltipNormal: tooltipNormalHTML, + tooltipGraze: tooltipGrazeHTML, + totalNormal: this.totalDamageNormal, + totalGraze: this.totalDamageGraze, + }, + ); - ...(this.hasSkillTest - ? [ - { - name: game.i18n!.localize( - 'COSMERE.ChatMessage.Action.ApplyGraze', - ), - icon: 'fa-solid fa-shield-halved', - callback: this.onApplyDamage.bind(this, false), - }, - ] - : []), - - { - name: game.i18n!.localize( - 'COSMERE.ChatMessage.Action.ApplyHealing', - ), - icon: 'fa-solid fa-heart-circle-plus', - callback: this.onApplyHealing.bind(this), - }, - ]); - } + const footer = getSystemSetting(SETTINGS.CHAT_ENABLE_APPLY_BUTTONS) + ? await renderSystemTemplate(TEMPLATES.CHAT_CARD_DAMAGE_BUTTONS, { + overlay: !getSystemSetting(SETTINGS.CHAT_ALWAYS_SHOW_BUTTONS), + }) + : undefined; + + const sectionHTML = await renderSystemTemplate( + TEMPLATES.CHAT_CARD_SECTION, + { + type: 'damage', + icon: 'fa-solid fa-burst', + title: game.i18n!.localize('GENERIC.Damage'), + content: damageHTML, + footer, + damageTypes: Array.from(types) + .sort() + .join(' '), + }, + ); - // Render actions - const actionsHtml = await renderTemplate(CHAT_CARD_ACTIONS_TEMPLATE, { - hasActions: groups.length > 0, - groups, + const section = $(sectionHTML as unknown as HTMLElement); + + section.find('.dice-subtotal').on('click', (event) => { + this.onSwitchDamageMode(event); }); - // Append actions - html.find('.message-content').append(actionsHtml); - - // Attach listeners - html.find('.card-actions .action[data-item]').on('click', (event) => { - // Get the index - const [groupIndex, index] = ( - $(event.target) - .closest('.action[data-item]') - .data('item') as string - ) - .split('-') - .map(Number) as [number, number]; - - // Get the item - const item = groups[groupIndex][index]; - - // Trigger the callback - if (item.callback) item.callback(); + section.find('.apply-buttons button').on('click', async (event) => { + await this.onClickApplyButton(event); }); + + html.find('.chat-card').append(section); } - /* --- Handlers --- */ + protected async enrichInjury(html: JQuery) { + if (!this.hasInjury) return; - private onDoGraze() { - // Get associated actor - const actor = this.associatedActor; - if (!actor) - return ui.notifications.warn( - game.i18n!.localize('GENERIC.Warning.NoActor'), - ); + const injury = TableResult.fromSource( + this.getFlag(SYSTEM_ID, 'injury.details'), + ); + const injuryRoll = Roll.fromData( + this.getFlag(SYSTEM_ID, 'injury.roll'), + ); - if (actor.system.resources.foc.value === 0) - return ui.notifications.warn( - game.i18n!.localize('GENERIC.Warning.NoFocus'), - ); + const data: { type: InjuryType; durationFormula: string } = + injury?.getFlag(SYSTEM_ID, 'injury-data'); + const durationRoll = this.rolls.find( + (r) => !(r instanceof D20Roll) && !(r instanceof DamageRoll), + ); + + // Current required because of a bug in the roll table + if ((data.type as string) === 'ViciousInjury') + data.type = InjuryType.ViciousInjury; + + let title; + const actor = this.associatedActor?.name ?? 'Actor'; + switch (data.type) { + case InjuryType.Death: + title = game.i18n!.format( + 'COSMERE.ChatMessage.InjuryDuration.Dead', + { actor }, + ); + break; + case InjuryType.PermanentInjury: + title = game.i18n!.format( + 'COSMERE.ChatMessage.InjuryDuration.Permanent', + { actor }, + ); + break; + default: { + title = game.i18n!.format( + 'COSMERE.ChatMessage.InjuryDuration.Temporary', + { actor, days: durationRoll?.total ?? 0 }, + ); + break; + } + } - // Reduce focus - void actor.update({ - 'system.resources.foc.value': actor.system.resources.foc.value - 1, + const sectionHTML = await renderSystemTemplate( + TEMPLATES.CHAT_CARD_INJURY, + { + title, + img: injury.img, + description: injury.text, + formula: injuryRoll?.formula, + total: injuryRoll?.total, + tooltip: await injuryRoll?.getTooltip(), + type: game.i18n!.localize( + CONFIG.COSMERE.injury.types[data.type].label, + ), + }, + ); + + const section = $(sectionHTML as unknown as HTMLElement); + const tooltip = section.find('.dice-tooltip'); + this.enrichD20Tooltip(injuryRoll, tooltip[0]); + tooltip.prepend(section.find('.dice-formula')); + + html.find('.chat-card').append(section); + } + + /** + * Augment damage roll tooltips with some additional information and styling. + * @param {DamageRoll} roll The roll instance. + * @param {string} type The type of the damage as a string. + * @param {JQuery} html The roll tooltip markup. + * @returns + */ + protected enrichDamageTooltip( + roll: DamageRoll, + type: string, + html: JQuery, + ) { + html.find('.label').text(type); + + const constant = getConstantFromRoll(roll); + if (constant === 0) return; + + const sign = constant < 0 ? '-' : '+'; + const newTotal = Number(html.find('.value').text()) + constant; + + html.find('.value').text(newTotal); + html.find('.dice-rolls').append( + `
  • ${sign}${constant}
  • `, + ); + } + + /** + * Augment d20 roll tooltips with some additional information and styling. + * @param {Roll} roll The roll instance. + * @param {HTMLElement} html The roll tooltip markup. + */ + protected enrichD20Tooltip(roll: Roll, html: HTMLElement) { + const constant = getConstantFromRoll(roll); + if (constant === 0) return; + + const sign = constant < 0 ? '-' : '+'; + const part = document.createElement('section'); + part.classList.add('tooltip-part', 'constant'); + part.innerHTML = ` +
    +
      +
      + ${sign}${Math.abs(constant)} +
      +
      + `; + html.appendChild(part); + } + + /** + * Adds overlay buttons to a chat card for retroactively making a roll into a multi roll or a crit. + * @param {JQuery} html The object to add overlay buttons to. + */ + protected async enrichCardOverlay(html: JQuery) { + if (!getSystemSetting(SETTINGS.CHAT_ENABLE_OVERLAY_BUTTONS)) return; + + const overlayD20 = await renderSystemTemplate( + TEMPLATES.CHAT_OVERLAY_D20, + { + imgAdvantage: `systems/${SYSTEM_ID}/assets/icons/svg/dice/retro-adv.svg`, + imgDisadvantage: `systems/${SYSTEM_ID}/assets/icons/svg/dice/retro-dis.svg`, + }, + ); + + html.find('.dice-roll-d20 .dice-total').append($(overlayD20)); + html.find('.overlay-d20 div').on('click', async (event) => { + await this.onClickOverlayD20(event); }); - // Notify - ui.notifications.info( - game.i18n!.localize('GENERIC.Notification.GrazeFocusSpent'), + //const overlayCrit = await renderSystemTemplate(TEMPLATES.CHAT_OVERLAY_CRIT, {}); + + // html.find('.rsr-damage .dice-total').append($(overlayCrit)); + + // html.find(".rsr-overlay-crit div").click(async event => { + // await _processRetroCritButtonEvent(message, event); + // }); + } + + /** + * Listen for shift key being pressed to show the chat message "delete" icon, or released (or focus lost) to hide it. + */ + public static activateListeners() { + window.addEventListener( + 'keydown', + () => this.toggleModifiers({ releaseAll: false }), + { passive: true }, + ); + window.addEventListener( + 'keyup', + () => this.toggleModifiers({ releaseAll: false }), + { passive: true }, + ); + window.addEventListener( + 'blur', + () => this.toggleModifiers({ releaseAll: true }), + { passive: true }, ); } - private onApplyDamage(includeMod = true) { - // Get selected actor - const actor = (game.canvas!.tokens!.controlled?.[0]?.actor ?? - game.user?.character) as CosmereActor | undefined; + /** + * Toggles attributes on the chatlog based on which modifier keys are being held. + * @param {object} [options] + * @param {boolean} [options.releaseAll=false] Force all modifiers to be considered released. + */ + private static toggleModifiers({ releaseAll = false }) { + document.querySelectorAll('.chat-sidebar > ol').forEach((chatlog) => { + const chatlogHTML = chatlog as HTMLElement; + for (const key of Object.values(KeyboardManager.MODIFIER_KEYS)) { + if (game.keyboard!.isModifierActive(key) && !releaseAll) + chatlogHTML.dataset[`modifier${key}`] = ''; + else delete chatlogHTML.dataset[`modifier${key}`]; + } + }); + } - if (!actor) - return ui.notifications.warn( - game.i18n!.localize('GENERIC.Warning.NoActor'), + /* --- Handlers --- */ + + /** + * Handles a d20 overlay button click event. + * @param {JQuery.ClickEvent} event The originating event of the button click. + */ + private async onClickOverlayD20(event: JQuery.ClickEvent) { + event.preventDefault(); + event.stopPropagation(); + + const button = event.currentTarget as HTMLElement; + const action = button.dataset.action; + const state = button.dataset.state; + + if (action === 'retro' && state) { + const roll = this.d20Rolls[0]; + + const d20BaseTerm = roll.terms.find( + (d) => d instanceof foundry.dice.terms.Die && d.faces === 20, + ) as foundry.dice.terms.Die; + + if (!d20BaseTerm || d20BaseTerm.number === 2) return; + + const d20Additional = await new Roll( + `${2 - d20BaseTerm.number!}d20${d20BaseTerm.modifiers.join('')}`, + ).evaluate(); + + const modifiers = new Array< + keyof (typeof foundry.dice.terms.Die)['MODIFIERS'] + >(); + d20BaseTerm.modifiers.forEach((m) => + modifiers.push( + m as keyof (typeof foundry.dice.terms.Die)['MODIFIERS'], + ), ); - // Get damage rolls - const damageRolls = this.damageRolls; + const d20Forced = new foundry.dice.terms.Die({ + number: 2, + faces: 20, + results: [ + ...d20BaseTerm.results, + ...d20Additional.dice[0].results, + ], + modifiers, + }); + d20Forced.keep(state); + d20Forced.modifiers.push(state); + + roll.terms[roll.terms.indexOf(d20BaseTerm)] = d20Forced; + roll.options.advantageMode = + state === 'kh' + ? AdvantageMode.Advantage + : state === 'kl' + ? AdvantageMode.Disadvantage + : AdvantageMode.None; + + roll.resetFormula(); + roll.resetTotal(); + + void this.update({ rolls: this.rolls }); + } + } - // Apply damage - void actor.applyDamage( - ...damageRolls.map((r) => ({ - amount: (r.total ?? 0) + (includeMod ? (r.mod ?? 0) : 0), - type: r.damageType, - })), - ); + /** + * Handles a click event on the toggle between using graze damage and full damage. + * @param {JQuery.ClickEvent} event The originating event of the button click. + * @returns + */ + private onSwitchDamageMode(event: JQuery.ClickEvent) { + const toggle = $(event.currentTarget as HTMLElement); + + if (toggle.css('opacity') === '0') return; + + event.preventDefault(); + event.stopPropagation(); + + this.useGraze = !this.useGraze; + toggle.attr('style', 'opacity: 0;'); + toggle.siblings('.dice-subtotal').attr('style', ''); + toggle + .siblings('p') + .text( + this.useGraze ? this.totalDamageGraze : this.totalDamageNormal, + ); } - private onApplyHealing(includeMod = true) { - // Get selected actor - const actor = (game.canvas!.tokens!.controlled?.[0]?.actor ?? - game.user?.character) as CosmereActor | undefined; + /** + * Handles an apply button click event. + * @param {JQuery.ClickEvent} event The originating event of the button click. + */ + private async onClickApplyButton( + event: JQuery.ClickEvent, + forceRolls = null, + ) { + event.preventDefault(); + event.stopPropagation(); + + const button = event.currentTarget as HTMLElement; + const action = button.dataset.action; + const multiplier = Number(button.dataset.multiplier); + + const targets = getApplyTargets(); + if (targets.size === 0) return; + + if (action === 'apply-damage' && multiplier) { + const damageRolls = forceRolls ?? this.damageRolls; + const damageToApply = damageRolls.map((r) => ({ + amount: + (this.useGraze ? (r.graze?.total ?? 0) : (r.total ?? 0)) * + Math.abs(multiplier), + type: multiplier < 0 ? DamageType.Healing : r.damageType, + })); + + await Promise.all( + Array.from(targets).map(async (t) => { + const target = (t as Token).actor as CosmereActor; + return await target.applyDamage(...damageToApply); + }), + ); + } - if (!actor) - return ui.notifications.warn( - game.i18n!.localize('GENERIC.Warning.NoActor'), + if (action === 'reduce-focus') { + await Promise.all( + Array.from(targets).map(async (t) => { + const target = (t as Token).actor as CosmereActor; + return await target.update({ + 'system.resources.foc.value': + target.system.resources.foc.value - 1, + }); + }), ); + } + } - // Get damage rolls - const damageRolls = this.damageRolls; + /** + * Handles collapsible sections expansion on click event. + * @param {PointerEvent} event The triggering event. + */ + private onClickCollapsible(event: JQuery.ClickEvent) { + event.stopPropagation(); + const target = event.currentTarget as HTMLElement; + target?.classList.toggle('expanded'); + } + + /** + * Handle target selection and panning. + * @param {Event} event The triggering event. + * @returns {Promise} A promise that resolves once the canvas pan has completed. + * @protected + */ + private async onClickTarget(event: JQuery.ClickEvent) { + event.stopPropagation(); + const uuid = (event.currentTarget as HTMLElement).dataset.uuid; + + if (!uuid) return; + + const actor = fromUuidSync(uuid) as CosmereActor; + const token = actor?.getActiveTokens()[0] as Token; - // Apply damage - void actor.applyDamage( - ...damageRolls.map((r) => ({ - amount: (r.total ?? 0) + (includeMod ? (r.mod ?? 0) : 0), - type: DamageType.Healing, - })), + if (!token) return; + + const releaseOthers = !event.shiftKey; + if (token.controlled) token.release(); + else { + token.control({ releaseOthers }); + return game.canvas!.animatePan(token.center); + } + } + + /** + * Handles hover begin events on the given html/jquery object. + * @param {JQuery} html The object to handle hover begin events for. + * @private + */ + private onOverlayHoverStart(html: JQuery) { + const hasPermission = game.user!.isGM || this.isAuthor; + + html.find('.overlay').show(); + html.find('.overlay-d20').toggle( + hasPermission && + this.hasSkillTest && + !( + this.d20Rolls[0].hasAdvantage || + this.d20Rolls[0].hasDisadvantage + ), ); + html.find('.overlay-crit').toggle(hasPermission && this.hasDamage); + } + + /** + * Handles hover end events on the given html/jquery object. + * @param {JQuery} html The object to handle hover end events for. + * @private + */ + private onOverlayHoverEnd(html: JQuery) { + html.find('.overlay').attr('style', 'display: none;'); } } diff --git a/src/system/documents/combat.ts b/src/system/documents/combat.ts index 23521801..7aef571f 100644 --- a/src/system/documents/combat.ts +++ b/src/system/documents/combat.ts @@ -1,3 +1,4 @@ +import { SYSTEM_ID } from '../constants'; import { CosmereCombatant } from './combatant'; export class CosmereCombat extends Combat { @@ -10,7 +11,7 @@ export class CosmereCombat extends Combat { resetActivations() { for (const combatant of this.turns) { void combatant.setFlag( - 'cosmere-rpg', + SYSTEM_ID, 'activated', combatant.isDefeated ? true : false, ); diff --git a/src/system/documents/combatant.ts b/src/system/documents/combatant.ts index 2ad66b09..f20abcae 100644 --- a/src/system/documents/combatant.ts +++ b/src/system/documents/combatant.ts @@ -2,6 +2,7 @@ import { DocumentModificationOptions } from '@league-of-foundry-developers/found import { SchemaField } from '@league-of-foundry-developers/foundry-vtt-types/src/foundry/common/data/fields.mjs'; import { CosmereActor } from './actor'; import { ActorType, TurnSpeed } from '@src/system/types/cosmere'; +import { SYSTEM_ID } from '../constants'; export class CosmereCombatant extends Combatant { override get actor(): CosmereActor { @@ -17,8 +18,8 @@ export class CosmereCombatant extends Combatant { userID: string, ) { super._onCreate(data, options, userID); - void this.setFlag('cosmere-rpg', 'turnSpeed', TurnSpeed.Slow); - void this.setFlag('cosmere-rpg', 'activated', false); + void this.setFlag(SYSTEM_ID, 'turnSpeed', TurnSpeed.Slow); + void this.setFlag(SYSTEM_ID, 'activated', false); void this.combat?.setInitiative( this.id!, this.generateInitiative(this.actor.type, TurnSpeed.Slow), @@ -41,13 +42,10 @@ export class CosmereCombatant extends Combatant { * Utility function to flip the combatants current turn speed between slow and fast. It then updates initiative to force an update of the combat-tracker ui */ toggleTurnSpeed() { - const currentSpeed = this.getFlag( - 'cosmere-rpg', - 'turnSpeed', - ) as TurnSpeed; + const currentSpeed = this.getFlag(SYSTEM_ID, 'turnSpeed') as TurnSpeed; const newSpeed = currentSpeed === TurnSpeed.Slow ? TurnSpeed.Fast : TurnSpeed.Slow; - void this.setFlag('cosmere-rpg', 'turnSpeed', newSpeed); + void this.setFlag(SYSTEM_ID, 'turnSpeed', newSpeed); void this.combat?.setInitiative( this.id!, this.generateInitiative(this.actor.type, newSpeed), diff --git a/src/system/documents/item.ts b/src/system/documents/item.ts index 5ea4b66f..ff10bd8e 100644 --- a/src/system/documents/item.ts +++ b/src/system/documents/item.ts @@ -4,9 +4,18 @@ import { Attribute, ItemConsumeType, ActivationType, + WeaponTraitId, + ArmorTraitId, + ActionCostType, } from '@system/types/cosmere'; +import { Goal } from '@system/types/item'; +import { GoalItemData } from '@system/data/item/goal'; +import { DeepPartial } from '@system/types/utils'; + import { CosmereActor } from './actor'; +import { SYSTEM_ID } from '../constants'; + import { Derived } from '@system/data/fields'; // Dialogs @@ -27,6 +36,9 @@ import { TraitItemDataModel, LootItemDataModel, EquipmentItemDataModel, + GoalItemDataModel, + PowerItemDataModel, + TalentTreeItemDataModel, } from '@system/data/item'; import { ActivatableItemData } from '@system/data/item/mixins/activatable'; @@ -51,6 +63,13 @@ import { } from '@system/dice'; import { AdvantageMode } from '@system/types/roll'; import { RollMode } from '@system/dice/types'; +import { + determineConfigurationMode, + getTargetDescriptors, + hasKey, +} from '../utils/generic'; +import { MESSAGE_TYPES } from './chat-message'; +import { renderSystemTemplate, TEMPLATES } from '../utils/templates'; // Constants const CONSUME_CONFIGURATION_DIALOG_TEMPLATE = @@ -140,6 +159,18 @@ export class CosmereItem< return this.type === ItemType.Equipment; } + public isGoal(): this is GoalItem { + return this.type === ItemType.Goal; + } + + public isPower(): this is PowerItem { + return this.type === ItemType.Power; + } + + public isTalentTree(): this is CosmereItem { + return this.type === ItemType.TalentTree; + } + /* --- Mixin type guards --- */ /** @@ -216,7 +247,7 @@ export class CosmereItem< /* --- Accessors --- */ public get isFavorite(): boolean { - return this.getFlag('cosmere-rpg', 'favorites.isFavorite'); + return this.getFlag(SYSTEM_ID, 'favorites.isFavorite'); } /** @@ -237,15 +268,95 @@ export class CosmereItem< const modalityId = this.system.modality; // Check actor modality flag - const activeMode = this.actor.getFlag( - 'cosmere-rpg', - `mode.${modalityId}`, - ); + const activeMode = this.actor.getFlag(SYSTEM_ID, `mode.${modalityId}`); // Check if the actor has the mode active return activeMode === this.system.id; } + /* --- Lifecycle --- */ + + override _onUpdate(_changes: object, options: object, userId: string) { + super._onUpdate(_changes, options, userId); + + if (game.user?.id !== userId) return; + + if (this.isGoal()) { + const changes: { system?: DeepPartial } = _changes; + + if (changes.system?.level === 3) { + this.handleGoalComplete(); + } + } + } + + /* --- Event handlers --- */ + + protected handleGoalComplete() { + // Ensure the item is a goal + if (!this.isGoal()) return; + + // Ensure actor is set + if (!this.actor) return; + + // Get the rewards + const rewards = this.system.rewards; + + // Handle rewards + rewards.forEach(async (reward) => { + if (reward.type === Goal.Reward.Type.SkillRanks) { + await this.actor!.modifySkillRank(reward.skill, reward.ranks); + + // Notification + ui.notifications.info( + game.i18n!.format( + 'GENERIC.Notification.IncreasedSkillRank', + { + skill: CONFIG.COSMERE.skills[reward.skill].label, + amount: reward.ranks, + actor: this.actor!.name, + }, + ), + ); + } else if (reward.type === Goal.Reward.Type.Items) { + reward.items.forEach(async (itemUUID) => { + // Get the item + const item = (await fromUuid( + itemUUID, + )) as unknown as CosmereItem; + + // Get the id + const id = item.hasId() ? item.system.id : null; + + // Ensure the item is not already embedded + if ( + id && + this.actor!.items.some( + (i) => i.hasId() && i.system.id === id, + ) + ) + return; + + // Add the item to the actor + await this.actor!.createEmbeddedDocuments('Item', [ + item.toObject(), + ]); + + // Notification + ui.notifications.info( + game.i18n!.format('GENERIC.Notification.AddedItem', { + type: game.i18n!.localize( + `TYPES.Item.${item.type}`, + ), + item: item.name, + actor: this.actor!.name, + }), + ); + }); + } + }); + } + /* --- Roll & Usage utilities --- */ /** @@ -331,7 +442,7 @@ export class CosmereItem< */ public async rollDamage( options: CosmereItem.RollDamageOptions = {}, - ): Promise { + ): Promise { if (!this.hasDamage() || !this.system.damage.formula) return null; // Get the actor to roll for (either assigned through option, the parent of this item, or the first controlled actor) @@ -376,12 +487,52 @@ export class CosmereItem< // Perform the roll const roll = await damageRoll( foundry.utils.mergeObject(options, { - formula: this.system.damage.formula, + formula: + rollData.mod !== undefined + ? `${this.system.damage.formula} + ${rollData.mod}` + : this.system.damage.formula, damageType: this.system.damage.type, mod: rollData.mod, data: rollData, + source: this.name, + }), + ); + + // Gather the formula options for graze rolls + const unmoddedRoll = roll.clone(); + const diceOnlyRoll = roll.clone(); + rollData.damage = { + total: roll, + unmodded: unmoddedRoll, + dice: diceOnlyRoll, + }; + unmoddedRoll.removeTermSafely( + (term) => + term instanceof foundry.dice.terms.NumericTerm && + term.total === rollData.mod, + ); + diceOnlyRoll.filterTermsSafely( + (term) => + term instanceof foundry.dice.terms.DiceTerm || + term instanceof foundry.dice.terms.OperatorTerm, + ); + + // Make the graze pool and roll it + const grazeFormula = + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + this.system.damage.grazeOverrideFormula || '@damage.dice'; + const usesBaseDamage = grazeFormula.includes('@damage'); + const grazeRoll = await damageRoll( + foundry.utils.mergeObject(options, { + formula: grazeFormula, + damageType: this.system.damage.type, + data: rollData, }), ); + // update with results from the basic roll if needed and store for display + if (usesBaseDamage) grazeRoll.replaceDieResults(roll.dice); + if (!grazeRoll) return null; + roll.graze = grazeRoll; if (roll && options.chatMessage !== false) { // Get the speaker @@ -396,7 +547,7 @@ export class CosmereItem< } // Return the roll - return roll; + return [roll]; } /** @@ -405,7 +556,7 @@ export class CosmereItem< */ public async rollAttack( options: CosmereItem.RollAttackOptions = {}, - ): Promise<[D20Roll, DamageRoll] | null> { + ): Promise<[D20Roll, DamageRoll[]] | null> { if (!this.hasActivation()) return null; if (!this.hasDamage() || !this.system.damage.formula) return null; @@ -425,29 +576,63 @@ export class CosmereItem< return null; } - // Get skill to use - const skillId = options.skill ?? this.system.activation.skill; - if (!skillId) return null; - const skill = actor.system.skills[skillId]; + // Get the skill to use during the skill test + const skillTestSkillId = + options.skillTest?.skill ?? this.system.activation.skill; + if (!skillTestSkillId) return null; - // Get the attribute - let attributeId = - options.attribute ?? + // Get the skill to use during the damage roll + const damageSkillId = + options.damage?.skill ?? + this.system.damage.skill ?? + skillTestSkillId; + + // Get the attribute to use during the skill test + let skillTestAttributeId = + options.skillTest?.attribute ?? this.system.activation.attribute ?? - skill.attribute; + actor.system.skills[skillTestSkillId].attribute; + + // Get the attribute to use during the damage roll + const damageAttributeId = + options.damage?.attribute ?? + this.system.damage.attribute ?? + actor.system.skills[damageSkillId].attribute; + + options.skillTest ??= {}; + options.damage ??= {}; + + // Handle key modifiers + const { fastForward, advantageMode, plotDie } = + determineConfigurationMode( + options.configurable, + options.skillTest.advantageMode + ? options.skillTest.advantageMode === + AdvantageMode.Advantage + : undefined, + options.skillTest.advantageMode + ? options.skillTest.advantageMode === + AdvantageMode.Disadvantage + : undefined, + options.skillTest.plotDie, + ); + + // Replace config values with key modified values + options.skillTest.advantageMode = advantageMode; + options.skillTest.plotDie = plotDie; // Perform configuration - if (options.configurable !== false) { + if (!fastForward && options.configurable !== false) { const attackConfig = await AttackConfigurationDialog.show({ title: `${this.name} (${game.i18n!.localize( - CONFIG.COSMERE.skills[skillId].label, + CONFIG.COSMERE.skills[skillTestSkillId].label, )})`, skillTest: { ...options.skillTest, parts: ['@mod'].concat(options.skillTest?.parts ?? []), data: this.getSkillTestRollData( - skillId, - attributeId, + skillTestSkillId, + skillTestAttributeId, actor, ), plotDie: @@ -457,26 +642,28 @@ export class CosmereItem< damageRoll: { ...options.damage, parts: this.system.damage.formula.split(' + '), - data: this.getDamageRollData(skillId, attributeId, actor), + data: this.getDamageRollData( + skillTestSkillId, + skillTestAttributeId, + actor, + ), }, - defaultAttribute: attributeId, + defaultAttribute: skillTestAttributeId, defaultRollMode: options.rollMode, }); // If the dialog was closed, exit out of rolls if (!attackConfig) return null; - attributeId = attackConfig.attribute; + skillTestAttributeId = attackConfig.attribute; options.rollMode = attackConfig.rollMode; - options.skillTest ??= {}; options.skillTest.plotDie = attackConfig.skillTest.plotDie; options.skillTest.advantageMode = attackConfig.skillTest.advantageMode; options.skillTest.advantageModePlot = attackConfig.skillTest.advantageModePlot; - options.damage ??= {}; options.damage.advantageMode = attackConfig.damageRoll.advantageMode; } @@ -485,8 +672,8 @@ export class CosmereItem< const skillRoll = (await this.roll({ ...options.skillTest, actor, - skill: skillId, - attribute: attributeId, + skill: skillTestSkillId, + attribute: skillTestAttributeId, rollMode: options.rollMode, speaker: options.speaker, configurable: false, @@ -494,11 +681,11 @@ export class CosmereItem< }))!; // Roll the damage - const damageRoll = (await this.rollDamage({ + const damageRolls = (await this.rollDamage({ ...options.damage, actor, - skill: skillId, - attribute: attributeId, + skill: damageSkillId, + attribute: damageAttributeId, rollMode: options.rollMode, speaker: options.speaker, chatMessage: false, @@ -520,12 +707,12 @@ export class CosmereItem< user: game.user!.id, speaker, content: `

      ${flavor}

      `, - rolls: [skillRoll, damageRoll], + rolls: [skillRoll, ...damageRolls], })) as ChatMessage; } // Return the rolls - return [skillRoll, damageRoll]; + return [skillRoll, damageRolls ?? []]; } /** @@ -534,7 +721,7 @@ export class CosmereItem< */ public async use( options: CosmereItem.UseOptions = {}, - ): Promise { + ): Promise { if (!this.hasActivation()) return null; // Set up post roll actions @@ -662,12 +849,22 @@ export class CosmereItem< this.system.activation.type === ActivationType.SkillTest || hasDamage; - // Get the speaker - const speaker = - options.speaker ?? - (ChatMessage.getSpeaker({ actor }) as ChatSpeakerData); + const messageConfig = { + user: game.user!.id, + speaker: + options.speaker ?? + (ChatMessage.getSpeaker({ actor }) as ChatSpeakerData), + rolls: [] as foundry.dice.Roll[], + flags: {} as Record, + }; - const descriptionHTML = await this.getEnrichedDescription(); + messageConfig.flags[SYSTEM_ID] = { + message: { + type: MESSAGE_TYPES.ACTION, + description: await this.getDescriptionHTML(), + targets: getTargetDescriptors(), + }, + }; if (rollRequired) { const rolls: foundry.dice.Roll[] = []; @@ -693,7 +890,7 @@ export class CosmereItem< if (!attackResult) return null; // Add the rolls to the list - rolls.push(...attackResult); + rolls.push(attackResult[0], ...attackResult[1]); // Set the flavor flavor = flavor @@ -705,14 +902,14 @@ export class CosmereItem< )})`; } else { if (hasDamage) { - const damageRoll = await this.rollDamage({ + const damageRolls = await this.rollDamage({ ...options, actor, chatMessage: false, }); - if (!damageRoll) return null; + if (!damageRolls) return null; - rolls.push(damageRoll); + rolls.push(...damageRolls); } if (this.system.activation.type === ActivationType.SkillTest) { @@ -737,25 +934,17 @@ export class CosmereItem< } } + messageConfig.rolls = rolls; + // Create chat message - await ChatMessage.create({ - user: game.user!.id, - speaker, - content: await renderTemplate(ACTIVITY_CARD_TEMPLATE, { - item: this, - hasDescription: !!descriptionHTML, - descriptionHTML, - flavor, - }), - rolls: rolls, - }); + await ChatMessage.create(messageConfig); // Perform post roll actions postRoll.forEach((action) => action()); // Return the result return hasDamage - ? (rolls as [D20Roll, DamageRoll]) + ? (rolls as [D20Roll, ...DamageRoll[]]) : (rolls[0] as D20Roll); } else { // NOTE: Use boolean or operator (`||`) here instead of nullish coalescing (`??`), @@ -765,17 +954,9 @@ export class CosmereItem< this.system.activation.flavor || undefined; // Create chat message - const message = (await ChatMessage.create({ - user: game.user!.id, - speaker, - content: await renderTemplate(ACTIVITY_CARD_TEMPLATE, { - item: this, - hasDescription: !!descriptionHTML, - descriptionHTML, - expanded: true, - flavor, - }), - })) as ChatMessage; + const message = (await ChatMessage.create( + messageConfig, + )) as ChatMessage; message.applyRollMode('roll'); // Perform post roll actions @@ -860,7 +1041,7 @@ export class CosmereItem< await this.update( { flags: { - 'cosmere-rpg': { + SYSTEM_ID: { favorites: { isFavorite: true, sort: index, @@ -874,25 +1055,88 @@ export class CosmereItem< public async clearFavorite() { await Promise.all([ - this.unsetFlag('cosmere-rpg', 'favorites.isFavorite'), - this.unsetFlag('cosmere-rpg', 'favorites.sort'), + this.unsetFlag(SYSTEM_ID, 'favorites.isFavorite'), + this.unsetFlag(SYSTEM_ID, 'favorites.sort'), ]); } /* --- Helpers --- */ - protected async getEnrichedDescription(): Promise { - if (!this.hasDescription()) return; + protected async getDescriptionHTML(): Promise { + if (!this.hasDescription()) return undefined; + + const descriptionData = (this as CosmereItem) + .system.description?.value; + const description = await TextEditor.enrichHTML(descriptionData ?? ''); + + const traitsNormal = []; + const traitsExpert = []; + const traits = []; + if (this.hasTraits()) { + for (const [key, value] of Object.entries(this.system.traits)) { + if (!value?.active) continue; + + const traitLoc = + CONFIG.COSMERE.traits.weaponTraits[key as WeaponTraitId] ?? + CONFIG.COSMERE.traits.armorTraits[key as ArmorTraitId]; + let label = game.i18n!.localize(traitLoc.label); + + if (value.expertise?.toggleActive) { + label = `${label}`; + traitsExpert.push(label); + } else { + traitsNormal.push(label); + } + } + + traits.push(...traitsNormal.sort(), ...traitsExpert.sort()); + } + + let action; if ( - !(this as CosmereItem).system.description - ?.value - ) - return; - - return await TextEditor.enrichHTML( - (this as CosmereItem).system.description! - .value!, + this.hasActivation() && + this.system.activation?.cost?.value !== undefined + ) { + const activation = this.system.activation as Record< + string, + unknown + >; + const cost = activation.cost as { + type: ActionCostType; + value: number; + }; + + switch (cost.type) { + case ActionCostType.Action: + action = `action${Math.min(3, cost.value)}`; + break; + case ActionCostType.Reaction: + action = 'reaction'; + break; + case ActionCostType.Special: + action = 'special'; + break; + case ActionCostType.FreeAction: + action = 'free'; + break; + default: + action = 'passive'; + break; + } + } + + const sectionHTML = await renderSystemTemplate( + TEMPLATES.CHAT_CARD_DESCRIPTION, + { + title: this.name, + img: this.img, + description, + traits: traits.join(', '), + action, + }, ); + + return sectionHTML; } protected getSkillTestRollData( @@ -1041,6 +1285,8 @@ export namespace CosmereItem { export interface RollAttackOptions extends Omit< RollOptions, + | 'skill' + | 'attribute' | 'parts' | 'opportunity' | 'complication' @@ -1050,6 +1296,8 @@ export namespace CosmereItem { > { skillTest?: Pick< RollOptions, + | 'skill' + | 'attribute' | 'parts' | 'opportunity' | 'complication' @@ -1057,7 +1305,7 @@ export namespace CosmereItem { | 'advantageMode' | 'advantageModePlot' >; - damage?: Pick; + damage?: Pick; } export interface UseOptions extends RollOptions { @@ -1088,3 +1336,6 @@ export type ActionItem = CosmereItem; export type TalentItem = CosmereItem; export type EquipmentItem = CosmereItem; export type WeaponItem = CosmereItem; +export type GoalItem = CosmereItem; +export type PowerItem = CosmereItem; +export type TalentTreeItem = CosmereItem; diff --git a/src/system/hooks/modules/dice-so-nice.ts b/src/system/hooks/modules/dice-so-nice.ts index 7ccfd1fd..0f28fdb7 100644 --- a/src/system/hooks/modules/dice-so-nice.ts +++ b/src/system/hooks/modules/dice-so-nice.ts @@ -1,7 +1,7 @@ -import { IMPORTED_RESOURCES } from '@system/constants'; +import { IMPORTED_RESOURCES, SYSTEM_ID } from '@system/constants'; Hooks.once('diceSoNiceReady', (dice3d: Dice3D) => { - dice3d.addSystem({ id: 'cosmere-rpg', name: 'Cosmere RPG' }, true); + dice3d.addSystem({ id: SYSTEM_ID, name: 'Cosmere RPG' }, true); dice3d.addDicePreset({ type: 'dp', labels: [ @@ -20,6 +20,6 @@ Hooks.once('diceSoNiceReady', (dice3d: Dice3D) => { IMPORTED_RESOURCES.PLOT_DICE_OP_BUMP, IMPORTED_RESOURCES.PLOT_DICE_OP_BUMP, ], - system: 'cosmere-rpg', + system: SYSTEM_ID, }); }); diff --git a/src/system/hooks/sheets.ts b/src/system/hooks/sheets.ts index cec5f551..f8059c11 100644 --- a/src/system/hooks/sheets.ts +++ b/src/system/hooks/sheets.ts @@ -1,9 +1,10 @@ import { BaseItemSheet } from '../applications/item/base'; +import { getSystemSetting, SETTINGS } from '../settings'; Hooks.on( 'renderItemSheetV2', (itemSheet: BaseItemSheet, node: HTMLFormElement) => { - if (game.settings!.get('cosmere-rpg', 'itemSheetSideTabs')) { + if (getSystemSetting(SETTINGS.ITEM_SHEET_SIDE_TABS)) { node.classList.add('side-tabs'); } }, diff --git a/src/system/hooks/welcome.ts b/src/system/hooks/welcome.ts index ab0935bf..e1a9ea99 100644 --- a/src/system/hooks/welcome.ts +++ b/src/system/hooks/welcome.ts @@ -1,12 +1,11 @@ // Dialogs import { ReleaseNotesDialog } from '@system/applications/dialogs/release-notes'; +import { SYSTEM_ID } from '../constants'; +import { getSystemSetting, SETTINGS } from '../settings'; Hooks.on('ready', async () => { // Ensure this message is only displayed when creating a new world - if ( - !game.user!.isGM || - !game.settings!.get('cosmere-rpg', 'firstTimeWorldCreation') - ) + if (!game.user!.isGM || !getSystemSetting(SETTINGS.INTERNAL_FIRST_CREATION)) return; // Get system version @@ -20,7 +19,7 @@ Hooks.on('ready', async () => { }); // Mark the setting so the message doesn't appear again - await game.settings!.set('cosmere-rpg', 'firstTimeWorldCreation', false); + await game.settings!.set(SYSTEM_ID, 'firstTimeWorldCreation', false); }); Hooks.on('ready', async () => { @@ -28,18 +27,13 @@ Hooks.on('ready', async () => { if (!game.user!.isGM) return; const currentVersion = game.system!.version; - const latestVersion = game.settings!.get( - 'cosmere-rpg', - 'latestVersion', + const latestVersion = getSystemSetting( + SETTINGS.INTERNAL_LATEST_VERSION, ) as string; if (currentVersion > latestVersion) { // Record the latest version of the system - await game.settings!.set( - 'cosmere-rpg', - 'latestVersion', - currentVersion, - ); + await game.settings!.set(SYSTEM_ID, 'latestVersion', currentVersion); // Show the release notes void ReleaseNotesDialog.show(); diff --git a/src/system/settings.ts b/src/system/settings.ts index 756a6d5c..54537745 100644 --- a/src/system/settings.ts +++ b/src/system/settings.ts @@ -1,5 +1,35 @@ -export function registerSettings() { - game.settings!.register('cosmere-rpg', 'firstTimeWorldCreation', { +import { SYSTEM_ID } from './constants'; +import { Theme } from './types/cosmere'; +import { setTheme } from './utils/templates'; + +/** + * Index of identifiers for system settings. + */ +export const SETTINGS = { + INTERNAL_FIRST_CREATION: 'firstTimeWorldCreation', + INTERNAL_LATEST_VERSION: 'latestVersion', + ITEM_SHEET_SIDE_TABS: 'itemSheetSideTabs', + ROLL_SKIP_DIALOG_DEFAULT: 'skipRollDialogByDefault', + CHAT_ENABLE_OVERLAY_BUTTONS: 'enableOverlayButtons', + CHAT_ENABLE_APPLY_BUTTONS: 'enableApplyButtons', + CHAT_ALWAYS_SHOW_BUTTONS: 'alwaysShowApplyButtons', + APPLY_BUTTONS_TO: 'applyButtonsTo', + SYSTEM_THEME: 'systemTheme', +} as const; + +export const enum TargetingOptions { + SelectedOnly = 0, + TargetedOnly = 1, + SelectedAndTargeted = 2, + PrioritiseSelected = 3, + PrioritiseTargeted = 4, +} + +/** + * Register all of the system's settings. + */ +export function registerSystemSettings() { + game.settings!.register(SYSTEM_ID, SETTINGS.INTERNAL_FIRST_CREATION, { name: 'First Time World Creation', scope: 'world', config: false, @@ -7,7 +37,7 @@ export function registerSettings() { type: Boolean, }); - game.settings!.register('cosmere-rpg', 'latestVersion', { + game.settings!.register(SYSTEM_ID, SETTINGS.INTERNAL_LATEST_VERSION, { name: 'Latest Version', scope: 'world', config: false, @@ -15,12 +45,165 @@ export function registerSettings() { type: String, }); - game.settings!.register('cosmere-rpg', 'itemSheetSideTabs', { - name: 'Vertical Side Tabs for Item Sheets', - hint: 'Toggle whether Item sheets should use vertical tabs down the right-hand side, similar to the character sheet, or leave the in-line horizontal ones (default).', - scope: 'world', + // SHEET SETTINGS + const sheetOptions = [ + { name: SETTINGS.ITEM_SHEET_SIDE_TABS, default: false }, + ]; + + sheetOptions.forEach((option) => { + game.settings!.register(SYSTEM_ID, option.name, { + name: game.i18n!.localize(`SETTINGS.${option.name}.name`), + hint: game.i18n!.localize(`SETTINGS.${option.name}.hint`), + scope: 'world', + config: true, + type: Boolean, + default: option.default, + }); + }); + + // ROLL SETTINGS + const rollOptions = [ + { name: SETTINGS.ROLL_SKIP_DIALOG_DEFAULT, default: false }, + ]; + + rollOptions.forEach((option) => { + game.settings!.register(SYSTEM_ID, option.name, { + name: game.i18n!.localize(`SETTINGS.${option.name}.name`), + hint: game.i18n!.localize(`SETTINGS.${option.name}.hint`), + scope: 'client', + config: true, + type: Boolean, + default: option.default, + }); + }); + + // CHAT SETTINGS + const chatOptions = [ + { name: SETTINGS.CHAT_ENABLE_OVERLAY_BUTTONS, default: true }, + { name: SETTINGS.CHAT_ENABLE_APPLY_BUTTONS, default: true }, + { name: SETTINGS.CHAT_ALWAYS_SHOW_BUTTONS, default: true }, + ]; + + chatOptions.forEach((option) => { + game.settings!.register(SYSTEM_ID, option.name, { + name: game.i18n!.localize(`SETTINGS.${option.name}.name`), + hint: game.i18n!.localize(`SETTINGS.${option.name}.hint`), + scope: 'client', + config: true, + type: Boolean, + default: option.default, + requiresReload: true, + }); + }); + + game.settings!.register(SYSTEM_ID, SETTINGS.APPLY_BUTTONS_TO, { + name: game.i18n!.localize(`SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.name`), + hint: game.i18n!.localize(`SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.hint`), + scope: 'client', config: true, - type: Boolean, - default: false, + type: Number, + default: TargetingOptions.SelectedOnly as number, + requiresReload: true, + choices: { + [TargetingOptions.SelectedOnly]: game.i18n!.localize( + `SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.choices.SelectedOnly`, + ), + [TargetingOptions.TargetedOnly]: game.i18n!.localize( + `SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.choices.TargetedOnly`, + ), + [TargetingOptions.SelectedAndTargeted]: game.i18n!.localize( + `SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.choices.SelectedAndTargeted`, + ), + [TargetingOptions.PrioritiseSelected]: game.i18n!.localize( + `SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.choices.PrioritiseSelected`, + ), + [TargetingOptions.PrioritiseTargeted]: game.i18n!.localize( + `SETTINGS.${SETTINGS.APPLY_BUTTONS_TO}.choices.PrioritiseTargeted`, + ), + }, }); } + +/** + * Register additional settings after modules have had a chance to initialize to give them a chance to modify choices. + */ +export function registerDeferredSettings() { + game.settings!.register(SYSTEM_ID, SETTINGS.SYSTEM_THEME, { + name: game.i18n!.localize(`SETTINGS.${SETTINGS.SYSTEM_THEME}.name`), + hint: game.i18n!.localize(`SETTINGS.${SETTINGS.SYSTEM_THEME}.hint`), + scope: 'client', + config: true, + type: String, + default: Theme.Default, + choices: { + ...CONFIG.COSMERE.themes, + }, + onChange: (s) => setTheme(document.body, s), + }); + + setTheme(document.body, getSystemSetting(SETTINGS.SYSTEM_THEME) as Theme); +} + +/** + * Index of identifiers for system keybindings. + */ +export const KEYBINDINGS = { + SKIP_DIALOG_DEFAULT: 'skipDialogDefault', + SKIP_DIALOG_ADVANTAGE: 'skipDialogAdvantage', + SKIP_DIALOG_DISADVANTAGE: 'skipDialogDisadvantage', + SKIP_DIALOG_RAISE_STAKES: 'skipDialogRaiseStakes', +} as const; + +/** + * Register all of the system's keybindings. + */ +export function registerSystemKeybindings() { + const keybindings = [ + { + name: KEYBINDINGS.SKIP_DIALOG_DEFAULT, + editable: [{ key: 'AltLeft' }, { key: 'AltRight' }], + }, + { + name: KEYBINDINGS.SKIP_DIALOG_ADVANTAGE, + editable: [{ key: 'ShiftLeft' }, { key: 'ShiftRight' }], + }, + { + name: KEYBINDINGS.SKIP_DIALOG_DISADVANTAGE, + editable: [ + { key: 'ControlLeft' }, + { key: 'ControlRight' }, + { key: 'OsLeft' }, + { key: 'OsRight' }, + ], + }, + { + name: KEYBINDINGS.SKIP_DIALOG_RAISE_STAKES, + editable: [{ key: 'KeyQ' }], + }, + ]; + + keybindings.forEach((keybind) => { + game.keybindings!.register(SYSTEM_ID, keybind.name, { + name: `KEYBINDINGS.${keybind.name}`, + editable: keybind.editable, + }); + }); +} + +/** + * Retrieve a specific setting value for the provided key. + * @param {string} settingKey The identifier of the setting to retrieve. + * @returns {string|boolean} The value of the setting as set for the world/client. + */ +export function getSystemSetting(settingKey: string) { + return game.settings!.get(SYSTEM_ID, settingKey); +} + +/** + * Retrieves an array of keybinding values for the provided key. + * @param {string} keybindingKey The identifier of the keybinding to retrieve. + * @returns {Array} The value of the keybindings associated with the given key. + */ +export function getSystemKeybinding(keybindingKey: string) { + return game.keybindings!.get(SYSTEM_ID, keybindingKey); +} diff --git a/src/system/types/config.ts b/src/system/types/config.ts index 19491101..d53d13f8 100644 --- a/src/system/types/config.ts +++ b/src/system/types/config.ts @@ -29,10 +29,12 @@ import { EquipHand, PathType, EquipmentType, + PowerType, + Theme, } from './cosmere'; import { AdvantageMode } from './roll'; -import { Talent } from './item'; +import { Talent, Goal } from './item'; export interface SizeConfig { label: string; @@ -73,7 +75,14 @@ export interface SkillConfig { key: string; label: string; attribute: Attribute; - attrLabel: string; + + /** + * Whether the skill is a core skill. + * Core skills are visible in the skill list on the character sheet. + */ + core?: boolean; + + // TODO: Replace hiddenUntilAcquired?: boolean; } @@ -218,7 +227,13 @@ export interface TalentTypeConfig { label: string; } +export interface PowerTypeConfig { + label: string; + plural: string; +} + export interface CosmereRPGConfig { + themes: Record; sizes: Record; creatureTypes: Record; conditions: Record; @@ -257,12 +272,21 @@ export interface CosmereRPGConfig { types: Record; }; + goal: { + rewards: { + types: Record; + }; + }; + talent: { types: Record; prerequisite: { types: Record; modes: Record; }; + grantRules: { + types: Record; + }; }; }; @@ -293,6 +317,10 @@ export interface CosmereRPGConfig { types: Record; }; + power: { + types: Record; + }; + damageTypes: Record; cultures: Record; diff --git a/src/system/types/cosmere.ts b/src/system/types/cosmere.ts index 3b3696f9..66e52bab 100644 --- a/src/system/types/cosmere.ts +++ b/src/system/types/cosmere.ts @@ -99,6 +99,10 @@ export const enum PathType { Heroic = 'heroic', } +export const enum PowerType { + None = 'none', +} + /** * The categories of weapon available */ @@ -167,6 +171,7 @@ export const enum ArmorTraitId { Cumbersome = 'cumbersome', Dangerous = 'dangerous', Presentable = 'presentable', + Unique = 'unique', } export const enum AdversaryRole { @@ -269,9 +274,20 @@ export const enum ItemType { Injury = 'injury', Connection = 'connection', + Goal = 'goal', + + Power = 'power', + + TalentTree = 'talent_tree', } export const enum TurnSpeed { Fast = 'fast', Slow = 'slow', } + +export const enum Theme { + Default = 'default', + //TODO: Move to stormlight module + Stormlight = 'stormlight', +} diff --git a/src/system/types/item.ts b/src/system/types/item.ts deleted file mode 100644 index 63e09749..00000000 --- a/src/system/types/item.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Attribute, Skill } from './cosmere'; - -export namespace Talent { - export const enum Type { - Ancestry = 'ancestry', - Path = 'path', - } - - export namespace Prerequisite { - export const enum Type { - Talent = 'talent', - Attribute = 'attribute', - Skill = 'skill', - Connection = 'connection', - } - - export const enum Mode { - AnyOf = 'any-of', - AllOf = 'all-of', - } - - export interface TalentRef { - /** - * UUID of the Talent item this prerequisite refers to. - */ - uuid: string; - /** - * The id of the talent - */ - id: string; - /** - * The name of the talent - */ - label: string; - } - } - - interface BasePrerequisite { - type: Type; - } - - export interface ConnectionPrerequisite - extends BasePrerequisite { - description: string; - } - - export interface AttributePrerequisite - extends BasePrerequisite { - attribute: Attribute; - value: number; - } - - export interface SkillPrerequisite - extends BasePrerequisite { - skill: Skill; - rank: number; - } - - export interface TalentPrerequisite - extends BasePrerequisite { - label?: string; - talents: Prerequisite.TalentRef[]; - mode: Prerequisite.Mode; - } - - export type Prerequisite = - | ConnectionPrerequisite - | AttributePrerequisite - | SkillPrerequisite - | TalentPrerequisite; -} diff --git a/src/system/types/item/goal.ts b/src/system/types/item/goal.ts new file mode 100644 index 00000000..93edc094 --- /dev/null +++ b/src/system/types/item/goal.ts @@ -0,0 +1,33 @@ +import { Skill } from '../cosmere'; + +export namespace Reward { + export const enum Type { + SkillRanks = 'skill-ranks', + Items = 'items', + } +} + +interface BaseReward { + type: Type; +} + +export interface SkillRanksReward extends BaseReward { + /** + * The Skill of which ranks are being granted + */ + skill: Skill; + + /** + * The number of ranks being granted + */ + ranks: number; +} + +export interface ItemsReward extends BaseReward { + /** + * The UUIDs of the items being granted + */ + items: string[]; +} + +export type Reward = SkillRanksReward | ItemsReward; diff --git a/src/system/types/item/index.ts b/src/system/types/item/index.ts new file mode 100644 index 00000000..ff32d3f0 --- /dev/null +++ b/src/system/types/item/index.ts @@ -0,0 +1,3 @@ +export * as Talent from './talent'; +export * as Goal from './goal'; +export * as TalentTree from './talent-tree'; diff --git a/src/system/types/item/talent-tree.ts b/src/system/types/item/talent-tree.ts new file mode 100644 index 00000000..5477778b --- /dev/null +++ b/src/system/types/item/talent-tree.ts @@ -0,0 +1,34 @@ +import { TalentItem, TalentTreeItem } from '@system/documents/item'; + +export namespace Node { + // TODO: Clean up + export const enum Type { + Icon = 'icon', + Text = 'text', + } +} + +export interface Node { + /** + * Unique identifier for the node + */ + id: string; + + /** + * Node type + */ + type: Node.Type; + + /** + * The UUID of the item the node refers to + */ + uuid: string; + + /** + * Position to render the node in the tree + */ + position: { + row: number; + column: number; + }; +} diff --git a/src/system/types/item/talent.ts b/src/system/types/item/talent.ts new file mode 100644 index 00000000..9cde5782 --- /dev/null +++ b/src/system/types/item/talent.ts @@ -0,0 +1,96 @@ +import { Attribute, Skill } from '../cosmere'; + +export const enum Type { + Ancestry = 'ancestry', + Path = 'path', + Power = 'power', +} + +export namespace GrantRule { + export const enum Type { + Items = 'items', + } +} + +export interface BaseGrantRule { + type: Type; +} + +export interface ItemsGrantRule extends BaseGrantRule { + /** + * An array of item UUIDs that are granted by this rule. + */ + items: string[]; +} + +export type GrantRule = ItemsGrantRule; + +export namespace Prerequisite { + export const enum Type { + Talent = 'talent', + Attribute = 'attribute', + Skill = 'skill', + Connection = 'connection', + Level = 'level', + } + + export const enum Mode { + AnyOf = 'any-of', + AllOf = 'all-of', + } + + export interface TalentRef { + /** + * UUID of the Talent item this prerequisite refers to. + */ + uuid: string; + /** + * The id of the talent + */ + id: string; + /** + * The name of the talent + */ + label: string; + } +} + +interface BasePrerequisite { + type: Type; +} + +export interface ConnectionPrerequisite + extends BasePrerequisite { + description: string; +} + +export interface AttributePrerequisite + extends BasePrerequisite { + attribute: Attribute; + value: number; +} + +export interface SkillPrerequisite + extends BasePrerequisite { + skill: Skill; + rank: number; +} + +export interface TalentPrerequisite + extends BasePrerequisite { + label?: string; + talents: Prerequisite.TalentRef[]; + mode: Prerequisite.Mode; +} + +export interface LevelPrerequisite + extends BasePrerequisite { + level: number; +} + +export type Prerequisite = + | ConnectionPrerequisite + | AttributePrerequisite + | SkillPrerequisite + | TalentPrerequisite + | LevelPrerequisite; diff --git a/src/system/util/actor.ts b/src/system/utils/actor.ts similarity index 90% rename from src/system/util/actor.ts rename to src/system/utils/actor.ts index 155133bf..7b476797 100644 --- a/src/system/util/actor.ts +++ b/src/system/utils/actor.ts @@ -20,9 +20,3 @@ export function getTypeLabel(type: CommonActorData['type']): string { // Construct type label return `${primaryLabel} ${subtype ? `(${subtype})` : ''}`.trim(); } - -/* --- Default export --- */ - -export default { - getTypeLabel, -}; diff --git a/src/system/utils/generic.ts b/src/system/utils/generic.ts new file mode 100644 index 00000000..751862ae --- /dev/null +++ b/src/system/utils/generic.ts @@ -0,0 +1,217 @@ +import { CosmereActor } from '../documents'; +import { + getSystemKeybinding, + getSystemSetting, + KEYBINDINGS, + SETTINGS, + TargetingOptions, +} from '../settings'; +import { AdvantageMode } from '../types/roll'; + +/** + * Determine if the keys of a requested keybinding are pressed. + * @param {string} action Keybinding action within the system namespace. Can have multiple keybindings associated. + * @returns {boolean} True if one of the keybindings for the requested action are triggered, false otherwise. + */ +export function areKeysPressed(action: string): boolean { + const keybinds = getSystemKeybinding(action); + + if (!keybinds || keybinds.length === 0) { + return false; + } + + const activeModifiers = {} as Record; + + const addModifiers = (key: string) => { + if (hasKey(KeyboardManager.MODIFIER_CODES, key)) { + KeyboardManager.MODIFIER_CODES[key].forEach( + (n: string) => + (activeModifiers[n] = game.keyboard!.downKeys.has(n)), + ); + } + }; + addModifiers(KeyboardManager.MODIFIER_KEYS.CONTROL); + addModifiers(KeyboardManager.MODIFIER_KEYS.SHIFT); + addModifiers(KeyboardManager.MODIFIER_KEYS.ALT); + + return getSystemKeybinding(action).some((b) => { + if ( + game.keyboard!.downKeys.has(b.key) && + b.modifiers?.every((m) => activeModifiers[m]) + ) + return true; + if (b.modifiers?.length) return false; + return activeModifiers[b.key]; + }); +} + +/** + * Checks if a given object has the given property key as a key for indexing. + * Adding this check beforehand allows an object to be indexed by that key directly without typescript errors. + * @param {T} obj The object to check for indexing. + * @param {PropertyKey} key The key to check within the object. + * @returns {boolean} True if the given object has requested property key, false otherwise. + */ +export function hasKey( + obj: T, + key: PropertyKey, +): key is keyof T { + return key in obj; +} + +/** + * Processes pressed keys and provided config values to determine final values for a roll, specifically: + * if it should skip the configuration dialog, what advantage mode it is using, and if it has raised stakes. + * @param {boolean} [configure] Should the roll dialog be skipped? + * @param {boolean} [advantage] Is something granting this roll advantage? + * @param {boolean} [disadvantage] Is something granting this roll disadvantage? + * @param {boolean} [raiseStakes] Is something granting this roll raised stakes? + * @returns {{fastForward: boolean, advantageMode: AdvantageMode, plotDie: boolean}} Whether a roll should fast forward, have a plot die, and its advantage mode. + */ +export function determineConfigurationMode( + configure?: boolean, + advantage?: boolean, + disadvantage?: boolean, + raiseStakes?: boolean, +) { + const modifiers = { + advantage: areKeysPressed(KEYBINDINGS.SKIP_DIALOG_ADVANTAGE), + disadvantage: areKeysPressed(KEYBINDINGS.SKIP_DIALOG_DISADVANTAGE), + raiseStakes: areKeysPressed(KEYBINDINGS.SKIP_DIALOG_RAISE_STAKES), + }; + + const fastForward = + configure !== undefined + ? !configure + : isFastForward() || Object.values(modifiers).some((k) => k); + + const hasAdvantage = advantage ?? modifiers.advantage; + const hasDisadvantage = disadvantage ?? modifiers.disadvantage; + const advantageMode = hasAdvantage + ? AdvantageMode.Advantage + : hasDisadvantage + ? AdvantageMode.Disadvantage + : AdvantageMode.None; + const plotDie = raiseStakes ?? modifiers.raiseStakes; + + return { fastForward, advantageMode, plotDie }; +} + +/** + * Processes pressed keys and selected system settings to determine if a roll should fast forward. + * This function allows the swappable behaviour of the Skip/Show Dialog modifier key, making it behave correctly depending on the system setting selected by the user. + * @returns {boolean} Whether a roll should fast forward or not. + */ +export function isFastForward() { + const skipKeyPressed = areKeysPressed(KEYBINDINGS.SKIP_DIALOG_DEFAULT); + const skipByDefault = getSystemSetting(SETTINGS.ROLL_SKIP_DIALOG_DEFAULT); + + return ( + (skipByDefault && !skipKeyPressed) || (!skipByDefault && skipKeyPressed) + ); +} + +/** + * Computes the constant value of a roll (i.e. total of numeric terms). + * @param {Roll} roll The roll to calculate the constant total from. + * @returns {number} The total constant value. + */ +export function getConstantFromRoll(roll: Roll) { + let previous: unknown; + let constant = 0; + for (const term of roll.terms) { + if (term instanceof foundry.dice.terms.NumericTerm) { + if ( + previous instanceof foundry.dice.terms.OperatorTerm && + previous.operator === '-' + ) { + constant -= term.number; + } else { + constant += term.number; + } + } + previous = term; + } + + return constant; +} + +/** + * Gets the current set of tokens that are selected or targeted (or both) depending on the chosen setting. + * @returns {Set} A set of tokens that the system considers as current targets. + */ +export function getApplyTargets() { + const setting = getSystemSetting( + SETTINGS.APPLY_BUTTONS_TO, + ) as TargetingOptions; + + const applyToTargeted = + setting === TargetingOptions.TargetedOnly || + setting >= TargetingOptions.SelectedAndTargeted; + const applyToSelected = + setting === TargetingOptions.SelectedOnly || + setting >= TargetingOptions.SelectedAndTargeted; + const prioritiseTargeted = setting === TargetingOptions.PrioritiseTargeted; + const prioritiseSelected = setting === TargetingOptions.PrioritiseSelected; + + const selectTokens = applyToSelected + ? canvas!.tokens!.controlled + : ([] as Token[]); + const targetTokens = applyToTargeted ? game.user!.targets : new Set(); + + if (prioritiseSelected && selectTokens.length > 0) { + targetTokens.clear(); + } + + if (prioritiseTargeted && targetTokens.size > 0) { + selectTokens.length = 0; + } + + return new Set([...selectTokens, ...targetTokens]); +} + +export interface TargetDescriptor { + /** + * The UUID of the target. + */ + uuid: string; + + /** + * The target's name. + */ + name: string; + + /** + * The target's image. + */ + img: string; + + /** + * The target's defense values. + */ + def: { + phy: number; + cog: number; + spi: number; + }; +} + +/** + * Grab the targeted tokens and return relevant information on them. + * @returns {TargetDescriptor[]} + */ +export function getTargetDescriptors() { + const targets = new Map(); + for (const token of game.user!.targets) { + const { name, img, system, uuid } = (token.actor as CosmereActor) ?? {}; + const phy = system.defenses.phy.value.value ?? 10; + const cog = system.defenses.cog.value.value ?? 10; + const spi = system.defenses.spi.value.value ?? 10; + + if (uuid) { + targets.set(uuid, { name, img, uuid, def: { phy, cog, spi } }); + } + } + + return Array.from(targets.values()); +} diff --git a/src/system/util/handlebars/application.ts b/src/system/utils/handlebars/application.ts similarity index 100% rename from src/system/util/handlebars/application.ts rename to src/system/utils/handlebars/application.ts diff --git a/src/system/util/handlebars/index.ts b/src/system/utils/handlebars/index.ts similarity index 88% rename from src/system/util/handlebars/index.ts rename to src/system/utils/handlebars/index.ts index 1126a361..ca360526 100644 --- a/src/system/util/handlebars/index.ts +++ b/src/system/utils/handlebars/index.ts @@ -19,6 +19,8 @@ import { Derived } from '@system/data/fields'; import { AnyObject } from '@src/system/types/utils'; import { ItemContext, ItemContextOptions } from './types'; +import { TEMPLATES } from '../templates'; +import { SYSTEM_ID } from '@src/system/constants'; Handlebars.registerHelper('add', (a: number, b: number) => a + b); Handlebars.registerHelper('sub', (a: number, b: number) => a - b); @@ -151,6 +153,17 @@ Handlebars.registerHelper('effect-duration', (effect: ActiveEffect) => { } }); +Handlebars.registerHelper( + 'inline-partial', + (partialName: string, options?: { hash?: AnyObject }) => { + return new Handlebars.SafeString( + (Handlebars.partials[partialName] as (data?: AnyObject) => string)( + options?.hash, + ).replace(/"/g, '"'), + ); + }, +); + Handlebars.registerHelper( 'itemContext', (item: CosmereItem, options?: { hash?: ItemContextOptions }) => { @@ -467,37 +480,14 @@ Handlebars.registerHelper('damageTypeConfig', (type: DamageType) => { }); export async function preloadHandlebarsTemplates() { - const partials = [ - 'systems/cosmere-rpg/templates/general/tabs.hbs', - 'systems/cosmere-rpg/templates/actors/character/partials/char-details-tab.hbs', - 'systems/cosmere-rpg/templates/actors/character/partials/char-actions-tab.hbs', - 'systems/cosmere-rpg/templates/actors/character/partials/char-equipment-tab.hbs', - 'systems/cosmere-rpg/templates/actors/character/partials/char-goals-tab.hbs', - 'systems/cosmere-rpg/templates/actors/character/partials/char-effects-tab.hbs', - 'systems/cosmere-rpg/templates/actors/adversary/partials/adv-actions-tab.hbs', - 'systems/cosmere-rpg/templates/actors/adversary/partials/adv-effects-tab.hbs', - 'systems/cosmere-rpg/templates/actors/adversary/partials/adv-equipment-tab.hbs', - 'systems/cosmere-rpg/templates/item/partials/item-description-tab.hbs', - 'systems/cosmere-rpg/templates/item/partials/item-effects-tab.hbs', - 'systems/cosmere-rpg/templates/item/partials/item-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/injury/partials/injury-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/specialty/partials/specialty-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/loot/partials/loot-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/armor/partials/armor-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/ancestry/partials/ancestry-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/talent/partials/talent-details-tab.hbs', - 'systems/cosmere-rpg/templates/item/action/partials/action-details-tab.hbs', - 'systems/cosmere-rpg/templates/combat/combatant.hbs', - 'systems/cosmere-rpg/templates/chat/parts/roll-details.hbs', - 'systems/cosmere-rpg/templates/chat/parts/chat-card-header.hbs', - ]; - return await loadTemplates( - partials.reduce( - (partials, path) => { - partials[path.split('/').pop()!.replace('.hbs', '')] = path; - return partials; - }, - {} as Record, - ), + const templates = Object.values(TEMPLATES).reduce( + (partials, path) => { + partials[path.split('/').pop()!.replace('.hbs', '')] = + `systems/${SYSTEM_ID}/templates/${path}`; + return partials; + }, + {} as Record, ); + + return await loadTemplates(templates); } diff --git a/src/system/util/handlebars/types.ts b/src/system/utils/handlebars/types.ts similarity index 100% rename from src/system/util/handlebars/types.ts rename to src/system/utils/handlebars/types.ts diff --git a/src/system/utils/templates.ts b/src/system/utils/templates.ts new file mode 100644 index 00000000..bcb3a6c7 --- /dev/null +++ b/src/system/utils/templates.ts @@ -0,0 +1,99 @@ +import { SYSTEM_ID } from '../constants'; +import { Theme } from '../types/cosmere'; + +/** + * Index of identifiers for system templates. + */ +export const TEMPLATES = { + GENERAL_TABS: 'general/tabs.hbs', + COMBAT_COMBATANT: 'combat/combatant.hbs', + + // ACTOR + ACTOR_CHARACTER_DETAILS_TAB: + 'actors/character/partials/char-details-tab.hbs', + ACTOR_CHARACTER_ACTIONS_TAB: + 'actors/character/partials/char-actions-tab.hbs', + ACTOR_CHARACTER_EQUIPMENT_TAB: + 'actors/character/partials/char-equipment-tab.hbs', + ACTOR_CHARACTER_GOALS_TAB: 'actors/character/partials/char-goals-tab.hbs', + ACTOR_CHARACTER_EFFECTS_TAB: + 'actors/character/partials/char-effects-tab.hbs', + ACTOR_ADVERSARY_ACTIONS_TAB: + 'actors/adversary/partials/adv-actions-tab.hbs', + ACTOR_ADVERSARY_EQUIPMENT_TAB: + 'actors/adversary/partials/adv-equipment-tab.hbs', + ACTOR_ADVERSARY_EFFECTS_TAB: + 'actors/adversary/partials/adv-effects-tab.hbs', + + //ITEM + ITEM_DESCRIPTION_TAB: 'item/partials/item-description-tab.hbs', + ITEM_EFFECTS_TAB: 'item/partials/item-effects-tab.hbs', + ITEM_DETAILS_TAB: 'item/partials/item-details-tab.hbs', + ITEM_INJURY_DETAILS_TAB: 'item/injury/partials/injury-details-tab.hbs', + ITEM_SPECIALTY_DETAILS_TAB: + 'item/specialty/partials/specialty-details-tab.hbs', + ITEM_LOOT_DETAILS_TAB: 'item/loot/partials/loot-details-tab.hbs', + ITEM_ARMOR_DETAILS_TAB: 'item/armor/partials/armor-details-tab.hbs', + ITEM_ANCESTRY_DETAILS_TAB: + 'item/ancestry/partials/ancestry-details-tab.hbs', + ITEM_TALENT_DETAILS_TAB: 'item/talent/partials/talent-details-tab.hbs', + ITEM_ACTION_DETAILS_TAB: 'item/action/partials/action-details-tab.hbs', + ITEM_GOAL_DETAILS_TAB: 'item/goal/partials/goal-details-tab.hbs', + ITEM_POWER_DETAILS_TAB: 'item/power/partials/power-details-tab.hbs', + ITEM_PATH_DETAILS_TAB: 'item/path/partials/path-details-tab.hbs', + ITEM_TALENT_TREE_NODE_TOOLTIP: + 'item/talent-tree/partials/talent-tree-node-tooltip.hbs', + + //CHAT + CHAT_CARD_HEADER: 'chat/card-header.hbs', + CHAT_CARD_CONTENT: 'chat/card-content.hbs', + CHAT_CARD_SECTION: 'chat/card-section.hbs', + CHAT_CARD_DESCRIPTION: 'chat/card-description.hbs', + CHAT_CARD_INJURY: 'chat/card-injury.hbs', + CHAT_CARD_DAMAGE_BUTTONS: 'chat/card-damage-buttons.hbs', + + CHAT_CARD_TRAY_TARGETS: 'chat/card-tray-targets.hbs', + + CHAT_ROLL_D20: 'chat/roll-d20.hbs', + CHAT_ROLL_DAMAGE: 'chat/roll-damage.hbs', + CHAT_ROLL_TOOLTIP: 'chat/roll-tooltip.hbs', + + CHAT_OVERLAY_D20: 'chat/overlay-d20.hbs', + CHAT_OVERLAY_CRIT: 'chat/overlay-crit.hbs', +} as const; + +/** + * Shortcut function to render a custom template from the system templates folder. + * @param {string} template Name (or sub path) of the template in the templates folder. + * @param {object} data The template data to render the template with. + * @returns {Promise} A rendered html template. + * @private + */ +export function renderSystemTemplate( + template: string, + data: object, +): Promise { + return renderTemplate(`systems/${SYSTEM_ID}/templates/${template}`, data); +} + +export const THEME_TAG = 'cosmere-theme'; + +/** + * Set the theme on an element, removing the previous theme class in the process. + * @param {HTMLElement} element Body or sheet element on which to set the theme data. + * @param {Theme} [theme=Theme.Default] Theme key to set. + * @param {string[]} [flags=[]] Additional theming flags to set. + */ +export function setTheme( + element: HTMLElement, + theme: Theme, + flags = new Set(), +) { + const previous = Array.from(element.classList).filter((c) => + c.startsWith(THEME_TAG), + ); + element.classList.remove(...previous); + element.classList.add(`${THEME_TAG}-${theme}`); + element.dataset.theme = theme; + element.dataset.themeFlags = Array.from(flags).join(' '); +} diff --git a/src/templates/actors/adversary/components/skills-group.hbs b/src/templates/actors/adversary/components/skills-group.hbs index e8df872d..3aaefb2a 100644 --- a/src/templates/actors/adversary/components/skills-group.hbs +++ b/src/templates/actors/adversary/components/skills-group.hbs @@ -5,15 +5,11 @@ {{#if skill.active}}
    1. -
      - +{{derived skill.mod }} -
      -
      - {{ localize skill.config.label }} -
      -
      - {{ localize skill.config.attrLabel}} -
      + {{app-actor-skill + skill=skill.id + readonly=(not @root.isEditMode) + pips=false + }}
    2. {{/if}} diff --git a/src/templates/actors/adversary/dialogs/configure-skills.hbs b/src/templates/actors/adversary/dialogs/configure-skills.hbs index b8c6e826..b5c2708d 100644 --- a/src/templates/actors/adversary/dialogs/configure-skills.hbs +++ b/src/templates/actors/adversary/dialogs/configure-skills.hbs @@ -1,5 +1,5 @@
      {{#each attributeGroups as |group|}} - {{app-actor-skills-group group-id=group}} + {{app-actor-skills-group group-id=group core=false}} {{/each}}
      \ No newline at end of file diff --git a/src/templates/actors/character/components/goals-list.hbs b/src/templates/actors/character/components/goals-list.hbs index 0c078142..ee1bc785 100644 --- a/src/templates/actors/character/components/goals-list.hbs +++ b/src/templates/actors/character/components/goals-list.hbs @@ -3,12 +3,11 @@ {{localize "COSMERE.Actor.Sheet.Details.Goals.Label"}} - {{#each goals as |goal index|}} -
    3. + {{#each goals as |goal|}} +
    4. - {{goal.text}} - + {{goal.name}} -
      - {{path.name}} - {{localize path.typeLabel}} Path -
      -
      - {{path.level}} -
      - {{#if @root.isEditMode}} -
      - +
      + {{path.name}} + {{localize path.typeLabel}} Path +
      + +
      - - + {{path.level}} +
      + + {{#if @root.isEditMode}} +
      + + + +
      + {{/if}}
      + + {{!-- Skills --}} + {{#if (gt path.skills.length 0)}} +
        + {{#each path.skills as |skill|}} +
      • + {{app-actor-skill + skill=skill.id + readonly=(not @root.isEditMode) + pips=true + }} +
      • + {{/each}} +
      {{/if}} + {{!-- Background image --}} {{#if img}} -
      - -
      +
      + +
      {{/if}} {{/each}} diff --git a/src/templates/actors/components/attributes.hbs b/src/templates/actors/components/attributes.hbs index b6cd5b62..61f67821 100644 --- a/src/templates/actors/components/attributes.hbs +++ b/src/templates/actors/components/attributes.hbs @@ -49,10 +49,16 @@ {{ localize attribute.config.labelShort }} + {{#if (not @root.isEditMode)}} + + {{else}} + {{/if}} {{/with}} diff --git a/src/templates/actors/components/details.hbs b/src/templates/actors/components/details.hbs index 3d39db02..14185017 100644 --- a/src/templates/actors/components/details.hbs +++ b/src/templates/actors/components/details.hbs @@ -28,6 +28,14 @@ {{derived deflect}} {{/with}} + + {{#if isEditMode}} + + + + {{/if}} {{/if}} @@ -76,6 +84,13 @@ {{#with actor.system.deflect as |deflect|}} {{derived deflect}} {{/with}} + {{#if isEditMode}} + + + + {{/if}} {{localize "COSMERE.Actor.Statistics.Deflect"}} diff --git a/src/templates/actors/components/skill.hbs b/src/templates/actors/components/skill.hbs new file mode 100644 index 00000000..a2dce179 --- /dev/null +++ b/src/templates/actors/components/skill.hbs @@ -0,0 +1,41 @@ +
      + + + {{derived skill.mod }} +
      +
      + {{ localize skill.label }} +
      +
      + {{ localize skill.attributeLabel}} +
      + +{{#if pips}} + +{{/if}} \ No newline at end of file diff --git a/src/templates/actors/components/skills-group.hbs b/src/templates/actors/components/skills-group.hbs index aad19848..4c8742c8 100644 --- a/src/templates/actors/components/skills-group.hbs +++ b/src/templates/actors/components/skills-group.hbs @@ -2,43 +2,8 @@ {{#each skills as |skill|}} {{#if skill.active}} -
    5. -
      - +{{derived skill.mod }} -
      -
      - {{ localize skill.config.label }} -
      -
      - {{ localize skill.config.attrLabel}} -
      - - +
    6. + {{app-actor-skill skill=skill.id readonly=(not @root.isEditMode)}}
    7. {{/if}} diff --git a/src/templates/actors/dialogs/configure-defense.hbs b/src/templates/actors/dialogs/configure-defense.hbs index fe259091..84aaae50 100644 --- a/src/templates/actors/dialogs/configure-defense.hbs +++ b/src/templates/actors/dialogs/configure-defense.hbs @@ -22,12 +22,12 @@
      - {{localize "GENERIC.Bonus"}} + + {{!-- Value --}} +
      + +
      + + {{!-- Mode --}} +
      + + +
      + + {{!-- Natural deflect --}} +
      + + +
      +

      + {{localize "COSMERE.Actor.Deflect.Natural.Hint"}} +

      + + {{!-- Bonus --}} +
      + + +
      + + {{!-- Submit --}} +
      +
      + +
      + \ No newline at end of file diff --git a/src/templates/chat/activity-card.hbs b/src/templates/chat/activity-card.hbs deleted file mode 100644 index e728b27f..00000000 --- a/src/templates/chat/activity-card.hbs +++ /dev/null @@ -1,50 +0,0 @@ -{{#with item as |item|}} -{{#with (itemContext item) as |ctx|}} - -
      -
      -
      - {{ item.name }} -
      - - {{ item.name }} - {{#if ctx.hasActivation}} - {{#if ctx.activation.hasCost}} - - {{#if (eq ctx.activation.cost.type "act")}} - {{ ctx.activation.cost.value }} - {{else}} - {{cosmereDingbat ctx.activation.cost.type}} - {{/if}} - - {{/if}} - {{/if}} - - {{#if subtitle}} - {{{ctx.subtitle}}} - {{/if}} -
      - {{#if @root.hasDescription}} -
      -
      - -
      - {{/if}} -
      - {{#if @root.hasDescription}} -
      -
      - {{{@root.descriptionHTML}}} -
      - {{/if}} -
      - {{#if @root.flavor}} -
      - {{@root.flavor}} -
      - {{/if}} -
      - -{{/with}} -{{/with}} - diff --git a/src/templates/chat/card-content.hbs b/src/templates/chat/card-content.hbs new file mode 100644 index 00000000..9e750b2c --- /dev/null +++ b/src/templates/chat/card-content.hbs @@ -0,0 +1,4 @@ +
      +
      +
      +
      \ No newline at end of file diff --git a/src/templates/chat/card-damage-buttons.hbs b/src/templates/chat/card-damage-buttons.hbs new file mode 100644 index 00000000..3238b8cc --- /dev/null +++ b/src/templates/chat/card-damage-buttons.hbs @@ -0,0 +1,17 @@ +
      + + + + + +
      \ No newline at end of file diff --git a/src/templates/chat/card-description.hbs b/src/templates/chat/card-description.hbs new file mode 100644 index 00000000..bf95c963 --- /dev/null +++ b/src/templates/chat/card-description.hbs @@ -0,0 +1,27 @@ +
      +
      + +
      + {{#if title}} + {{{title}}} + {{/if}} + {{#if subtitle}} + {{{subtitle}}} + {{else}} + {{#if traits}} + {{{traits}}} + {{/if}} + {{/if}} +
      + {{#if action}} + + {{/if}} +
      + {{#if description}} +
      +
      + {{{description}}} +
      +
      + {{/if}} +
      \ No newline at end of file diff --git a/src/templates/chat/parts/chat-card-header.hbs b/src/templates/chat/card-header.hbs similarity index 54% rename from src/templates/chat/parts/chat-card-header.hbs rename to src/templates/chat/card-header.hbs index c6cb0578..d2183b4a 100644 --- a/src/templates/chat/parts/chat-card-header.hbs +++ b/src/templates/chat/card-header.hbs @@ -1,15 +1,22 @@
      -
      - {{name}} -
      +

      + + {{name}} + + {{name}} {{#if subtitle}} {{subtitle}} {{/if}} -

      -
      + + +
      +
      + +
      + {{#if grazeInputCollapsed}} + + + + {{else}} + + + + {{/if}} +
      +
      + {{#if (not grazeInputCollapsed)}} +
      + + +

      + {{localize "COSMERE.Item.Sheet.Damage.GrazeHint"}} +

      +
      + {{/if}} {{/if}} +
      {{/if}} \ No newline at end of file diff --git a/src/templates/item/components/details-id.hbs b/src/templates/item/components/details-id.hbs index 42919bba..58326f71 100644 --- a/src/templates/item/components/details-id.hbs +++ b/src/templates/item/components/details-id.hbs @@ -1,12 +1,12 @@ {{#if hasId}}
      - + {{app-id-input name="system.id" value=item.system.id }}

      - {{note}} + {{localize systemFields.id.hint type=type}}

      {{/if}} \ No newline at end of file diff --git a/src/templates/item/goal/components/rewards-list.hbs b/src/templates/item/goal/components/rewards-list.hbs new file mode 100644 index 00000000..7ab45093 --- /dev/null +++ b/src/templates/item/goal/components/rewards-list.hbs @@ -0,0 +1,60 @@ +
        +
      • +
        + {{localize "GENERIC.Type"}} +
        +
        + {{localize "GENERIC.Description"}} +
        +
        + {{#if editable}} +
        + + + + {{/if}} +
        +
      • + + {{#each rewards as |reward index|}} +
      • +
        + {{localize typeLabel}} +
        + +
        + {{#if (eq reward.type "skill-ranks")}} + + {{localize "COSMERE.Item.Sheet.Goal.Reward.SkillRanksDescriptionValue" + skill=(localize (concat "COSMERE.Skill." reward.skill)) + ranks=reward.ranks + }} + + {{else if (eq reward.type "items")}} + {{#each reward.items as |item|}} + {{{item.link}}} + {{#if (not @last)}} + , + {{/if}} + {{/each}} + {{/if}} +
        + +
        + {{#if @root.editable}} + + + + + + + {{/if}} +
      • + {{/each}} +
      \ No newline at end of file diff --git a/src/templates/item/goal/dialogs/edit-reward.hbs b/src/templates/item/goal/dialogs/edit-reward.hbs new file mode 100644 index 00000000..878b80a0 --- /dev/null +++ b/src/templates/item/goal/dialogs/edit-reward.hbs @@ -0,0 +1,24 @@ +
      + {{formGroup schema.fields.type value=type localize=true}} + + {{#if (eq type "skill-ranks")}} + {{formGroup schema.fields.skill value=skill localize=true blank=false}} + {{formGroup schema.fields.ranks value=ranks localize=true}} + {{else if (eq type "items")}} +
      + {{app-document-drop-list + name="items" + value=items + type="Item" + }} +
      + {{/if}} + + {{!-- Submit --}} +
      +
      + +
      +
      \ No newline at end of file diff --git a/src/templates/item/goal/partials/goal-details-tab.hbs b/src/templates/item/goal/partials/goal-details-tab.hbs new file mode 100644 index 00000000..87fd04ba --- /dev/null +++ b/src/templates/item/goal/partials/goal-details-tab.hbs @@ -0,0 +1,16 @@ +{{#with (lookup tabsMap "details") as |tab|}} + +
      + {{app-item-details-id}} + {{formGroup @root.systemFields.level + value=@root.item.system.level + localize=true + }} + +
      {{localize "COSMERE.Item.Sheet.Goal.Reward.Title"}}
      + {{app-goal-rewards-list + rewards=@root.item.system.rewards + }} +
      + +{{/with}} \ No newline at end of file diff --git a/src/templates/item/goal/parts/sheet-content.hbs b/src/templates/item/goal/parts/sheet-content.hbs new file mode 100644 index 00000000..91758547 --- /dev/null +++ b/src/templates/item/goal/parts/sheet-content.hbs @@ -0,0 +1,9 @@ +
      + {{app-item-header}} + {{> tabs tabs=tabs ignoreLabel=sideTabs}} +
      + {{> item-description-tab}} + {{> goal-details-tab}} + {{> item-effects-tab}} +
      +
      \ No newline at end of file diff --git a/src/templates/item/path/partials/path-details-tab.hbs b/src/templates/item/path/partials/path-details-tab.hbs new file mode 100644 index 00000000..244ae377 --- /dev/null +++ b/src/templates/item/path/partials/path-details-tab.hbs @@ -0,0 +1,22 @@ +{{#with (lookup tabsMap "details") as |tab|}} + +
      + {{app-item-details-id}} + {{app-item-details-type}} + +
      + +
      + {{app-multi-value-select + name="system.linkedSkills" + value=@root.item.system.linkedSkills + options=@root.linkedSkillsOptions + }} +
      +
      +

      + {{localize @root.systemFields.linkedSkills.hint}} +

      +
      + +{{/with}} \ No newline at end of file diff --git a/src/templates/item/path/parts/sheet-content.hbs b/src/templates/item/path/parts/sheet-content.hbs new file mode 100644 index 00000000..c4a7f358 --- /dev/null +++ b/src/templates/item/path/parts/sheet-content.hbs @@ -0,0 +1,9 @@ +
      + {{app-item-header}} + {{> tabs tabs=tabs ignoreLabel=sideTabs}} +
      + {{> item-description-tab}} + {{> path-details-tab}} + {{> item-effects-tab}} +
      +
      \ No newline at end of file diff --git a/src/templates/item/power/partials/power-details-tab.hbs b/src/templates/item/power/partials/power-details-tab.hbs new file mode 100644 index 00000000..062286eb --- /dev/null +++ b/src/templates/item/power/partials/power-details-tab.hbs @@ -0,0 +1,23 @@ +{{#with (lookup tabsMap "details") as |tab|}} + +
      + {{app-item-details-id}} + {{app-item-details-type}} + + {{formGroup @root.systemFields.customSkill + value=@root.item.system.customSkill + localize=true + }} + + {{#if @root.item.system.customSkill}} + {{formGroup @root.systemFields.skill + value=@root.item.system.skill + localize=true + blank="GENERIC.None" + }} + {{/if}} + + {{app-item-details-activation}} +
      + +{{/with}} \ No newline at end of file diff --git a/src/templates/item/power/parts/sheet-content.hbs b/src/templates/item/power/parts/sheet-content.hbs new file mode 100644 index 00000000..f3b24683 --- /dev/null +++ b/src/templates/item/power/parts/sheet-content.hbs @@ -0,0 +1,9 @@ +
      + {{app-item-header}} + {{> tabs tabs=tabs ignoreLabel=sideTabs}} +
      + {{> item-description-tab}} + {{> power-details-tab}} + {{> item-effects-tab}} +
      +
      \ No newline at end of file diff --git a/src/templates/item/talent-tree/dialogs/configure.hbs b/src/templates/item/talent-tree/dialogs/configure.hbs new file mode 100644 index 00000000..71a92476 --- /dev/null +++ b/src/templates/item/talent-tree/dialogs/configure.hbs @@ -0,0 +1,25 @@ +
      + {{!-- Width --}} + {{formGroup + schema.fields.width + value=width + localize=true + name="width" + }} + + {{!-- Height --}} + {{formGroup + schema.fields.height + value=height + localize=true + name="height" + }} + + {{!-- Submit --}} +
      +
      + +
      +
      \ No newline at end of file diff --git a/src/templates/item/talent-tree/partials/talent-tree-node-tooltip.hbs b/src/templates/item/talent-tree/partials/talent-tree-node-tooltip.hbs new file mode 100644 index 00000000..a4015d12 --- /dev/null +++ b/src/templates/item/talent-tree/partials/talent-tree-node-tooltip.hbs @@ -0,0 +1,9 @@ +
      +
      +

      {{title}}

      +
      +
      +
      + {{{description}}} +
      +
      \ No newline at end of file diff --git a/src/templates/item/talent-tree/parts/sheet-content.hbs b/src/templates/item/talent-tree/parts/sheet-content.hbs new file mode 100644 index 00000000..3c574673 --- /dev/null +++ b/src/templates/item/talent-tree/parts/sheet-content.hbs @@ -0,0 +1,30 @@ +
      +
      + {{#times rows as |row|}} + {{#times @root.columns as |column|}} +
      + {{#with (lookup (lookup @root.cells row) column) as |node|}} +
      + +
      + {{else if @root.isEditMode}} +
      + {{/with}} +
      + {{/times}} + {{/times}} +
      + +
      +
      \ No newline at end of file diff --git a/src/templates/item/talent/components/grant-rules-list.hbs b/src/templates/item/talent/components/grant-rules-list.hbs new file mode 100644 index 00000000..b5cf3e55 --- /dev/null +++ b/src/templates/item/talent/components/grant-rules-list.hbs @@ -0,0 +1,52 @@ +
        +
      • +
        + {{localize systemFields.grantRules.model.fields.type.label}} +
        +
        + {{localize "COSMERE.Item.Sheet.Talent.GrantRules.Description"}} +
        +
        + {{#if editable}} +
        + + + + {{/if}} +
        +
      • + + {{#each rules as |rule|}} +
      • +
        + {{localize rule.typeLabel}} +
        +
        + {{#if (eq rule.type "items")}} + {{#each rule.items as |item|}} + {{{item.link}}} + {{#if (not @last)}} + , + {{/if}} + {{/each}} + {{/if}} +
        +
        + {{#if @root.editable}} + + + + + + + {{/if}} +
        +
      • + {{/each}} +
      \ No newline at end of file diff --git a/src/templates/item/talent/components/prerequisites.hbs b/src/templates/item/talent/components/prerequisites.hbs index 9dfcfc33..4cbfb19b 100644 --- a/src/templates/item/talent/components/prerequisites.hbs +++ b/src/templates/item/talent/components/prerequisites.hbs @@ -42,6 +42,8 @@ {{rule.rank}}+ {{else if (eq rule.type "connection")}} {{rule.description}} + {{else if (eq rule.type "level")}} + {{rule.level}}+ {{/if}}
      diff --git a/src/templates/item/talent/dialogs/edit-grant-rule.hbs b/src/templates/item/talent/dialogs/edit-grant-rule.hbs new file mode 100644 index 00000000..6e4b4abe --- /dev/null +++ b/src/templates/item/talent/dialogs/edit-grant-rule.hbs @@ -0,0 +1,27 @@ +
      + {{!-- Type --}} + {{formGroup schema.fields.type + value=type + name="type" + localize=true + }} + + {{!-- Items rule --}} + {{#if (eq type "items")}} +
      + {{app-document-drop-list + name="items" + value=items + type="Item" + }} +
      + {{/if}} + + {{!-- Submit --}} +
      +
      + +
      +
      \ No newline at end of file diff --git a/src/templates/item/talent/dialogs/edit-prerequisite.hbs b/src/templates/item/talent/dialogs/edit-prerequisite.hbs index 6407f714..11d685b2 100644 --- a/src/templates/item/talent/dialogs/edit-prerequisite.hbs +++ b/src/templates/item/talent/dialogs/edit-prerequisite.hbs @@ -2,26 +2,30 @@ {{!-- Type --}}
      - +
      + +
      {{!-- Talent --}} {{#if (eq type "talent")}}
      - {{#if (gt talents.length 1)}} - - {{else}} - - {{/if}} +
      + {{#if (gt talents.length 1)}} + + {{else}} + + {{/if}} +
      @@ -37,20 +41,24 @@ {{#if (eq type "attribute")}}
      - +
      + +
      - +
      + +
      {{/if}} @@ -58,20 +66,24 @@ {{#if (eq type "skill")}}
      - +
      + +
      - +
      + +
      {{/if}} @@ -79,15 +91,26 @@ {{#if (eq type "connection")}}
      - +
      + +
      {{/if}} + {{!-- Level --}} + {{#if (eq type "level")}} + {{formGroup schema.fields.level + value=level + name="level" + localize=true + }} + {{/if}} + {{!-- Submit --}}
      diff --git a/src/templates/item/talent/partials/talent-details-tab.hbs b/src/templates/item/talent/partials/talent-details-tab.hbs index 0cf64bd0..52f2aac3 100644 --- a/src/templates/item/talent/partials/talent-details-tab.hbs +++ b/src/templates/item/talent/partials/talent-details-tab.hbs @@ -45,12 +45,29 @@

      {{/if}} + {{!-- Power talent fields --}} + {{#if @root.isPowerTalent}} + {{formGroup @root.systemFields.power + value=@root.item.system.power + localize=true + blank="GENERIC.None" + }} + {{/if}} + {{!-- Prerequisites --}}
      {{localize "COSMERE.Item.Sheet.Talent.Prerequisites.Label"}}
      {{app-talent-prerequisites}} {{app-item-details-activation}} {{app-item-details-damage}} + + {{!-- Grant rules --}} +
      {{localize @root.systemFields.grantRules.label}}
      + {{app-talent-grant-rules-list}} +

      + {{localize @root.systemFields.grantRules.hint}} +

      + {{app-item-details-modality}}