Compare commits

..

200 commits
v1.1.0 ... main

Author SHA1 Message Date
703dc83681 Merge pull request 'Make the DialogManager more ESM-y' (#15) from chore/esmify-DialogManager into main
Reviewed-on: #15
2025-11-22 03:56:58 +00:00
41034854eb Make the DialogManager more ESM-y 2025-11-21 19:56:07 -07:00
382ca50bb5 Merge pull request 'Data Request API helper' (#10) from feat/data-requests into main
Reviewed-on: #10
2025-11-22 02:51:15 +00:00
e99016e433 Add a title to the QueryStatus app 2025-11-21 19:50:36 -07:00
361a2004d8 Update loading spinner to use CSS variables depending on theme 2025-11-21 19:49:02 -07:00
44977c95cc Make it so requesting data from offline users shows disconnected initially 2025-11-21 19:48:43 -07:00
c6f14b3c21 Remove unused status value 2025-11-21 19:30:31 -07:00
022b6c5b31 Remove / tweak error messages 2025-11-21 19:30:01 -07:00
cb334f41de Add safety check to ensure the response we got was for one of our requests. 2025-11-21 19:28:33 -07:00
cab29d9cd6 Add localization for a bunch of stuff that I initially missed 2025-11-21 19:25:58 -07:00
6997c736dc Implement the localizer helper API 2025-11-21 19:23:58 -07:00
b428eb3bf6 Finish styling the QueryStatus application 2025-11-20 23:37:46 -07:00
c014e17da2 Implement the request cancellation 2025-11-20 22:43:14 -07:00
9ea417ddc1 Add region comments 2025-11-20 22:42:56 -07:00
804c4b3984 Disable the eslint brace-style rule because sometimes inconsistency is better than consistency 2025-11-20 22:42:42 -07:00
c113c326c6 Move the query event handlers into a subfolder and name them in a consistent way 2025-11-20 22:14:43 -07:00
6a2cc1170d Add actions for finishing early and cancelling the request entirely 2025-11-20 17:12:55 -07:00
d60448640f Add methods to handle the finishing and cancellation of requests to the API 2025-11-20 17:12:03 -07:00
860c8b619a Make requestID readonly on the application 2025-11-20 17:11:31 -07:00
df0c69c731 Update the way the QueryManager exports are structured to be more esm-y rather than Java-y 2025-11-19 21:18:42 -07:00
bb095a9b4e Get user re-querying working when they disconnect and improve the user status 2025-11-19 21:02:49 -07:00
1bf6cbbd45 Remove github workflow entirely 2025-11-19 19:36:42 -07:00
c6598ac5fa Add the unlisted releases to the README for manual installation 2025-11-19 19:19:38 -07:00
fd10ba402d Remove github step since we aren't releasing to Github at all 2025-11-18 23:54:23 -07:00
f500152ba7 Remove pnpm lock 2025-11-18 23:48:46 -07:00
bf579a3451 Give the CDN URL to the proper step of the action 2025-11-18 23:29:45 -07:00
5c1985c4ab Re-add the error printing 2025-11-18 23:28:02 -07:00
213996ab0a Correct error with CDN URL creation 2025-11-18 23:25:41 -07:00
53f35562ad Update error handling 2025-11-18 23:22:01 -07:00
d3f9c4c376 Actually commit the correct file this time 2025-11-18 23:18:39 -07:00
9e3bc775b4 Ensure the uploadScript gets the environment variables it requires and clean up asset adding error 2025-11-18 23:16:56 -07:00
7245e89c62 Update package.json for the AWS dependency I forgot to include 2025-11-18 23:06:28 -07:00
a4ae2aefca Remove the Github release shenanigans in favour of uploading to s3 2025-11-18 23:03:01 -07:00
6e5422e08b Try using the stream as the request payload 2025-11-18 17:06:11 -07:00
cd3b5998dd Apparently Github has a different URI for asset uploads 2025-11-18 01:12:09 -07:00
871c820f94 Make the response conform to Github's specification 2025-11-18 01:05:12 -07:00
2ddcda676e Begin work on testing the Github release portion of the action 2025-11-18 00:58:50 -07:00
45de56650a Add missing semicolon 2025-11-17 22:08:37 -07:00
d018aea4f1 Add some informational logs to the manifest preparation 2025-11-17 22:08:14 -07:00
d7db9cb2df Add success log 2025-11-17 22:06:25 -07:00
41d3541c4b Try providing the serialization as an attachment name 2025-11-17 21:59:50 -07:00
5e0028cdd6 Try providing the Content-Type for Axios 2025-11-17 21:52:19 -07:00
a63f0e02d9 Move uploading into a helper function and improve error handling 2025-11-17 21:43:57 -07:00
bb5e27af87 Set release name properly 2025-11-17 21:31:17 -07:00
ea57941472 Remove FormData shenanigans 2025-11-17 21:30:22 -07:00
39d122a882 Use set on the formData instead of append 2025-11-17 21:25:43 -07:00
aa7c231e58 Use a readStream instead of openAsBlob 2025-11-17 21:24:06 -07:00
a06934538e Try a different import method for openAsBlob 2025-11-17 20:58:39 -07:00
4b121c1f0f Remove uses when using a script 2025-11-16 14:49:09 -07:00
351300651b Add script for creating the forgejo release 2025-11-16 14:47:58 -07:00
031fdb4a40 Add token to see if that fixes the issue 2025-11-16 14:05:49 -07:00
760009c9ba Remove name from version extraction 2025-11-16 14:05:38 -07:00
07a55e9064 Add a log for visibility 2025-11-16 14:04:41 -07:00
a4355c608a Create release directory 2025-11-16 13:56:08 -07:00
4275909dc8 Try making the release step work 2025-11-16 13:54:39 -07:00
7841e04dfc Fix yaml syntax 2025-11-16 12:54:24 -07:00
2146d51fde Add an artifact for checking the end result 2025-11-16 12:53:12 -07:00
bb616dbec2 Correct URL template 2025-11-16 12:43:28 -07:00
92ad2607cd Add the scripts and dependency install to the release jobs 2025-11-16 12:33:51 -07:00
72a612d8a9 Run the correct script 2025-11-16 12:28:20 -07:00
14e53455c6 Use the correct artifact download action 2025-11-16 12:25:33 -07:00
49784448e8 Pluralize outputs 2025-11-16 12:24:13 -07:00
eac6a02c04 Apparently only single quotes work as string delimiters 2025-11-16 12:18:44 -07:00
03b647cac1 Prevent boolean casting that breaks the checks 2025-11-16 12:07:45 -07:00
1b986da6d4 Update the forgejo variable reference to be correct 2025-11-16 12:00:12 -07:00
5c030c680d Add names and make the variable reference be correct 2025-11-16 11:57:07 -07:00
f1521992a2 Switch to using the act tag instead of Docker 2025-11-16 11:53:05 -07:00
cf89b53b3b Version bump 2025-11-16 11:25:19 -07:00
9b5b4bb9d1 Add rest of steps back to action 2025-11-16 11:22:33 -07:00
caf6dfa4a3 Remove logs and add error when the tag name is empty 2025-11-16 11:10:09 -07:00
6c150b9b0e Pluralize outputs 2025-11-16 11:08:28 -07:00
0179121c87 Try using a variable directly instead of environment variable 2025-11-16 11:05:42 -07:00
5b63688834 Try using a different output method 2025-11-16 11:01:08 -07:00
2eeeae9eef Try to figure out why the version is getting empty string 2025-11-16 10:49:22 -07:00
57f9c347ff Add functionality back in 2025-11-16 10:43:57 -07:00
9723ea8bdc Comment out functionality to try and figure out what's breaking 2025-11-16 10:39:34 -07:00
7cb5e49d6d Install node dependencies during build 2025-11-16 10:28:51 -07:00
786bd68c35 Update the URL pointers for the manifest creation 2025-11-16 10:27:05 -07:00
088b8c6f5d Remove Github URLs from the manifest 2025-11-16 10:12:26 -07:00
00692431cd Begin work on the manifest preparation step for actions 2025-11-16 02:09:35 -07:00
834f169a80 Tweak the npm scripts 2025-11-16 01:55:07 -07:00
9057cbd682 Add axios as a devDependency for the tagExists script 2025-11-16 01:49:22 -07:00
01e046f916 Remove unneeded file hiding 2025-11-16 01:48:58 -07:00
6df0780676 Finish implementing the tagExists action helper script 2025-11-16 01:48:22 -07:00
58893f46db Begin implementing a script to check if a tag exists via forgejo API for my release workflow 2025-11-16 01:01:26 -07:00
0362342419 Get the QueryStatus application displaying the status more appropriately 2025-11-12 00:09:52 -07:00
5770abb7e8 Add a disconnected icon for the purposes of the QueryStatus app 2025-11-12 00:08:49 -07:00
a242101b5b Begin working on the QueryStatus application for the requestor to monitor user responses 2025-11-09 00:31:04 -07:00
47b68621c1 Add CONST as a readonly global 2025-11-08 19:12:30 -07:00
e79bd4d505 Add the template and styles for the chat notification 2025-11-08 19:12:19 -07:00
db4f57fc90 Add the special case for prompting all but the requesting user 2025-11-08 19:11:52 -07:00
723bcf8735 Add notification and submission tracking for the QueryManager 2025-11-08 19:07:01 -07:00
bd301d69fb Implement the chat notification for the player 2025-11-08 19:06:19 -07:00
b6ab0a229a Rename the notification event name 2025-11-08 19:06:01 -07:00
Eldritch-Oliver
bfd408ef0b Begin implementing the socket event handlers 2025-11-08 00:40:48 -07:00
Eldritch-Oliver
36811b268c Add a QueryManager helper class 2025-11-08 00:40:33 -07:00
Eldritch-Oliver
ce6ac8a93b Add foundations for the data request sockets 2025-11-05 22:41:53 -07:00
Eldritch-Oliver
8632054e63 Switch to using pnpm 2025-10-15 16:40:53 -06:00
f7fee99b44
Merge pull request #48 from Eldritch-Oliver/feature/tabbed-doc-sheet-config
Custom Document Sheet Config
2025-10-05 11:59:21 -06:00
Eldritch-Oliver
a7f91babf7 Remove log 2025-10-05 11:58:58 -06:00
Eldritch-Oliver
696f9e8261 Remove context menu action that is deprecated 2025-10-04 19:42:27 -06:00
Eldritch-Oliver
72ebc0354d Remove old application that is no longer used by anything 2025-10-04 19:41:53 -06:00
Eldritch-Oliver
4e304f7d22 Override the default configureSheet action in order to open the custom DocumentSheetConfig 2025-10-04 19:41:24 -06:00
Eldritch-Oliver
6081b8f9e8 Add a custom DocumentSheetConfig that supports tab-based configuration for my system-specific stuff 2025-10-04 19:40:51 -06:00
Eldritch-Oliver
c7c0deaec7 Make the hotReload target the correct styles directory 2025-10-01 20:52:42 -06:00
Eldritch-Oliver
48e40538dc Restore intellisense for the module code 2025-10-01 00:38:17 -06:00
Eldritch-Oliver
6866bea131 Update scripts to allow auto-linking of Foundry source for intellisense 2025-09-28 00:34:49 -06:00
Eldritch-Oliver
65cc95c35c Begin working on a symlink script to make intellisense better 2025-09-27 11:48:59 -06:00
Eldritch-Oliver
cb266b3c1e Version bump for release 2025-09-17 19:23:34 -06:00
a50d0e8609
Merge pull request #42 from Eldritch-Oliver/feature/resize-controls
Add the ability to tell the system the starting width/height and whether or not a sheet should be resizable
2025-09-17 19:22:00 -06:00
Eldritch-Oliver
b683e8b5a0 Remove unneeded _onRender method 2025-09-17 19:20:49 -06:00
Eldritch-Oliver
baaafcccfc Make the styling of the save button more consistent 2025-09-17 19:13:22 -06:00
Eldritch-Oliver
c29fa3e017 Update the size settings to apply without using the constructor 2025-09-17 19:12:23 -06:00
Eldritch-Oliver
dff8a46ebb Make the size settings apply to the application when it is constructed 2025-09-16 00:45:48 -06:00
Eldritch-Oliver
c50e88e483 Add an application for controlling the size settings. 2025-09-16 00:45:08 -06:00
Eldritch-Oliver
a98af33477 Tweak indentation to be more consistent with other applications 2025-09-16 00:35:05 -06:00
Eldritch-Oliver
171a133563 Update manifest data 2025-09-15 21:57:09 -06:00
Eldritch-Oliver
ca267868a1 Update the draft release procedure to use a different text replacement method (closes #41) 2025-09-06 18:59:07 -06:00
Eldritch-Oliver
df9a63073a Ensure AttributeManager is a singleton per actor 2025-09-04 20:21:06 -06:00
Eldritch-Oliver
cd3f076b7d Improve handling of checkbox inputs within the DialogManager.ask 2025-09-04 19:37:18 -06:00
da57b12800
Merge pull request #40 from Eldritch-Oliver/feature/prevent-item-creation
Prevent item creation and hide the item tab
2025-09-04 19:36:05 -06:00
Eldritch-Oliver
92e7ec1c72 Prevent item creation and hide the item tab 2025-09-04 19:35:11 -06:00
f3a3a65be1
Merge pull request #39 from Eldritch-Oliver/GH-38
Add tooltips for the attribute names on the player sheet
2025-09-04 18:49:25 -06:00
Eldritch-Oliver
865bf87b25 Add tooltips for the attribute names on the player sheet 2025-09-04 18:48:38 -06:00
0bc3594672
Merge pull request #37 from Eldritch-Oliver/GH-34
Allow read-mode on Actor sheets instead of forced edit-only mode
2025-09-01 22:54:38 -06:00
Eldritch-Oliver
5cc94f9185 Allow read-mode on Actor sheets 2025-09-01 22:46:43 -06:00
Oliver-Akins
d66402f5cf Version bump 2025-07-26 22:45:14 -06:00
4b0ddbfc0c
Merge pull request #32 from Oliver-Akins/GH-16
Ensure attribute values remain centered when the attribute name is long
2025-07-26 22:44:42 -06:00
Oliver-Akins
c8d1259e6f Ensure attribute values remain centered when the attribute name is long 2025-07-26 22:44:14 -06:00
ab14729e91
Merge pull request #30 from Oliver-Akins/GH-19
Ask Application Block Improvements
2025-07-26 22:37:35 -06:00
Oliver-Akins
daa88fb272 Add better styling for checkboxes to make them more distinct 2025-07-26 22:36:37 -06:00
Oliver-Akins
08fb6768ad Add a divider block type 2025-07-26 11:06:05 -06:00
Oliver-Akins
fe022b2d43 Remove unused CSS reset 2025-07-26 11:03:17 -06:00
Oliver-Akins
1cdf03fe36 Add the handlebars for the select block 2025-07-26 10:54:06 -06:00
Oliver-Akins
ce65e3a516 Add an error block and validation to help ensure users know when they messed up the block type 2025-07-26 10:53:37 -06:00
Oliver-Akins
7796c82962 Move the prompt wrapper into each block partial so that those which don't need it can leave it out 2025-07-26 10:52:55 -06:00
Oliver-Akins
40820988da Add my typical options helper for ease of use 2025-07-26 10:51:55 -06:00
61f41d610e
Merge pull request #29 from Oliver-Akins/GH-17
Make the checkbox inputs right-aligned in the Ask application
2025-07-25 22:46:34 -06:00
387f9464b8
Merge pull request #27 from Oliver-Akins/GH-21
Make checkbox inputs can default to unchecked
2025-07-25 22:46:01 -06:00
75a09ef8e5
Merge pull request #26 from Oliver-Akins/GH-18
Make sure the details block types span the full width of the ask modal
2025-07-25 22:45:39 -06:00
Oliver-Akins
392923a497 Make the checkbox inputs right-aligned 2025-07-25 22:36:27 -06:00
Oliver-Akins
27dbc2db47 Make checkbox inputs can default to unchecked 2025-07-25 22:28:31 -06:00
Oliver-Akins
d7efa2c4de Make sure the details block types span the full width of the ask modal 2025-07-25 22:21:56 -06:00
312202191d
Merge pull request #25 from Oliver-Akins/fix/#22
Ensure newly created attributes save properly without needing to reorder them first
2025-07-25 22:17:11 -06:00
9ce0e71b21
Merge pull request #24 from Oliver-Akins/fix/#14
Make attribute drag and drop require using the drag handle
2025-07-25 22:16:44 -06:00
a8506be4ef
Merge pull request #23 from Oliver-Akins/fix/missing-icon
Make the drag handle icon for the Attribute Manager actually show up
2025-07-25 22:15:48 -06:00
Oliver-Akins
196da71c1d Ensure newly created attributes save properly without needing to reorder them first 2025-07-25 22:14:31 -06:00
Oliver-Akins
9266257e18 Improve the styling of the dragged element a lil bit to make it retain a background 2025-07-25 22:01:16 -06:00
Oliver-Akins
7d3e6d3653 Tweak what element is required to be used in order to drag an attribute 2025-07-25 22:00:56 -06:00
Oliver-Akins
08a90776ae Add an auto-generated badge to the release content 2025-07-25 21:11:44 -06:00
Oliver-Akins
fa8dc7d037 Fix the build step to include the assets folder 2025-07-25 21:11:30 -06:00
Oliver-Akins
47e5d5168b Version bump 2025-07-24 20:44:55 -06:00
f67efa7ffa
Merge pull request #13 from Oliver-Akins/feature/improved-ask-modal
Improved Ask Modal
2025-07-24 20:43:53 -06:00
Oliver-Akins
44a88cc7b5 Add DialogManager to a global API and create the Ask dialog for the user 2025-07-24 20:34:17 -06:00
Oliver-Akins
02b49687cf Cleanup logger statements 2025-07-24 20:33:07 -06:00
Oliver-Akins
db3cad909e Add a systemFilePath handlebars helper for better file path referencing 2025-07-24 20:23:42 -06:00
Oliver-Akins
cd2c06dc35 Version bump for release 2025-07-13 22:05:32 -06:00
Oliver-Akins
dd7115ae64 Add the attributes as quick references for the actor roll data 2025-07-13 22:00:30 -06:00
Oliver-Akins
b4ec9745e7 Add drag and drop support for reordering the attributes manually in the Attribute Manager 2025-07-13 21:51:20 -06:00
Oliver-Akins
fec638cb22 Add improved capabilities to the Player actor type 2025-07-13 21:50:32 -06:00
Oliver-Akins
02a553e6c9 Create a helper function to sort two attributes 2025-07-13 21:49:40 -06:00
Oliver-Akins
cade7f6ce5 Add drag handle icon 2025-07-13 21:49:10 -06:00
Oliver-Akins
361ddfb7b6 Add custom HTML Elements for icons/SVG loading 2025-07-13 21:49:01 -06:00
Oliver-Akins
0fcf24bfcb Update the toID method to collapse multiple whitespace characters to a single underscore 2025-07-13 19:14:47 -06:00
Oliver-Akins
8c39374038 Fix my github action 2025-07-02 23:18:04 -06:00
292f8caf92
Merge pull request #10 from Oliver-Akins/rewrite/v13
v13 Compatibility and Rewrite
2025-07-02 23:00:23 -06:00
Oliver-Akins
e33c6631d0 Fix the actor image not saving correctly 2025-07-02 22:57:26 -06:00
Oliver-Akins
f9040a29a2 Improve the styling for the actor sheet a bit 2025-07-02 22:48:49 -06:00
Oliver-Akins
57cb54d5e5 Add image to put into the Foundry description 2025-07-02 22:13:19 -06:00
Oliver-Akins
b0a0df928f Update the manifest's description 2025-07-02 21:25:57 -06:00
Oliver-Akins
fab040523f Clean up the github action for consistency 2025-06-30 23:02:53 -06:00
Oliver-Akins
071d027946 Update the manifest's download link 2025-06-30 23:02:13 -06:00
Oliver-Akins
fba6545bad Fix the prose-mirror's light theme styling 2025-06-29 15:56:24 -06:00
Oliver-Akins
e1f173dde7 Add ability to let players who own a document edit the attributes for it via the UI 2025-06-29 15:56:03 -06:00
Oliver-Akins
0cc202e48d Tweak eslint config for global var changes in v13 2025-06-29 13:35:34 -06:00
Oliver-Akins
df3fe3df88 Default max to nullable instead of 0 2025-06-29 13:35:20 -06:00
Oliver-Akins
6b81d8d83b Add a helper application to manage attributes on characters 2025-06-29 13:35:11 -06:00
Oliver-Akins
91ccd93814 Remove handlebars log 2025-06-29 11:04:40 -06:00
Oliver-Akins
72ce47cdd1 Register everything on init 2025-06-29 00:47:56 -06:00
Oliver-Akins
e224f819a3 Create PlayerSheet 2025-06-29 00:47:05 -06:00
Oliver-Akins
e0578d425d Add Logger class 2025-06-29 00:46:45 -06:00
Oliver-Akins
959f75d55c Add Document class changes to make the Token bars work with my attributes object 2025-06-29 00:46:30 -06:00
Oliver-Akins
ae525ce1b8 Create Player data model 2025-06-29 00:46:00 -06:00
Oliver-Akins
8386548f8b Add useful consts 2025-06-29 00:45:48 -06:00
Oliver-Akins
1707ad56db Add English lang file 2025-06-29 00:45:19 -06:00
Oliver-Akins
b790d78461 Remove feature flag reference from the README since those aren't gonna be included in the updated system 2025-06-26 22:21:33 -06:00
Oliver-Akins
c58d6cc58f Add the initial file support for the restructured system using the improved knowledge that I've gained since initial implementation 2025-06-26 22:20:49 -06:00
Oliver-Akins
4f1d6614e5 Wipe the existing code from existance 2025-06-26 22:07:59 -06:00
Oliver-Akins
7253f06236 Add system lock for sanity 2025-06-26 22:06:03 -06:00
Oliver-Akins
9c3b2ae821 Ignore the deprecated folder while I rewrite it 2025-06-26 22:05:54 -06:00
9ea74b12c7
Merge pull request #4 from Oliver-Akins/feature/sheet-size-saving
Sheet Size Saving + Feature Flag Rework
2024-09-29 00:52:26 -06:00
Oliver-Akins
478f91877f Add a ready log to show the feature flags during dev 2024-09-29 00:47:15 -06:00
Oliver-Akins
07cd7951e6 Add some info into the README about the featureflags 2024-09-29 00:46:39 -06:00
Oliver-Akins
a0f20a586f Feature-flag the StorableSize implementation 2024-09-29 00:18:57 -06:00
Oliver-Akins
4a1469ad70 Prevent overwriting the global taf object 2024-09-29 00:18:23 -06:00
Oliver-Akins
4584b1a7a5 Change the way feature flags are working because using settings was a bad idea (and bump version to 2.0.0 since it's an API change) 2024-09-29 00:09:30 -06:00
Oliver-Akins
e0f6b2a8e1 Allow empty catch blocks 2024-09-29 00:05:03 -06:00
Oliver-Akins
713ab3fa00 Make dev settings show in the config when in localhost 2024-09-29 00:04:45 -06:00
Oliver-Akins
d46d727a70 Implement the size storable class mixin 2024-09-29 00:03:43 -06:00
Oliver-Akins
765d2b1476 Version bump 2024-09-28 21:41:19 -06:00
132 changed files with 5853 additions and 4626 deletions

2
.env.template Normal file
View file

@ -0,0 +1,2 @@
# The absolute path to the Foundry installation to create symlinks to
FOUNDRY_ROOT=""

View file

@ -0,0 +1,96 @@
on: [ workflow_dispatch ]
jobs:
create-artifacts:
name: "Create artifacts"
runs-on: act
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: npm clean-install
- id: version
run: cat system.json | echo version=`jq -r ".version"` >> "$FORGEJO_OUTPUT"
- name: Assert that the tag doesn't exist
run: node scripts/tagExists.mjs
env:
TAG_NAME: "v${{steps.version.outputs.version}}"
# Compendia steps
- name: Build compendia
run: "npm run data:build"
- name: Remove compendia source
run: "rm -rf packs/**/_source"
- name: Compress files
run: zip -r release.zip langs module styles templates README.md assets
- name: Upload artifacts
uses: https://data.forgejo.org/forgejo/upload-artifact@v4
with:
path: |
system.json
release.zip
scripts/*.mjs
package-lock.json
package.json
retention-days: 7
if-no-files-found: error
forgejo-release:
name: "Create Forgejo release"
runs-on: act
needs:
- create-artifacts
if: vars.RELEASE_TO_FORGEJO == 'yes'
steps:
- name: Download artifacts
uses: https://data.forgejo.org/forgejo/download-artifact@v4
with:
merge-multiple: true
- name: Install dependencies
run: npm i
- id: version
run: cat system.json | echo version=`jq -r ".version"` >> "$FORGEJO_OUTPUT"
- name: Update manifest
run: node scripts/prepareManifest.mjs
env:
DOWNLOAD_URL: "${{forgejo.server_url}}/${{forgejo.repository}}/releases/download/v${{steps.version.outputs.version}}/release.zip"
LATEST_URL: "${{forgejo.server_url}}/${{forgejo.repository}}/releases/download/latest/system.json"
- name: Add manifest into release archive
run: zip release.zip --update system.json
- name: Upload archive to s3
run: node scripts/uploadToS3.mjs
env:
TAG: "v${{steps.version.outputs.version}}"
FILE: "release.zip"
S3_BUCKET: "${{vars.S3_BUCKET}}"
S3_REGION: "${{vars.S3_REGION}}"
S3_KEY: "${{secrets.S3_KEY}}"
S3_SECRET: "${{secrets.S3_SECRET}}"
S3_ENDPOINT: "${{vars.S3_ENDPOINT}}"
- name: Upload manifest to s3
run: node scripts/uploadToS3.mjs
env:
TAG: "v${{steps.version.outputs.version}}"
FILE: "system.json"
S3_BUCKET: "${{vars.S3_BUCKET}}"
S3_REGION: "${{vars.S3_REGION}}"
S3_KEY: "${{secrets.S3_KEY}}"
S3_SECRET: "${{secrets.S3_SECRET}}"
S3_ENDPOINT: "${{vars.S3_ENDPOINT}}"
- name: Create draft release
run: node scripts/createForgejoRelease.mjs
env:
TAG: "v${{steps.version.outputs.version}}"
CDN_URL: "${{vars.CDN_URL}}"

View file

@ -0,0 +1,9 @@
on:
release:
types: [published]
jobs:
release-to-foundry:
runs-on: docker
steps:
- name: retrieve release URLS
- name: publish to Foundry

View file

@ -1,58 +0,0 @@
name: Create Draft Release
on: [workflow_dispatch]
jobs:
everything:
runs-on: ubuntu-latest
steps:
# Checkout the repository
- uses: actions/checkout@v4
# Install node and NPM
- uses: actions/setup-node@v4
with:
node-version: "20"
# Install required packages
- run: npm install
- name: Reading the system.json for the version
id: "version"
run: cat system.json | echo version=`jq -r ".version"` >> "$GITHUB_OUTPUT"
# Check that tag doesn't exist
- uses: mukunku/tag-exists-action@v1.5.0
id: check-tag
with:
tag: "v${{ steps.version.outputs.version }}"
- name: "Ensure that the tag doesn't exist"
if: ${{ steps.check-tag.outputs.exists == 'true' }}
run: exit 1
- name: Ensure there are specific files to release
if: ${{ vars.files_to_release == '' }}
run: exit 1
# Compile the stuff that needs to be compiled
- run: npm run build
- run: node scripts/buildCompendia.mjs
- name: Move system.json to a temp file
id: manifest-move
run: mv system.json system.temp.json
- name: Update the download property in the manifest
id: manifest-update
run: cat system.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/${{ vars.zip_name }}.zip"' > system.json
- name: Create the zip
run: zip -r ${{ vars.zip_name || 'release' }}.zip ${{ vars.files_to_release }}
- name: Create the draft release
uses: ncipollo/release-action@v1
with:
tag: "v${{ steps.version.outputs.version }}"
commit: ${{ github.ref }}
draft: true
generateReleaseNotes: true
artifacts: "${{vars.zip_name || 'release'}}.zip,system.json"

4
.gitignore vendored
View file

@ -1,2 +1,4 @@
node_modules/ node_modules/
.styles deprecated
.env
/foundry

BIN
.promo/hjonk-samples.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

View file

@ -8,9 +8,9 @@
"git.branchProtection": [], "git.branchProtection": [],
"files.exclude": { "files.exclude": {
"*.lock": true, "*.lock": true,
".styles": false,
"node_modules": true, "node_modules": true,
"packs": true, "packs": true,
"foundry": true
}, },
"html.customData": [ "html.customData": [
"./.vscode/components.html-data.json" "./.vscode/components.html-data.json"

View file

@ -2,3 +2,17 @@
This is an intentionally bare-bones system that features a text-only character This is an intentionally bare-bones system that features a text-only character
sheet, allowing the playing of games that may not otherwise have a Foundry system sheet, allowing the playing of games that may not otherwise have a Foundry system
implementation. implementation.
## Unlisted Releases
Some of the versions of Text-Based Actors are not available in the [Releases list](https://git.varify.ca/Foundry/taf/releases),
these versions are installable manually by using the appropriate manifest link
below:
| Version | Manifest URL
| ------- | ------------
| v2.2.1 | https://cdn.varify.ca/Foundry/taf/v2.2.1/system.json
| v2.2.0 | https://cdn.varify.ca/Foundry/taf/v2.2.0/system.json
| v2.1.0 | https://cdn.varify.ca/Foundry/taf/v2.1.0/system.json
| v2.0.0 | https://cdn.varify.ca/Foundry/taf/v2.0.0/system.json
| v1.1.0 | https://cdn.varify.ca/Foundry/taf/v1.1.0/system.json
| v1.0.0 | https://cdn.varify.ca/Foundry/taf/v1.0.0/system.json

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m91.562 48.438c0.9375-2.8125 1.5625-5.625 1.5625-8.4375 0-13.438-10.938-24.375-24.062-24.375-6.875 0-13.75 3.125-18.125 8.125-3.4375-2.8125-7.8125-4.375-12.5-4.375-11.25 0-20.312 9.0625-20.312 20.312 0 1.25 0 2.5 0.3125 3.75-9.6875 1.5625-16.562 10-16.562 20 0 11.25 9.0625 20.312 20.312 20.312h56.25c11.25 0 20.312-9.0625 20.312-20.312-0.3125-5.3125-2.8125-10.938-7.1875-15zm-28.125 13.125c0.9375 0.9375 0.9375 2.5 0 3.4375-0.3125 0.3125-0.9375 0.625-1.5625 0.625s-1.25-0.3125-1.5625-0.625l-7.8125-7.8125-7.8125 7.8125c-0.3125 0.3125-0.9375 0.625-1.5625 0.625s-1.25-0.3125-1.5625-0.625c-0.9375-0.9375-0.9375-2.5 0-3.4375l7.8125-7.8125-7.8125-7.8125c-0.9375-0.9375-0.9375-2.5 0-3.4375s2.5-0.9375 3.4375 0l7.8125 7.8125 7.8125-7.8125c0.9375-0.9375 2.5-0.9375 3.4375 0s0.9375 2.5 0 3.4375l-7.8125 7.8125z"/>
</svg>

After

Width:  |  Height:  |  Size: 900 B

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="100pt" height="100pt" version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m45.832 75c0 4.582-3.75 8.332-8.332 8.332s-8.332-3.75-8.332-8.332 3.75-8.332 8.332-8.332 8.332 3.75 8.332 8.332zm-8.332-33.332c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332zm0-25c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332zm25 16.664c4.582 0 8.332-3.75 8.332-8.332s-3.75-8.332-8.332-8.332-8.332 3.75-8.332 8.332 3.75 8.332 8.332 8.332zm0 8.3359c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332zm0 25c-4.582 0-8.332 3.75-8.332 8.332s3.75 8.332 8.332 8.332 8.332-3.75 8.332-8.332-3.75-8.332-8.332-8.332z"/>
</svg>

After

Width:  |  Height:  |  Size: 831 B

5
augments.d.ts vendored
View file

@ -1,3 +1,8 @@
declare global {
class Hooks extends foundry.helpers.Hooks {};
const fromUuid = foundry.utils.fromUuid;
}
interface Actor { interface Actor {
/** The system-specific data */ /** The system-specific data */
system: any; system: any;

View file

@ -4,7 +4,7 @@ import stylistic from "@stylistic/eslint-plugin";
export default [ export default [
// Tell eslint to ignore files that I don't mind being formatted slightly differently // Tell eslint to ignore files that I don't mind being formatted slightly differently
{ ignores: [ `scripts/` ] }, { ignores: [ `scripts/`, `foundry/*` ] },
{ {
languageOptions: { languageOptions: {
globals: globals.browser, globals: globals.browser,
@ -16,14 +16,11 @@ export default [
languageOptions: { languageOptions: {
globals: { globals: {
CONFIG: `writable`, CONFIG: `writable`,
CONST: `readonly`,
game: `readonly`, game: `readonly`,
Handlebars: `readonly`, Handlebars: `readonly`,
Hooks: `readonly`, Hooks: `readonly`,
ui: `readonly`, ui: `readonly`,
Actor: `readonly`,
Actors: `readonly`,
Item: `readonly`,
Items: `readonly`,
ActorSheet: `readonly`, ActorSheet: `readonly`,
ItemSheet: `readonly`, ItemSheet: `readonly`,
foundry: `readonly`, foundry: `readonly`,
@ -31,6 +28,8 @@ export default [
ActiveEffect: `readonly`, ActiveEffect: `readonly`,
Dialog: `readonly`, Dialog: `readonly`,
renderTemplate: `readonly`, renderTemplate: `readonly`,
fromUuid: `readonly`,
fromUuidSync: `readonly`,
}, },
}, },
}, },
@ -42,6 +41,7 @@ export default [
languageOptions: { languageOptions: {
globals: { globals: {
Logger: `readonly`, Logger: `readonly`,
taf: `readonly`,
}, },
}, },
rules: { rules: {
@ -49,6 +49,7 @@ export default [
"func-names": [`warn`, `as-needed`], "func-names": [`warn`, `as-needed`],
"grouped-accessor-pairs": `error`, "grouped-accessor-pairs": `error`,
"no-alert": `error`, "no-alert": `error`,
"no-empty": [`error`, { allowEmptyCatch: true }],
"no-implied-eval": `error`, "no-implied-eval": `error`,
"no-invalid-this": `error`, "no-invalid-this": `error`,
"no-lonely-if": `error`, "no-lonely-if": `error`,
@ -72,7 +73,7 @@ export default [
"@stylistic/eol-last": `warn`, "@stylistic/eol-last": `warn`,
"@stylistic/operator-linebreak": [`warn`, `before`], "@stylistic/operator-linebreak": [`warn`, `before`],
"@stylistic/indent": [`warn`, `tab`], "@stylistic/indent": [`warn`, `tab`],
"@stylistic/brace-style": [`warn`, `1tbs`, { "allowSingleLine": true }], "@stylistic/brace-style": [`off`],
"@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }], "@stylistic/quotes": [`warn`, `backtick`, { "avoidEscape": true }],
"@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }], "@stylistic/comma-dangle": [`warn`, { arrays: `always-multiline`, objects: `always-multiline`, imports: `always-multiline`, exports: `always-multiline`, functions: `always-multiline` }],
"@stylistic/comma-style": [`warn`, `last`], "@stylistic/comma-style": [`warn`, `last`],

View file

@ -1,7 +1,19 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "es2022",
"target": "es2022",
"types": [ "types": [
"./augments.d.ts" "./augments.d.ts"
] ],
"paths": {
"@client/*": ["./foundry/client/*"],
"@common/*": ["./foundry/common/*"],
} }
},
"include": [
"module/**/*",
"foundry/client/client.mjs",
"foundry/client/global.d.mts",
"foundry/common/primitives/global.d.mts"
]
} }

60
langs/en-ca.json Normal file
View file

@ -0,0 +1,60 @@
{
"TYPES": {
"Actor": {
"player": "Player"
}
},
"taf": {
"settings": {
"canPlayersManageAttributes": {
"name": "Players Can Manage Attributes",
"hint": "This allows players who have edit access to a document to be able to edit what attributes those characters have via the attribute editor"
}
},
"sheet-names": {
"PlayerSheet": "Player Sheet"
},
"misc": {
"Key": "Key",
"Value": "Value",
"no-data-submitted": "No data submitted",
"data-query-notif-header": "Data Query Notification"
},
"Apps": {
"QueryStatus": {
"title": "Information Request Status",
"user-disconnected-tooltip": "This user is not logged in to Foundry",
"cancel-request": "Cancel Request",
"finish-early": "Finish Request Early",
"send-request": "Send Request"
},
"TAFDocumentSheetConfig": {
"Sizing": "Sizing",
"Width": {
"label": "Width"
},
"Height": {
"label": "Height"
},
"Resizable": {
"label": "Resizable"
},
"tabs": {
"foundry": "Foundry",
"system": "Text-Based Actors"
}
}
},
"sockets": {
"user-list-required": "A list fo users must be provided"
},
"notifs": {
"error": {
"missing-id": "An ID must be provided",
"invalid-socket": "Invalid socket data received, this means a module or system bug is present.",
"unknown-socket-event": "An unknown socket event was received: {event}",
"malformed-socket-payload": "Socket event \"{event}\" received with malformed payload. Details: {details}"
}
}
}
}

36
module/api.mjs Normal file
View file

@ -0,0 +1,36 @@
// Apps
import { Ask } from "./apps/Ask.mjs";
import { AttributeManager } from "./apps/AttributeManager.mjs";
import { PlayerSheet } from "./apps/PlayerSheet.mjs";
import { QueryStatus } from "./apps/QueryStatus.mjs";
// Utils
import { attributeSorter } from "./utils/attributeSort.mjs";
import { DialogManager } from "./utils/DialogManager.mjs";
import { localizer } from "./utils/localizer.mjs";
import { QueryManager } from "./utils/QueryManager.mjs";
import { toID } from "./utils/toID.mjs";
const { deepFreeze } = foundry.utils;
Object.defineProperty(
globalThis,
`taf`,
{
value: deepFreeze({
DialogManager,
QueryManager,
Apps: {
Ask,
AttributeManager,
PlayerSheet,
QueryStatus,
},
utils: {
attributeSorter,
localizer,
toID,
},
}),
},
);

133
module/apps/Ask.mjs Normal file
View file

@ -0,0 +1,133 @@
import { __ID__, filePath } from "../consts.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const validInputTypes = [
`checkbox`,
`details`,
`divider`,
`error`,
`input`,
`select`,
];
export class Ask extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
tag: `dialog`,
classes: [
__ID__,
`dialog`, // accesses some Foundry-provided styling
`Ask`,
],
position: {
width: 330,
},
window: {
title: `Questions`,
resizable: true,
minimizable: true,
contentTag: `form`,
},
form: {
closeOnSubmit: true,
submitOnChange: false,
handler: this.#submit,
},
actions: {
cancel: this.#cancel,
},
};
static PARTS = {
inputs: {
template: filePath(`templates/Ask/inputs.hbs`),
templates: validInputTypes.map(type => filePath(`templates/Ask/inputs/${type}.hbs`)),
},
controls: {
template: filePath(`templates/Ask/controls.hbs`),
},
};
// #endregion Options
// #region Instance
_inputs = [];
alwaysUseAnswerObject = false;
/** @type {string | undefined} */
_description = undefined;
/** @type {Function | undefined} */
_userOnConfirm;
/** @type {Function | undefined} */
_userOnCancel;
/** @type {Function | undefined} */
_userOnClose;
constructor({
inputs = [],
description = undefined,
onConfirm,
onCancel,
onClose,
alwaysUseAnswerObject,
...options
} = {}) {
super(options);
this.alwaysUseAnswerObject = alwaysUseAnswerObject;
for (const input of inputs) {
if (!validInputTypes.includes(input.type)) {
input.details = `Invalid input type provided: ${input.type}`;
input.type = `error`;
};
};
this._inputs = inputs;
this._description = description;
this._userOnCancel = onCancel;
this._userOnConfirm = onConfirm;
this._userOnClose = onClose;
};
// #endregion Instance
// #region Lifecycle
async _onFirstRender() {
super._onFirstRender();
this.element.show();
};
async _prepareContext() {
return {
inputs: this._inputs,
description: this._description,
};
};
async _onClose() {
super._onClose();
this._userOnClose?.();
};
// #endregion Lifecycle
// #region Actions
/** @this {AskDialog} */
static async #submit(_event, _element, formData) {
const answers = formData.object;
const keys = Object.keys(answers);
if (keys.length === 1 && !this.alwaysUseAnswerObject) {
this._userOnConfirm?.(answers[keys[0]]);
return;
};
this._userOnConfirm?.(answers);
};
/** @this {AskDialog} */
static async #cancel() {
this._userOnCancel?.();
this.close();
};
// #endregion Actions
};

View file

@ -0,0 +1,266 @@
import { __ID__, filePath } from "../consts.mjs";
import { attributeSorter } from "../utils/attributeSort.mjs";
import { toID } from "../utils/toID.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { deepClone, diffObject, mergeObject, performIntegerSort, randomID, setProperty } = foundry.utils;
const { DragDrop, TextEditor } = foundry.applications.ux;
export class AttributeManager extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
tag: `form`,
classes: [
__ID__,
`AttributeManager`,
],
position: {
width: 400,
height: `auto`,
},
window: {
resizable: true,
},
form: {
submitOnChange: false,
closeOnSubmit: true,
handler: this.#onSubmit,
},
actions: {
addNew: this.#addNew,
removeAttribute: this.#remove,
},
};
static PARTS = {
attributes: { template: filePath(`templates/AttributeManager/attribute-list.hbs`) },
controls: { template: filePath(`templates/AttributeManager/controls.hbs`) },
};
// #endregion Options
// #region Instance Data
/** @type {string | null} */
#doc = null;
#attributes;
constructor({ document , ...options } = {}) {
super(options);
this.#doc = document;
this.#attributes = deepClone(document.system.attr);
};
get title() {
return `Attributes: ${this.#doc.name}`;
};
// #endregion Instance Data
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
const elements = this.element
.querySelectorAll(`[data-bind]`);
for (const input of elements) {
input.addEventListener(`change`, this.#bindListener.bind(this));
};
new DragDrop.implementation({
dragSelector: `.draggable`,
permissions: {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this),
},
callbacks: {
dragstart: this._onDragStart.bind(this),
drop: this._onDrop.bind(this),
},
}).bind(this.element);
};
// #endregion Lifecycle
// #region Data Prep
async _preparePartContext(partId) {
const ctx = {};
ctx.actor = this.#doc;
switch (partId) {
case `attributes`: {
await this._prepareAttributeContext(ctx);
};
};
return ctx;
};
async _prepareAttributeContext(ctx) {
const attrs = [];
for (const [id, data] of Object.entries(this.#attributes)) {
if (data == null) { continue };
attrs.push({
id,
name: data.name,
displayName: data.isNew ? `New Attribute` : data.name,
sort: data.sort,
isRange: data.isRange,
isNew: data.isNew ?? false,
});
};
ctx.attrs = attrs.sort(attributeSorter);
};
// #endregion Data Prep
// #region Actions
/**
* @param {Event} event
*/
async #bindListener(event) {
const target = event.target;
const data = target.dataset;
const binding = data.bind;
let value = target.value;
switch (target.type) {
case `checkbox`: {
value = target.checked;
};
};
setProperty(this.#attributes, binding, value);
await this.render({ parts: [ `attributes` ]});
};
/** @this {AttributeManager} */
static async #addNew() {
const id = randomID();
this.#attributes[id] = {
name: ``,
sort: Number.MAX_SAFE_INTEGER,
isRange: false,
isNew: true,
};
await this.render({ parts: [ `attributes` ]});
};
/** @this {AttributeManager} */
static async #remove($e, element) {
const attribute = element.closest(`[data-attribute]`)?.dataset.attribute;
if (!attribute) { return };
delete this.#attributes[attribute];
this.#attributes[`-=${attribute}`] = null;
await this.render({ parts: [ `attributes` ] });
};
/** @this {AttributeManager} */
static async #onSubmit() {
const entries = Object.entries(this.#attributes)
.map(([id, attr]) => {
if (attr == null) {
return [ id, attr ];
};
if (attr.isNew) {
delete attr.isNew;
return [ toID(attr.name), attr ];
};
return [ id, attr ];
});
const data = Object.fromEntries(entries);
const diff = diffObject(
this.#doc.system.attr,
data,
{ inner: false, deletionKeys: true },
);
await this.#doc.update({ "system.attr": diff });
};
// #endregion Actions
// #region Drag & Drop
_canDragStart() {
return this.#doc.isOwner;
};
_canDragDrop() {
return this.#doc.isOwner;
};
_onDragStart(event) {
const target = event.currentTarget.closest(`[data-attribute]`);
if (`link` in event.target.dataset) { return };
let dragData;
if (target.dataset.attribute) {
const attributeID = target.dataset.attribute;
const attribute = this.#attributes[attributeID];
dragData = {
_id: attributeID,
sort: attribute.sort,
};
};
if (!dragData) { return };
event.dataTransfer.setDragImage(target, 16, 23);
event.dataTransfer.setData(`text/plain`, JSON.stringify(dragData));
};
_onDrop(event) {
const dropped = TextEditor.implementation.getDragEventData(event);
const dropTarget = event.target.closest(`[data-attribute]`);
if (!dropTarget) { return };
const targetID = dropTarget.dataset.attribute;
let target;
// Not moving location, ignore drop event
if (targetID === dropped._id) { return };
// Determine all of the siblings and create sort data
const siblings = [];
for (const element of dropTarget.parentElement.children) {
const siblingID = element.dataset.attribute;
const attr = this.#attributes[siblingID];
const sibling = {
_id: siblingID,
sort: attr.sort,
};
if (siblingID && siblingID !== dropped._id) {
siblings.push(sibling);
};
if (siblingID === targetID) {
target = sibling;
}
};
const sortUpdates = performIntegerSort(
dropped,
{
target,
siblings,
},
);
const updateEntries = sortUpdates.map(({ target, update }) => {
return [ `${target._id}.sort`, update.sort ];
});
const update = Object.fromEntries(updateEntries);
mergeObject(
this.#attributes,
update,
{
insertKeys: false,
insertValues: false,
inplace: true,
performDeletions: false,
},
);
this.render({ parts: [ `attributes` ] });
};
// #endregion Drag & Drop
};

164
module/apps/PlayerSheet.mjs Normal file
View file

@ -0,0 +1,164 @@
import { __ID__, filePath } from "../consts.mjs";
import { AttributeManager } from "./AttributeManager.mjs";
import { attributeSorter } from "../utils/attributeSort.mjs";
import { TAFDocumentSheetConfig } from "./TAFDocumentSheetConfig.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { getProperty } = foundry.utils;
export class PlayerSheet extends HandlebarsApplicationMixin(ActorSheetV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
__ID__,
`PlayerSheet`,
],
position: {
width: 575,
height: 740,
},
window: {
resizable: true,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
actions: {
manageAttributes: this.#manageAttributes,
configureSheet: this.#configureSheet,
},
};
static PARTS = {
header: { template: filePath(`templates/PlayerSheet/header.hbs`) },
attributes: { template: filePath(`templates/PlayerSheet/attributes.hbs`) },
content: { template: filePath(`templates/PlayerSheet/content.hbs`) },
};
// #endregion Options
// #region Lifecycle
_initializeApplicationOptions(options) {
const sizing = getProperty(options.document, `flags.${__ID__}.PlayerSheet.size`) ?? {};
options.window ??= {};
switch (sizing.resizable) {
case `false`:
options.window.resizable ??= false;
break;
case `true`:
options.window.resizable ??= true;
break;
};
options.position ??= {};
if (sizing.width) {
options.position.width ??= sizing.width;
};
if (sizing.height) {
options.position.height ??= sizing.height;
};
return super._initializeApplicationOptions(options);
};
_getHeaderControls() {
const controls = super._getHeaderControls();
controls.push({
icon: `fa-solid fa-at`,
label: `Manage Attributes`,
action: `manageAttributes`,
visible: () => {
const isGM = game.user.isGM;
const allowPlayerEdits = game.settings.get(__ID__, `canPlayersManageAttributes`);
const editable = this.isEditable;
return isGM || (allowPlayerEdits && editable);
},
});
return controls;
};
async close() {
this.#attributeManager?.close();
this.#attributeManager = null;
return super.close();
};
// #endregion Lifecycle
// #region Data Prep
async _preparePartContext(partID) {
let ctx = {
actor: this.actor,
system: this.actor.system,
editable: this.isEditable,
};
switch (partID) {
case `attributes`: {
await this._prepareAttributes(ctx);
break;
};
case `content`: {
await this._prepareContent(ctx);
break;
};
};
return ctx;
};
async _prepareAttributes(ctx) {
ctx.hasAttributes = this.actor.system.hasAttributes;
const attrs = [];
for (const [id, data] of Object.entries(this.actor.system.attr)) {
attrs.push({
...data,
id,
path: `system.attr.${id}`,
});
};
ctx.attrs = attrs.toSorted(attributeSorter);
};
async _prepareContent(ctx) {
const TextEditor = foundry.applications.ux.TextEditor.implementation;
ctx.enriched = {
system: {
content: await TextEditor.enrichHTML(this.actor.system.content),
},
};
};
// #endregion Data Prep
// #region Actions
#attributeManager = null;
/** @this {PlayerSheet} */
static async #manageAttributes() {
this.#attributeManager ??= new AttributeManager({ document: this.actor });
if (this.#attributeManager.rendered) {
await this.#attributeManager.bringToFront();
} else {
await this.#attributeManager.render({ force: true });
};
};
static async #configureSheet(event) {
event.stopPropagation();
if ( event.detail > 1 ) { return }
// const docSheetConfigWidth = TAFDocumentSheetConfig.DEFAULT_OPTIONS.position.width;
new TAFDocumentSheetConfig({
document: this.document,
position: {
top: this.position.top + 40,
left: this.position.left + ((this.position.width - 60) / 2),
},
}).render({ force: true });
};
// #endregion Actions
};

111
module/apps/QueryStatus.mjs Normal file
View file

@ -0,0 +1,111 @@
import { __ID__, filePath } from "../consts.mjs";
import { cancel, finish, get as getQuery, requery } from "../utils/QueryManager.mjs";
import { Logger } from "../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class QueryStatus extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
__ID__,
`QueryStatus`,
],
position: {
width: 300,
height: `auto`,
},
window: {
title: `taf.Apps.QueryStatus.title`,
resizable: true,
},
actions: {
promptUser: this.promptUser,
finishEarly: this.finishEarly,
cancelRequest: this.cancelRequest,
},
};
static PARTS = {
users: {
template: filePath(`templates/QueryStatus/users.hbs`),
},
controls: {
template: filePath(`templates/QueryStatus/controls.hbs`),
},
};
// #endregion Options
// #region Instance
/** @type {string} */
#requestID;
constructor({
requestID,
...opts
}) {
if (!requestID) {
Logger.error(`A requestID must be provided for QueryStatus applications`);
return null;
};
super(opts);
this.#requestID = requestID;
};
get requestID() {
return this.#requestID;
};
// #endregion Instance
// #region Lifecycle
async _preparePartContext(partID) {
const ctx = {};
switch (partID) {
case `users`: {
this._prepareUsers(ctx);
break;
};
};
return ctx;
};
async _prepareUsers(ctx) {
const query = getQuery(this.#requestID);
if (!query) { return };
const users = [];
for (const userID of query.users) {
const user = game.users.get(userID);
users.push({
id: userID,
name: user.name,
active: user.active,
answers: query.responses[userID] ?? null,
status: query.status[userID],
});
};
ctx.users = users;
};
// #endregion Lifecycle
// #region Actions
/** @this {QueryStatus} */
static async promptUser($e, element) {
const userID = element.closest(`[data-user-id]`)?.dataset.userId;
if (!userID) { return };
requery(this.#requestID, [ userID ]);
};
/** @this {QueryStatus} */
static async cancelRequest() {
cancel(this.#requestID);
};
/** @this {QueryStatus} */
static async finishEarly() {
finish(this.#requestID);
};
// #endregion Actions
};

View file

@ -0,0 +1,171 @@
import { __ID__, filePath } from "../consts.mjs";
import { getDefaultSizing } from "../utils/getSizing.mjs";
const { diffObject, expandObject, flattenObject } = foundry.utils;
const { DocumentSheetConfig } = foundry.applications.apps;
const { CONST } = foundry;
export class TAFDocumentSheetConfig extends DocumentSheetConfig {
// #region Options
static DEFAULT_OPTIONS = {
classes: [`taf`],
form: {
handler: this.#onSubmit,
},
};
static get PARTS() {
const { form, footer } = super.PARTS;
return {
tabs: { template: `templates/generic/tab-navigation.hbs` },
foundryTab: {
...form,
template: filePath(`templates/TAFDocumentSheetConfig/foundry.hbs`),
templates: [ `templates/sheets/document-sheet-config.hbs` ],
},
systemTab: {
template: filePath(`templates/TAFDocumentSheetConfig/system.hbs`),
classes: [`standard-form`],
},
footer,
};
};
static TABS = {
main: {
initial: `system`,
labelPrefix: `taf.Apps.TAFDocumentSheetConfig.tabs`,
tabs: [
{ id: `system` },
{ id: `foundry` },
],
},
};
// #endregion Options
// #region Data Prep
async _preparePartContext(partID, context, options) {
this._prepareTabs(`main`);
context.meta = {
idp: this.id,
};
switch (partID) {
case `foundryTab`: {
await this._prepareFormContext(context, options);
break;
};
case `systemTab`: {
await this._prepareSystemSettingsContext(context, options);
break;
};
case `footer`: {
await this._prepareFooterContext(context, options);
break;
};
};
return context;
};
async _prepareSystemSettingsContext(context, _options) {
// Inherited values for placeholders
const defaults = getDefaultSizing();
context.placeholders = {
...defaults,
resizable: defaults.resizable ? `Resizable` : `Not Resizable`,
};
// Custom values from document itself
const sheetConfig = this.document.getFlag(__ID__, `PlayerSheet`) ?? {};
const sizing = sheetConfig.size ?? {};
context.values = {
width: sizing.width,
height: sizing.height,
resizable: sizing.resizable ?? ``,
};
// Static prep
context.resizeOptions = [
{ label: `Default (${context.placeholders.resizable})`, value: `` },
{ label: `Resizable`, value: `true` },
{ label: `No Resizing`, value: `false` },
];
};
// #endregion Data Prep
// #region Actions
/** @this {TAFDocumentSheetConfig} */
static async #onSubmit(event, form, formData) {
const foundryReopen = await TAFDocumentSheetConfig.#submitFoundry.call(this, event, form, formData);
const systemReopen = await TAFDocumentSheetConfig.#submitSystem.call(this, event, form, formData);
if (foundryReopen || systemReopen) {
this.document._onSheetChange({ sheetOpen: true });
};
};
/**
* This method is mostly the form submission handler that foundry uses in
* DocumentSheetConfig, however because we clobber that in order to save our
* own config stuff as well, we need to duplicate Foundry's handling and tweak
* it a bit to make it work nicely with our custom saving.
*
* @this {TAFDocumentSheetConfig}
*/
static async #submitFoundry(_event, _form, formData) {
const { object } = formData;
const { documentName, type = CONST.BASE_DOCUMENT_TYPE } = this.document;
// Update themes.
const themes = game.settings.get(`core`, `sheetThemes`);
const defaultTheme = foundry.utils.getProperty(themes, `defaults.${documentName}.${type}`);
const documentTheme = themes.documents?.[this.document.uuid];
const themeChanged = (object.defaultTheme !== defaultTheme) || (object.theme !== documentTheme);
if (themeChanged) {
foundry.utils.setProperty(themes, `defaults.${documentName}.${type}`, object.defaultTheme);
themes.documents ??= {};
themes.documents[this.document.uuid] = object.theme;
await game.settings.set(`core`, `sheetThemes`, themes);
}
// Update sheets.
const { defaultClass } = this.constructor.getSheetClassesForSubType(documentName, type);
const sheetClass = this.document.getFlag(`core`, `sheetClass`) ?? ``;
const defaultSheetChanged = object.defaultClass !== defaultClass;
const documentSheetChanged = object.sheetClass !== sheetClass;
if (themeChanged || (game.user.isGM && defaultSheetChanged)) {
if (game.user.isGM && defaultSheetChanged) {
const setting = game.settings.get(`core`, `sheetClasses`);
foundry.utils.setProperty(setting, `${documentName}.${type}`, object.defaultClass);
await game.settings.set(`core`, `sheetClasses`, setting);
}
// This causes us to manually rerender the sheet due to the theme or default
// sheet class changing resulting in no update making it to the client-document's
// _onUpdate handling
if (!documentSheetChanged) {
return true;
}
}
// Update the document-specific override.
if (documentSheetChanged) {
this.document.setFlag(`core`, `sheetClass`, object.sheetClass);
};
return false;
};
/** @this {TAFDocumentSheetConfig} */
static async #submitSystem(_event, _form, formData) {
const { FLAGS: flags } = expandObject(formData.object);
const diff = flattenObject(diffObject(this.document.flags, flags));
const hasChanges = Object.keys(diff).length > 0;
if (hasChanges) {
await this.document.update({ flags });
};
return hasChanges;
};
// #endregion Actions
};

View file

@ -0,0 +1,11 @@
import { TafSVGLoader } from "./svgLoader.mjs";
/**
Attributes:
@property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file
*/
export class TafIcon extends TafSVGLoader {
static elementName = `taf-icon`;
static _stylePath = `icon.css`;
};

View file

@ -1,3 +1,5 @@
import { filePath } from "../../consts.mjs";
/** /**
* @param {HTMLElement} Base * @param {HTMLElement} Base
*/ */
@ -11,9 +13,9 @@ export function StyledShadowElement(Base) {
/** /**
* The stringified CSS to use * The stringified CSS to use
* @type {string} * @type {Map<string, string>}
*/ */
static _styles; static _styles = new Map();
/** /**
* The HTML element of the stylesheet * The HTML element of the stylesheet
@ -24,12 +26,6 @@ export function StyledShadowElement(Base) {
/** @type {ShadowRoot} */ /** @type {ShadowRoot} */
_shadow; _shadow;
/**
* The hook ID for this element's CSS hot reload
* @type {number}
*/
#cssHmr;
constructor() { constructor() {
super(); super();
@ -40,38 +36,28 @@ export function StyledShadowElement(Base) {
#mounted = false; #mounted = false;
connectedCallback() { connectedCallback() {
if (this.#mounted) { return } if (this.#mounted) { return };
this._getStyles(); this._getStyles();
if (game.settings.get(`dotdungeon`, `devMode`)) {
this.#cssHmr = Hooks.on(`dd-hmr:css`, (data) => {
if (data.path.endsWith(this.constructor._stylePath)) {
this._style.innerHTML = data.content;
};
});
};
this.#mounted = true; this.#mounted = true;
}; };
disconnectedCallback() { disconnectedCallback() {
if (!this.#mounted) { return } if (!this.#mounted) { return };
if (this.#cssHmr != null) {
Hooks.off(`dd-hmr:css`, this.#cssHmr);
this.#cssHmr = null;
};
this.#mounted = false; this.#mounted = false;
}; };
_getStyles() { _getStyles() {
if (this.constructor._styles) { // TODO: Cache the CSS content in a more sane way that doesn't break
this._style.innerHTML = this.constructor._styles; const stylePath = this.constructor._stylePath;
if (this.constructor._styles.has(stylePath)) {
this._style.innerHTML = this.constructor._styles.get(stylePath);
} else { } else {
fetch(`./systems/dotdungeon/.styles/${this.constructor._stylePath}`) fetch(filePath(`styles/components/${stylePath}`))
.then(r => r.text()) .then(r => r.text())
.then(t => { .then(t => {
this.constructor._styles = t; this.constructor._styles.set(stylePath, t);
this._style.innerHTML = t; this._style.innerHTML = t;
}); });
} }

View file

@ -0,0 +1,24 @@
import { Logger } from "../../utils/Logger.mjs";
import { TafIcon } from "./Icon.mjs";
import { TafSVGLoader } from "./svgLoader.mjs";
const components = [
TafSVGLoader,
TafIcon,
];
export function registerCustomComponents() {
(CONFIG.CACHE ??= {}).componentListeners ??= [];
for (const component of components) {
if (!window.customElements.get(component.elementName)) {
Logger.debug(`Registering component "${component.elementName}"`);
window.customElements.define(
component.elementName,
component,
);
if (component.formAssociated) {
CONFIG.CACHE.componentListeners.push(component.elementName);
}
};
}
};

View file

@ -1,16 +1,18 @@
import { StyledShadowElement } from "./mixins/Styles.mjs"; import { filePath } from "../../consts.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { StyledShadowElement } from "./StyledShadowElement.mjs";
/** /**
Attributes: Attributes:
@property {string} name - The name of the icon, takes precedence over the path @property {string} name - The name of the icon, takes precedence over the path
@property {string} path - The path of the icon file @property {string} path - The path of the icon file
*/ */
export class SystemIcon extends StyledShadowElement(HTMLElement) { export class TafSVGLoader extends StyledShadowElement(HTMLElement) {
static elementName = `dd-icon`; static elementName = `taf-svg`;
static formAssociated = false; static formAssociated = false;
/* Stuff for the mixin to use */ /* Stuff for the mixin to use */
static _stylePath = ``; static _stylePath = `svg-loader.css`;
static _cache = new Map(); static _cache = new Map();
@ -20,12 +22,8 @@ export class SystemIcon extends StyledShadowElement(HTMLElement) {
/** @type {null | string} */ /** @type {null | string} */
_path; _path;
/* Stored IDs for all of the hooks that are in this component */
#svgHmr;
constructor() { constructor() {
super(); super();
// this._shadow = this.attachShadow({ mode: `open`, delegatesFocus: true });
this.#container = document.createElement(`div`); this.#container = document.createElement(`div`);
this._shadow.appendChild(this.#container); this._shadow.appendChild(this.#container);
@ -34,7 +32,7 @@ export class SystemIcon extends StyledShadowElement(HTMLElement) {
_mounted = false; _mounted = false;
async connectedCallback() { async connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (this._mounted) { return } if (this._mounted) { return };
this._name = this.getAttribute(`name`); this._name = this.getAttribute(`name`);
this._path = this.getAttribute(`path`); this._path = this.getAttribute(`path`);
@ -56,7 +54,7 @@ export class SystemIcon extends StyledShadowElement(HTMLElement) {
*/ */
let content; let content;
if (this._name) { if (this._name) {
content = await this.#getIcon(`./systems/dotdungeon/assets/${this._name}.svg`); content = await this.#getIcon(filePath(`assets/${this._name}.svg`));
}; };
if (this._path && !content) { if (this._path && !content) {
@ -67,28 +65,12 @@ export class SystemIcon extends StyledShadowElement(HTMLElement) {
this.#container.appendChild(content.cloneNode(true)); this.#container.appendChild(content.cloneNode(true));
}; };
/*
This is so that when we get an HMR event from Foundry we can appropriately
handle it using our logic to update the component and the icon cache.
*/
if (game.settings.get(game.system.id, `devMode`)) {
this.#svgHmr = Hooks.on(`${game.system.id}-hmr:svg`, (iconName, data) => {
if (this._name === iconName || this._path?.endsWith(data.path)) {
const svg = this.#parseSVG(data.content);
this.constructor._cache.set(iconName, svg);
this.#container.replaceChildren(svg.cloneNode(true));
};
});
};
this._mounted = true; this._mounted = true;
}; };
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (!this._mounted) { return } if (!this._mounted) { return };
Hooks.off(`${game.system.id}-hmr:svg`, this.#svgHmr);
this._mounted = false; this._mounted = false;
}; };
@ -96,7 +78,7 @@ export class SystemIcon extends StyledShadowElement(HTMLElement) {
async #getIcon(path) { async #getIcon(path) {
// Cache hit! // Cache hit!
if (this.constructor._cache.has(path)) { if (this.constructor._cache.has(path)) {
Logger.debug(`Icon ${path} cache hit`); Logger.debug(`Image ${path} cache hit`);
return this.constructor._cache.get(path); return this.constructor._cache.get(path);
}; };
@ -110,7 +92,7 @@ export class SystemIcon extends StyledShadowElement(HTMLElement) {
return; return;
}; };
Logger.debug(`Adding icon ${path} to the cache`); Logger.debug(`Adding image ${path} to the cache`);
const svg = this.#parseSVG(await r.text()); const svg = this.#parseSVG(await r.text());
this.constructor._cache.set(path, svg); this.constructor._cache.set(path, svg);
return svg; return svg;

9
module/consts.mjs Normal file
View file

@ -0,0 +1,9 @@
export const __ID__ = `taf`;
// MARK: filePath
export function filePath(path) {
if (path.startsWith(`/`)) {
path = path.slice(1);
};
return `systems/${__ID__}/${path}`;
};

30
module/data/Player.mjs Normal file
View file

@ -0,0 +1,30 @@
export class PlayerData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
content: new fields.HTMLField({
blank: true,
trim: true,
initial: ``,
}),
attr: new fields.TypedObjectField(
new fields.SchemaField({
name: new fields.StringField({ blank: false, trim: true }),
sort: new fields.NumberField({ min: 1, initial: 1, integer: true, nullable: false }),
value: new fields.NumberField({ min: 0, initial: 0, integer: true, nullable: false }),
max: new fields.NumberField({ min: 0, initial: null, integer: true, nullable: true }),
isRange: new fields.BooleanField({ initial: false, nullable: false }),
}),
{
initial: {},
nullable: false,
required: true,
},
),
};
};
get hasAttributes() {
return Object.keys(this.attr).length > 0;
};
};

View file

@ -0,0 +1,45 @@
const { Actor } = foundry.documents;
export class TAFActor extends Actor {
async modifyTokenAttribute(attribute, value, isDelta = false, isBar = true) {
const attr = foundry.utils.getProperty(this.system, attribute);
const current = isBar ? attr.value : attr;
const update = isDelta ? current + value : value;
if ( update === current ) {
return this;
};
// Determine the updates to make to the actor data
let updates;
if (isBar) {
updates = {[`system.${attribute}.value`]: Math.clamp(update, 0, attr.max)};
} else {
updates = {[`system.${attribute}`]: update};
};
// Allow a hook to override these changes
const allowed = Hooks.call(`modifyTokenAttribute`, {attribute, value, isDelta, isBar}, updates, this);
return allowed !== false ? this.update(updates) : this;
};
getRollData() {
const data = {};
if (`attr` in this.system) {
for (const attrID in this.system.attr) {
const attr = this.system.attr[attrID];
if (attr.isRange) {
data[attrID] = {
value: attr.value,
max: attr.max,
};
} else {
data[attrID] = attr.value;
};
};
};
return data;
};
};

View file

@ -0,0 +1,7 @@
const { Item } = foundry.documents;
export class TAFItem extends Item {
async _preCreate() {
return false;
};
};

109
module/documents/Token.mjs Normal file
View file

@ -0,0 +1,109 @@
const { TokenDocument } = foundry.documents;
const { getProperty, getType, hasProperty, isSubclass } = foundry.utils;
export class TAFTokenDocument extends TokenDocument {
/**
* @override
* This override's purpose is to make it so that Token attributes and bars can
* be accessed from the data model's values directly instead of relying on only
* the schema, which doesn't account for my TypedObjectField of attributes.
*/
static getTrackedAttributes(data, _path = []) {
// Case 1 - Infer attributes from schema structure.
if ( (data instanceof foundry.abstract.DataModel) || isSubclass(data, foundry.abstract.DataModel) ) {
return this._getTrackedAttributesFromObject(data, _path);
}
if ( data instanceof foundry.data.fields.SchemaField ) {
return this._getTrackedAttributesFromSchema(data, _path);
}
// Case 2 - Infer attributes from object structure.
if ( [`Object`, `Array`].includes(getType(data)) ) {
return this._getTrackedAttributesFromObject(data, _path);
}
// Case 3 - Retrieve explicitly configured attributes.
if ( !data || (typeof data === `string`) ) {
const config = this._getConfiguredTrackedAttributes(data);
if ( config ) {
return config;
}
data = undefined;
}
// Track the path and record found attributes
if ( data !== undefined ) {
return {bar: [], value: []};
}
// Case 4 - Infer attributes from system template.
const bar = new Set();
const value = new Set();
for ( const [type, model] of Object.entries(game.model.Actor) ) {
const dataModel = CONFIG.Actor.dataModels?.[type];
const inner = this.getTrackedAttributes(dataModel ?? model, _path);
inner.bar.forEach(attr => bar.add(attr.join(`.`)));
inner.value.forEach(attr => value.add(attr.join(`.`)));
}
return {
bar: Array.from(bar).map(attr => attr.split(`.`)),
value: Array.from(value).map(attr => attr.split(`.`)),
};
};
/**
* @override
*/
getBarAttribute(barName, {alternative} = {}) {
const attribute = alternative || this[barName]?.attribute;
if (!attribute || !this.actor) {
return null;
};
const system = this.actor.system;
// Get the current attribute value
const data = getProperty(system, attribute);
if (data == null) {
return null;
};
if (Number.isNumeric(data)) {
let editable = hasProperty(system, attribute);
return {
type: `value`,
attribute,
value: Number(data),
editable,
};
};
if (`value` in data && `max` in data) {
let editable = hasProperty(system, `${attribute}.value`);
const isRange = getProperty(system, `${attribute}.isRange`);
if (isRange) {
return {
type: `bar`,
attribute,
value: parseInt(data.value || 0),
max: parseInt(data.max || 0),
editable,
};
} else {
return {
type: `value`,
attribute: `${attribute}.value`,
value: Number(data.value),
editable,
};
};
};
// Otherwise null
return null;
};
};

View file

@ -0,0 +1,7 @@
import { filePath } from "../consts.mjs";
import { options } from "./options.mjs";
export default {
systemFilePath: filePath,
"taf-options": options,
};

View file

@ -1,5 +1,3 @@
import { localizer } from "../utils/localizer.mjs";
/** /**
* @typedef {object} Option * @typedef {object} Option
* @property {string} [label] * @property {string} [label]
@ -8,8 +6,9 @@ import { localizer } from "../utils/localizer.mjs";
*/ */
/** /**
* @param {string | number} selected * @param {string | number} selected The selected value
* @param {Array<Option | string>} opts * @param {Array<Option | string>} opts The options that are valid
* @param {any} meta The Handlebars meta processing
*/ */
export function options(selected, opts, meta) { export function options(selected, opts, meta) {
const { localize = false } = meta.hash; const { localize = false } = meta.hash;
@ -17,7 +16,7 @@ export function options(selected, opts, meta) {
const htmlOptions = []; const htmlOptions = [];
for (let opt of opts) { for (let opt of opts) {
if (foundry.utils.getType(opt) === `string`) { if (typeof opt === `string`) {
opt = { label: opt, value: opt }; opt = { label: opt, value: opt };
}; };
opt.value = Handlebars.escapeExpression(opt.value); opt.value = Handlebars.escapeExpression(opt.value);
@ -27,9 +26,9 @@ export function options(selected, opts, meta) {
${selected === opt.value ? `selected` : ``} ${selected === opt.value ? `selected` : ``}
${opt.disabled ? `disabled` : ``} ${opt.disabled ? `disabled` : ``}
> >
${localize ? localizer(opt.label) : opt.label} ${localize ? game.i18n.format(opt.label) : opt.label}
</option>`, </option>`,
); );
}; };
return htmlOptions.join(`\n`); return new Handlebars.SafeString(htmlOptions.join(`\n`));
}; };

48
module/hooks/init.mjs Normal file
View file

@ -0,0 +1,48 @@
// Apps
import { PlayerSheet } from "../apps/PlayerSheet.mjs";
// Data Models
import { PlayerData } from "../data/Player.mjs";
// Documents
import { TAFActor } from "../documents/Actor.mjs";
import { TAFItem } from "../documents/Item.mjs";
import { TAFTokenDocument } from "../documents/Token.mjs";
// Settings
import { registerWorldSettings } from "../settings/world.mjs";
// Utils
import { __ID__ } from "../consts.mjs";
import helpers from "../handlebarsHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../apps/elements/_index.mjs";
import { registerSockets } from "../sockets/_index.mjs";
Hooks.on(`init`, () => {
Logger.debug(`Initializing`);
CONFIG.Token.documentClass = TAFTokenDocument;
CONFIG.Actor.documentClass = TAFActor;
CONFIG.Actor.dataModels.player = PlayerData;
// We disable items in the system for now
CONFIG.Item.documentClass = TAFItem;
delete CONFIG.ui.sidebar.TABS.items;
foundry.documents.collections.Actors.registerSheet(
__ID__,
PlayerSheet,
{
makeDefault: true,
label: `taf.sheet-names.PlayerSheet`,
},
);
registerWorldSettings();
registerSockets();
registerCustomComponents();
Handlebars.registerHelper(helpers);
});

View file

@ -0,0 +1,6 @@
import { userActivity } from "../utils/QueryManager.mjs";
Hooks.on(`userConnected`, (user, connected) => {
if (user.isSelf) { return };
userActivity(user.id, connected);
});

3
module/main.mjs Normal file
View file

@ -0,0 +1,3 @@
import "./api.mjs";
import "./hooks/init.mjs";
import "./hooks/userConnected.mjs";

12
module/settings/world.mjs Normal file
View file

@ -0,0 +1,12 @@
import { __ID__ } from "../consts.mjs";
export function registerWorldSettings() {
game.settings.register(__ID__, `canPlayersManageAttributes`, {
name: `taf.settings.canPlayersManageAttributes.name`,
hint: `taf.settings.canPlayersManageAttributes.hint`,
config: true,
type: Boolean,
default: false,
scope: `world`,
});
};

33
module/sockets/_index.mjs Normal file
View file

@ -0,0 +1,33 @@
import { Logger } from "../utils/Logger.mjs";
import { queryCancel } from "./query/cancel.mjs";
import { queryNotify } from "./query/notify.mjs";
import { queryPrompt } from "./query/prompt.mjs";
import { querySubmit } from "./query/submit.mjs";
const events = {
// Data Request sockets
"query.cancel": queryCancel,
"query.notify": queryNotify,
"query.prompt": queryPrompt,
"query.submit": querySubmit,
};
export function registerSockets() {
Logger.info(`Setting up socket listener`);
game.socket.on(`system.taf`, (data, userID) => {
const { event, payload } = data ?? {};
if (event == null || payload === undefined) {
ui.notifications.error(game.i18n.format(`taf.notifs.error.invalid-socket`));
return;
};
if (events[event] == null) {
ui.notifications.error(game.i18n.format(`taf.notifs.error.unknown-socket-event`, { event }));
return;
};
const user = game.users.get(userID);
events[event](payload, user);
});
};

View file

@ -0,0 +1,19 @@
import { DialogManager } from "../../utils/DialogManager.mjs";
import { localizer } from "../../utils/localizer.mjs";
export async function queryCancel(payload) {
const { id } = payload;
if (!id) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.notifs.error.missing-id`,
},
));
return;
};
await DialogManager.close(id);
};

View file

@ -0,0 +1,25 @@
import { localizer } from "../../utils/localizer.mjs";
import { respondedToQueries } from "../../utils/QueryManager.mjs";
export function queryNotify(payload) {
const { id, userID, content, includeGM } = payload;
if (userID !== game.user.id) { return };
// Ensure that each user can only get one notification about a query
if (!respondedToQueries.has(id)) { return };
let whisper = [game.user.id];
if (includeGM) {
whisper = game.users.filter(u => u.isGM).map(u => u.id);
};
ChatMessage.implementation.create({
flavor: localizer(`taf.misc.data-query-notif-header`),
content,
whisper,
style: CONST.CHAT_MESSAGE_STYLES.OOC,
});
respondedToQueries.delete(id);
};

View file

@ -0,0 +1,54 @@
import { DialogManager } from "../../utils/DialogManager.mjs";
import { localizer } from "../../utils/localizer.mjs";
import { respondedToQueries } from "../../utils/QueryManager.mjs";
export async function queryPrompt(payload) {
const {
id,
users,
config,
request,
} = payload;
if (!id) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.notifs.error.missing-id`,
},
));
return;
};
// null/undefined is a special case for "all users but me" by default
if (users != null && !Array.isArray(users)) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.sockets.user-list-required`,
},
));
return;
};
if (users != null && !users.includes(game.user.id)) { return };
request.id = id;
const result = await DialogManager.ask(request, config);
if (result.state === `fronted`) {
return;
} else if (result.state === `errored`) {
ui.notifications.error(result.error);
} else if (result.state === `prompted`) {
respondedToQueries.add(request.id);
game.socket.emit(`system.taf`, {
event: `query.submit`,
payload: {
id: request.id,
answers: result.answers,
},
});
};
};

View file

@ -0,0 +1,23 @@
import { addResponse, has as hasQuery } from "../../utils/QueryManager.mjs";
import { localizer } from "../../utils/localizer.mjs";
export function querySubmit(payload, user) {
const {
id,
answers,
} = payload;
if (!id) {
ui.notifications.error(localizer(
`taf.notifs.error.malformed-socket-payload`,
{
event: `query.cancel`,
details: `taf.notifs.error.missing-id`,
},
));
return;
};
if (!hasQuery(id)) { return };
addResponse(id, user.id, answers);
};

View file

@ -0,0 +1,114 @@
import { Ask } from "../apps/Ask.mjs";
/** @type {Map<string, Promise>} */
const promises = new Map();
/** @type {Map<string, ApplicationV2>} */
const dialogs = new Map();
export function close(id) {
dialogs.get(id)?.close();
dialogs.delete(id);
promises.delete(id);
};
/**
* Asks the user to provide a simple piece of information, this is primarily
* intended to be used within macros so that it can have better info gathering
* as needed. This returns an object of input keys/labels to the value the user
* input for that label, if there is only one input, this will return the value
* without an object wrapper, allowing for easier access.
*
* @param {AskConfig} data
* @param {AskOptions} opts
* @returns {AskResult}
*/
export async function ask(
data,
{
onlyOneWaiting = true,
alwaysUseAnswerObject = true,
} = {},
) {
if (!data.id) {
return {
state: `errored`,
error: `An ID must be provided`,
};
};
if (!data.inputs.length) {
return {
state: `errored`,
error: `At least one input must be provided`,
};
};
const id = data.id;
// Don't do multi-thread waiting
if (dialogs.has(id)) {
const app = dialogs.get(id);
app.bringToFront();
if (onlyOneWaiting) {
return { state: `fronted` };
} else {
return promises.get(id);
};
};
let autofocusClaimed = false;
for (const i of data.inputs) {
i.id ??= foundry.utils.randomID(16);
i.key ??= i.label;
switch (i.type) {
case `input`: {
i.inputType ??= `text`;
}
}
// Only ever allow one input to claim autofocus
i.autofocus &&= !autofocusClaimed;
autofocusClaimed ||= i.autofocus;
// Set the value's attribute name if it isn't specified explicitly
if (!i.valueAttribute) {
switch (i.inputType) {
case `checkbox`:
i.type = `checkbox`;
delete i.valueAttribute;
delete i.inputType;
break;
default:
i.valueAttribute = `value`;
};
};
};
const promise = new Promise((resolve) => {
const app = new Ask({
...data,
alwaysUseAnswerObject,
onClose: () => {
dialogs.delete(id);
promises.delete(id);
resolve({ state: `prompted` });
},
onConfirm: (answers) => resolve({ state: `prompted`, answers }),
});
app.render({ force: true });
dialogs.set(id, app);
});
promises.set(id, promise);
return promise;
};
export function size() {
return dialogs.size;
};
export const DialogManager = {
close,
ask,
size,
};

View file

@ -12,10 +12,10 @@ const augmentedProps = new Set([
]); ]);
/** @type {Console} */ /** @type {Console} */
globalThis.Logger = new Proxy(console, { export const Logger = new Proxy(console, {
get(target, prop, _receiver) { get(target, prop, _receiver) {
if (augmentedProps.has(prop)) { if (augmentedProps.has(prop)) {
return (...args) => target[prop](game.system.id, `|`, ...args); return target[prop].bind(target, game.system.id, `|`);
}; };
return target[prop]; return target[prop];
}, },

View file

@ -0,0 +1,289 @@
import { filePath } from "../consts.mjs";
import { Logger } from "./Logger.mjs";
import { QueryStatus } from "../apps/QueryStatus.mjs";
/**
* An object containing information about the current status for all
* users involved with the data request.
* @typedef {Record<
* string,
* "finished" | "waiting" | "disconnected" | "unprompted"
* >} UserStatus
*/
/**
* @typedef QueryData
* @property {string[]} users
* @property {Function} resolve
* @property {Record<string, object>} responses
* @property {(() => Promise<void>)|null} onSubmit
* @property {QueryStatus|null} app
* @property {UserStatus} status
* @property {object} request The data used to form the initial request
* @property {object} config The data used to create the initial config
*/
/**
* This internal API is used in order to prevent the query.notify event
* from being fired off in situations where the user hasn't responded,
* wasn't part of the query, or has already been notified.
* @type {Set<string>}
*/
export const respondedToQueries = new Set();
/** @type {Map<string, QueryData>} */
const queries = new Map();
/** @type {Map<string, Promise>} */
const promises = new Map();
async function sendBasicNotification(requestID, userID, answers) {
const content = await foundry.applications.handlebars.renderTemplate(
filePath(`templates/query-response.hbs`),
{ answers },
);
await notify(requestID, userID, content, { includeGM: false });
};
export function has(requestID) {
return queries.has(requestID);
};
/** @returns {Omit<QueryData, "resolve"|"onSubmit"|"app">} */
export function get(requestID) {
if (!queries.has(requestID)) { return null };
const query = queries.get(requestID);
const cloned = foundry.utils.deepClone(query);
delete cloned.onSubmit;
delete cloned.resolve;
delete cloned.app;
return foundry.utils.deepFreeze(cloned);
};
export async function query(
request,
{
onSubmit = sendBasicNotification,
users = null,
showStatusApp = true,
...config
} = {},
) {
if (!request.id) {
ui.notifications.error(game.i18n.localize(`taf.notifs.error.missing-id`));
return;
};
game.socket.emit(`system.taf`, {
event: `query.prompt`,
payload: {
id: request.id,
users,
request,
config,
},
});
if (promises.has(request.id)) {
return null;
};
users ??= game.users
.filter(u => u.id !== game.user.id)
.map(u => u.id);
const promise = new Promise((resolve) => {
/** @type {UserStatus} */
const status = {};
for (const user of users) {
status[user] = game.users.get(user).active ? `waiting` : `disconnected`;
};
queries.set(
request.id,
{
users,
request,
config,
responses: {},
resolve,
onSubmit,
app: null,
status,
},
);
});
if (showStatusApp) {
const app = new QueryStatus({ requestID: request.id });
app.render({ force: true });
queries.get(request.id).app = app;
};
return promise;
};
export async function requery(requestID, users) {
const query = queries.get(requestID);
if (!query) { return };
game.socket.emit(`system.taf`, {
event: `query.prompt`,
payload: {
id: requestID,
users,
request: query.request,
config: query.config,
},
});
for (const user of users) {
query.status[user] = `waiting`;
};
query.app?.render({ parts: [ `users` ] });
};
export async function addResponse(requestID, userID, answers) {
if (!queries.has(requestID)) { return };
const query = queries.get(requestID);
// User closed the popup manually
if (answers == null) {
query.status[userID] = `unprompted`;
}
// User submitted the answers as expected
else {
query.responses[userID] = answers;
query.status[userID] = `finished`;
await query.onSubmit?.(requestID, userID, answers);
};
await maybeResolve(requestID);
};
async function maybeResolve(requestID) {
const query = queries.get(requestID);
// Determine how many users are considered "finished"
let finishedUserCount = 0;
for (const user of query.users) {
const hasApp = query.app != null;
switch (query.status[user]) {
case `finished`: {
finishedUserCount++;
break;
};
case `cancelled`:
case `disconnected`:
case `unprompted`: {
if (!hasApp) {
finishedUserCount++;
};
break;
};
};
};
// Ensure that we have a finished response from everyone prompted
if (query.users.length === finishedUserCount) {
query.app?.close();
query.resolve(query.responses);
queries.delete(requestID);
promises.delete(requestID);
} else {
query.app?.render({ parts: [ `users` ] });
};
};
export async function notify(requestID, userID, content, { includeGM = false } = {}) {
// Prevent sending notifications for not-your queries
if (!queries.has(requestID)) { return };
game.socket.emit(`system.taf`, {
event: `query.notify`,
payload: {
id: requestID,
userID,
content,
includeGM,
},
});
};
export async function finish(requestID) {
// prevent finishing other people's queries
if (!queries.has(requestID)) { return };
const query = queries.get(requestID);
query.app?.close();
query.resolve(query.responses);
queries.delete(requestID);
promises.delete(requestID);
game.socket.emit(`system.taf`, {
event: `query.cancel`,
payload: { id: requestID },
});
};
export async function cancel(requestID) {
// prevent cancelling other people's queries
if (!queries.has(requestID)) { return };
const query = queries.get(requestID);
query.app?.close();
query.resolve(null);
queries.delete(requestID);
promises.delete(requestID);
game.socket.emit(`system.taf`, {
event: `query.cancel`,
payload: { id: requestID },
});
};
export async function setApplication(requestID, app) {
if (!queries.has(requestID)) { return };
if (!(app instanceof QueryStatus)) { return };
const query = queries.get(requestID);
if (query.app) {
Logger.error(`Cannot set an application for a query that has one already`);
return;
};
query.app = app;
};
export async function userActivity(userID, connected) {
for (const [id, query] of queries.entries()) {
if (query.users.includes(userID)) {
// Update the user's status to allow for the app to re-prompt them
if (query.status[userID] !== `finished`) {
if (connected) {
query.status[userID] = `unprompted`;
} else {
query.status[userID] = `disconnected`;
};
maybeResolve(id);
};
query.app?.render({ parts: [ `users` ] });
};
};
};
export const QueryManager = {
has, get,
query, requery,
addResponse,
notify,
finish, cancel,
setApplication,
userActivity,
};

View file

@ -0,0 +1,6 @@
export function attributeSorter(a, b) {
if (a.sort === b.sort) {
return a.name.localeCompare(b.name);
};
return Math.sign(a.sort - b.sort);
};

View file

@ -0,0 +1,32 @@
import { PlayerSheet } from "../apps/PlayerSheet.mjs";
/**
* @typedef SheetSizing
* @property {number} width The initial width of the application
* @property {number} height The initial height of the application
* @property {boolean} resizable Whether or not the application
* is able to be resized with a drag handle.
*/
/**
* Retrieves the computed default sizing data based on world settings
* and the sheet class' DEFAULT_OPTIONS
* @returns {SheetSizing}
*/
export function getDefaultSizing() {
/** @type {SheetSizing} */
const sizing = {
width: undefined,
height: undefined,
resizable: undefined,
};
// TODO: defaults from world settings
// Defaults from the sheet class itself
sizing.height ||= PlayerSheet.DEFAULT_OPTIONS.position.height;
sizing.width ||= PlayerSheet.DEFAULT_OPTIONS.position.width;
sizing.resizable ||= PlayerSheet.DEFAULT_OPTIONS.window.resizable;
return sizing;
};

View file

@ -0,0 +1,32 @@
const config = Object.preventExtensions({
subKeyPattern: /@(?<key>[a-zA-Z.]+)/gm,
maxDepth: 10,
});
export function localizer(key, args = {}, depth = 0) {
/** @type {string} */
let localized = game.i18n.format(key, args);
const subkeys = localized.matchAll(config.subKeyPattern);
// Short-cut to help prevent infinite recursion
if (depth > config.maxDepth) {
return localized;
};
/*
Helps prevent recursion on the same key so that we aren't doing excess work.
*/
const localizedSubkeys = new Map();
for (const match of subkeys) {
const subkey = match.groups.key;
if (localizedSubkeys.has(subkey)) { continue };
localizedSubkeys.set(subkey, localizer(subkey, args, depth + 1));
};
return localized.replace(
config.subKeyPattern,
(_fullMatch, subkey) => {
return localizedSubkeys.get(subkey);
},
);
};

13
module/utils/toID.mjs Normal file
View file

@ -0,0 +1,13 @@
/**
* A helper method that converts an arbitrary string into a format that can be
* used as an object key easily.
*
* @param {string} text The text to convert
* @returns The converted ID
*/
export function toID(text) {
return text
.toLowerCase()
.replace(/\s+/g, `_`)
.replace(/\W/g, ``);
};

5988
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,18 @@
{ {
"devDependencies": { "devDependencies": {
"@aws-sdk/client-s3": "^3.934.0",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@foundryvtt/foundryvtt-cli": "^1.0.3", "@foundryvtt/foundryvtt-cli": "^1.0.3",
"@league-of-foundry-developers/foundry-vtt-types": "^9.280.0",
"@stylistic/eslint-plugin": "^2.6.1", "@stylistic/eslint-plugin": "^2.6.1",
"axios": "^1.13.2",
"dotenv": "^17.2.2",
"eslint": "^9.8.0", "eslint": "^9.8.0",
"globals": "^15.9.0", "globals": "^15.9.0"
"sass": "^1.77.8"
}, },
"scripts": { "scripts": {
"css": "sass --watch --embed-source-map --no-error-css styles/:.styles/", "data:build": "node scripts/buildCompendia.mjs",
"build": "sass --embed-source-map --no-error-css styles/:.styles/", "data:extract": "node scripts/extractCompendia.mjs",
"link": "node scripts/linkFoundry.mjs",
"lint": "eslint --fix", "lint": "eslint --fix",
"lint:nofix": "eslint" "lint:nofix": "eslint"
} }

View file

@ -0,0 +1,52 @@
import axios from "axios";
const {
TAG,
FORGEJO_API_URL: API,
FORGEJO_REPOSITORY: REPO,
FORGEJO_TOKEN: TOKEN,
CDN_URL,
} = process.env;
async function addReleaseAsset(releaseID, name) {
return axios.post(
`${API}/repos/${REPO}/releases/${releaseID}/assets`,
{ external_url: `${CDN_URL}/${REPO}/${TAG}/${name}`, },
{
headers: {
Authorization: `token ${TOKEN}`,
"Content-Type": `multipart/form-data`,
},
params: { name },
}
);
};
async function main() {
// Initial Release Data
const release = await axios.post(
`${API}/repos/${REPO}/releases`,
{
name: TAG,
tag_name: TAG,
draft: true,
hide_archive_links: true,
},
{
headers: { Authorization: `token ${TOKEN}` },
}
);
try {
await addReleaseAsset(release.data.id, `release.zip`);
await addReleaseAsset(release.data.id, `system.json`);
} catch (e) {
console.error(`Failed to add assets to the release`);
process.exit(1);
};
console.log(`Release created`);
};
main();

47
scripts/linkFoundry.mjs Normal file
View file

@ -0,0 +1,47 @@
import { existsSync } from "fs";
import { symlink, unlink } from "fs/promises";
import { join } from "path";
import { config } from "dotenv";
config({ quiet: true });
const root = process.env.FOUNDRY_ROOT;
// Early exit
if (!root) {
console.error(`Must provide a FOUNDRY_ROOT environment variable`);
process.exit(1);
};
// Assert Foundry exists
if (!existsSync(root)) {
console.error(`Foundry root not found.`);
process.exit(1);
};
// Removing existing symlink
if (existsSync(`foundry`)) {
console.log(`Attempting to unlink foundry instance`);
try {
await unlink(`foundry`);
} catch {
console.error(`Failed to unlink foundry folder.`);
process.exit(1);
};
};
// Account for if the root is pointing at an Electron install
let targetRoot = root;
if (existsSync(join(root, `resources`, `app`))) {
console.log(`Switching to use the "${root}/resources/app" directory`);
targetRoot = join(root, `resources`, `app`);
};
// Create symlink
console.log(`Linking foundry source into folder`)
try {
await symlink(targetRoot, `foundry`);
} catch (e) {
console.error(e);
process.exit(1);
};

View file

@ -1,77 +0,0 @@
async function rollDice() {
const sidesOnDice = 6;
const answers = await DialogManager.ask({
id: `eat-the-reich-dice-pool`,
question: `Set up your dice pool:`,
inputs: [
{
key: `statBase`,
inputType: `number`,
defaultValue: 2,
label: `Number of Dice`,
autofocus: true,
},
{
key: `successThreshold`,
inputType: `number`,
defaultValue: 3,
label: `Success Threshold (d${sidesOnDice} > X)`,
},
{
key: `critsEnabled`,
inputType: `checkbox`,
defaultValue: true,
label: `Enable Criticals`,
},
],
});
const { statBase, successThreshold, critsEnabled } = answers;
let rollMode = game.settings.get(`core`, `rollMode`);
let successes = 0;
let critsOnly = 0;
const results = [];
for (let i = statBase; i > 0; i--) {
let r = new Roll(`1d${sidesOnDice}`);
await r.evaluate();
let classes = `roll die d6`;
// Determine the success count and class modifications for the chat
if (r.total > successThreshold) {
successes++;
}
else {
classes += ` failure`
}
if (r.total === sidesOnDice && critsEnabled) {
successes++;
critsOnly++;
classes += ` success`;
}
results.push(`<li class="${classes}">${r.total}</li>`);
}
let content = `Rolls:<div class="dice-tooltip"><ol class="dice-rolls">${results.join(``)}</ol></div><hr>Successes: ${successes}<br>Crits: ${critsOnly}`;
if (rollMode === CONST.DICE_ROLL_MODES.BLIND) {
ui.notifications.warn(`Cannot make a blind roll from the macro, rolling with mode "Private GM Roll" instead`);
rollMode = CONST.DICE_ROLL_MODES.PRIVATE;
}
const chatData = ChatMessage.applyRollMode(
{
title: `Dice Pool`,
content,
},
rollMode,
);
await ChatMessage.implementation.create(chatData);
}
rollDice()

View file

@ -0,0 +1,45 @@
/*
The intent of this script is to do all of the modifications of the
manifest file that we need to do in order to release the system.
This can include removing dev-only fields/attributes that end
users will never, and should never, care about nor need.
*/
import { readFile, writeFile } from "fs/promises";
const MANIFEST_PATH = `system.json`;
const {
DOWNLOAD_URL,
LATEST_URL,
} = process.env;
let manifest;
try {
manifest = JSON.parse(await readFile(MANIFEST_PATH, `utf-8`));
console.log(`Manifest loaded from disk`);
} catch {
console.error(`Failed to parse manifest file.`);
process.exit(1);
};
console.log(`Updating download/manifest URLs`)
manifest.download = DOWNLOAD_URL;
manifest.manifest = LATEST_URL;
// Filter out dev-only resources
if (manifest.esmodules) {
console.log(`Removing dev-only esmodules`);
manifest.esmodules = manifest.esmodules.filter(
filepath => !filepath.startsWith(`dev/`)
);
};
// Remove dev flags
console.log(`Cleaning up flags`);
delete manifest.flags?.hotReload;
if (Object.keys(manifest.flags).length === 0) {
delete manifest.flags;
};
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, undefined, `\t`));
console.log(`Manifest written back to disk`);

38
scripts/tagExists.mjs Normal file
View file

@ -0,0 +1,38 @@
import axios from "axios";
const {
TAG_NAME,
FORGEJO_API_URL: API_URL,
FORGEJO_REPOSITORY: REPO,
FORGEJO_TOKEN: TOKEN,
} = process.env;
async function main() {
if (!TAG_NAME) {
console.log(`Tag name must not be blank`);
process.exit(1);
};
const requestURL = `${API_URL}/repos/${REPO}/tags/${TAG_NAME}`;
const response = await axios.get(
requestURL,
{
headers: { Authorization: `token ${TOKEN}` },
validateStatus: () => true,
},
);
// We actually *want* an error when the tag exists, instead of when
// it doesn't
if (response.status === 200) {
console.log(`Tag with name "${TAG_NAME}" already exists`);
process.exit(1);
};
console.log(`Tag with name "${TAG_NAME}" not found, proceeding`);
};
main();

65
scripts/uploadToS3.mjs Normal file
View file

@ -0,0 +1,65 @@
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { createReadStream } from "fs";
const requiredEnvVariables = [
`TAG`, `FILE`,
`FORGEJO_REPOSITORY`,
`S3_BUCKET`, `S3_REGION`, `S3_KEY`, `S3_SECRET`, `S3_ENDPOINT`,
];
async function main() {
// Assert all of the required env variables are present
const missing = [];
for (const envVar of requiredEnvVariables) {
if (!(envVar in process.env)) {
missing.push(envVar);
};
};
if (missing.length > 0) {
console.error(`Missing the following required environment variables: ${missing.join(`, `)}`);
process.exit(1);
};
const {
TAG,
S3_ENDPOINT,
S3_REGION,
S3_KEY,
S3_SECRET,
S3_BUCKET,
FILE,
FORGEJO_REPOSITORY: REPO,
} = process.env;
const s3Client = new S3Client({
endpoint: S3_ENDPOINT,
forcePathStyle: false,
region: S3_REGION,
credentials: {
accessKeyId: S3_KEY,
secretAccessKey: S3_SECRET
},
});
const name = FILE.split(`/`).at(-1);
const params = {
Bucket: S3_BUCKET,
Key: `${REPO}/${TAG}/${name}`,
Body: createReadStream(FILE),
ACL: "public-read",
METADATA: {
"x-repo-version": TAG,
},
};
try {
const response = await s3Client.send(new PutObjectCommand(params));
console.log("Upload successful");
} catch (err) {
console.error("Upload to s3 failed");
};
};
main();

View file

@ -1,32 +0,0 @@
import { SystemIcon } from "./icon.mjs";
import { SystemIncrementer } from "./incrementer.mjs";
import { SystemRange } from "./range.mjs";
/**
* A list of element classes to register, expects all of them to have a static
* property of "elementName" that is the namespaced name that the component will
* be registered under. Any elements that are formAssociated have their name added
* to the "CONFIG.CACHE.componentListeners" array and should be listened to for
* "change" events in sheets.
*/
const components = [
SystemIcon,
SystemIncrementer,
SystemRange,
];
export function registerCustomComponents() {
(CONFIG.CACHE ??= {}).componentListeners ??= [];
for (const component of components) {
if (!window.customElements.get(component.elementName)) {
console.debug(`${game.system.id} | Registering component "${component.elementName}"`);
window.customElements.define(
component.elementName,
component,
);
if (component.formAssociated) {
CONFIG.CACHE.componentListeners.push(component.elementName);
}
};
};
};

View file

@ -1,153 +0,0 @@
import { StyledShadowElement } from "./mixins/Styles.mjs";
import { SystemIcon } from "./icon.mjs";
/**
Attributes:
@property {string} name - The path to the value to update
@property {number} value - The actual value of the input
@property {number} min - The minimum value of the input
@property {number} max - The maximum value of the input
@property {number?} smallStep - The step size used for the buttons and arrow keys
@property {number?} largeStep - The step size used for the buttons + Ctrl and page up / down
Styling:
- `--height`: Controls the height of the element + the width of the buttons (default: 1.25rem)
- `--width`: Controls the width of the number input (default 50px)
*/
export class SystemIncrementer extends StyledShadowElement(HTMLElement) {
static elementName = `dd-incrementer`;
static formAssociated = true;
static _stylePath = `v1/components/incrementer.scss`;
_internals;
#input;
_min;
_max;
_smallStep;
_largeStep;
constructor() {
super();
// Form internals
this._internals = this.attachInternals();
this._internals.role = `spinbutton`;
};
get form() {
return this._internals.form;
}
get name() {
return this.getAttribute(`name`);
}
set name(value) {
this.setAttribute(`name`, value);
}
get value() {
return this.getAttribute(`value`);
};
set value(value) {
this.setAttribute(`value`, value);
};
get type() {
return `number`;
}
connectedCallback() {
super.connectedCallback();
this.replaceChildren();
// Attribute parsing / registration
const value = this.getAttribute(`value`);
this._min = parseInt(this.getAttribute(`min`) ?? 0);
this._max = parseInt(this.getAttribute(`max`) ?? 0);
this._smallStep = parseInt(this.getAttribute(`smallStep`) ?? 1);
this._largeStep = parseInt(this.getAttribute(`largeStep`) ?? 5);
this._internals.ariaValueMin = this._min;
this._internals.ariaValueMax = this._max;
const container = document.createElement(`div`);
// The input that the user can see / modify
const input = document.createElement(`input`);
this.#input = input;
input.type = `number`;
input.ariaHidden = true;
input.min = this.getAttribute(`min`);
input.max = this.getAttribute(`max`);
input.addEventListener(`change`, this.#updateValue.bind(this));
input.value = value;
// plus button
const increment = document.createElement(SystemIcon.elementName);
increment.setAttribute(`name`, `ui/plus`);
increment.setAttribute(`var:size`, `0.75rem`);
increment.setAttribute(`var:fill`, `currentColor`);
increment.ariaHidden = true;
increment.classList.value = `increment`;
increment.addEventListener(`mousedown`, this.#increment.bind(this));
// minus button
const decrement = document.createElement(SystemIcon.elementName);
decrement.setAttribute(`name`, `ui/minus`);
decrement.setAttribute(`var:size`, `0.75rem`);
decrement.setAttribute(`var:fill`, `currentColor`);
decrement.ariaHidden = true;
decrement.classList.value = `decrement`;
decrement.addEventListener(`mousedown`, this.#decrement.bind(this));
// Construct the DOM
container.appendChild(decrement);
container.appendChild(input);
container.appendChild(increment);
this._shadow.appendChild(container);
/*
This converts all of the namespace prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
};
#updateValue() {
let value = parseInt(this.#input.value);
if (this.getAttribute(`min`)) {
value = Math.max(this._min, value);
}
if (this.getAttribute(`max`)) {
value = Math.min(this._max, value);
}
this.#input.value = value;
this.value = value;
this.dispatchEvent(new Event(`change`, { bubbles: true }));
};
/** @param {Event} $e */
#increment($e) {
$e.preventDefault();
let value = parseInt(this.#input.value);
value += $e.ctrlKey ? this._largeStep : this._smallStep;
this.#input.value = value;
this.#updateValue();
};
/** @param {Event} $e */
#decrement($e) {
$e.preventDefault();
let value = parseInt(this.#input.value);
value -= $e.ctrlKey ? this._largeStep : this._smallStep;
this.#input.value = value;
this.#updateValue();
};
};

View file

@ -1,138 +0,0 @@
import { StyledShadowElement } from "./mixins/Styles.mjs";
/**
Attributes:
@property {string} name - The path to the value to update in the datamodel
@property {number} value - The actual value of the input
@property {number} max - The maximum value that this range has
@extends {HTMLElement}
*/
export class SystemRange
extends StyledShadowElement(
HTMLElement,
{ mode: `open`, delegatesFocus: true },
) {
static elementName = `dd-range`;
static formAssociated = true;
static observedAttributes = [`max`];
static _stylePath = `v3/components/range.css`;
_internals;
#input;
constructor() {
super();
// Form internals
this._internals = this.attachInternals();
this._internals.role = `spinbutton`;
};
get form() {
return this._internals.form;
};
get name() {
return this.getAttribute(`name`);
};
set name(value) {
this.setAttribute(`name`, value);
};
get value() {
try {
return parseInt(this.getAttribute(`value`));
} catch {
throw new Error(`Failed to parse attribute: "value" - Make sure it's an integer`);
};
};
set value(value) {
this.setAttribute(`value`, value);
};
get max() {
try {
return parseInt(this.getAttribute(`max`));
} catch {
throw new Error(`Failed to parse attribute: "max" - Make sure it's an integer`);
};
};
set max(value) {
this.setAttribute(`max`, value);
};
get type() {
return `number`;
};
connectedCallback() {
super.connectedCallback();
// Attribute validation
if (!this.hasAttribute(`max`)) {
throw new Error(`dotdungeon | Cannot have a range without a maximum value`);
};
// Keyboard accessible input for the thing
this.#input = document.createElement(`input`);
this.#input.type = `number`;
this.#input.min = 0;
this.#input.max = this.max;
this.#input.value = this.value;
this.#input.addEventListener(`change`, () => {
const inputValue = parseInt(this.#input.value);
if (inputValue === this.value) { return };
this._updateValue.bind(this)(Math.sign(this.value - inputValue));
this._updateValue(Math.sign(this.value - inputValue));
});
this._shadow.appendChild(this.#input);
// Shadow-DOM construction
this._elements = new Array(this.max);
const container = document.createElement(`div`);
container.classList.add(`container`);
// Creating the node for filled content
const filledContainer = document.createElement(`div`);
filledContainer.classList.add(`range-increment`, `filled`);
const filledNode = this.querySelector(`[slot="filled"]`);
if (filledNode) { filledContainer.appendChild(filledNode) };
const emptyContainer = document.createElement(`div`);
emptyContainer.classList.add(`range-increment`, `empty`);
const emptyNode = this.querySelector(`[slot="empty"]`);
if (emptyNode) { emptyContainer.appendChild(emptyNode) };
this._elements.fill(filledContainer, 0, this.value);
this._elements.fill(emptyContainer, this.value);
container.append(...this._elements.map((slot, i) => {
const node = slot.cloneNode(true);
node.setAttribute(`data-index`, i + 1);
node.addEventListener(`click`, () => {
const filled = node.classList.contains(`filled`);
this._updateValue(filled ? -1 : 1);
});
return node;
}));
this._shadow.appendChild(container);
/*
This converts all of the namespace prefixed properties on the element to
CSS variables so that they don't all need to be provided by doing style=""
*/
for (const attrVar of this.attributes) {
if (attrVar.name?.startsWith(`var:`)) {
const prop = attrVar.name.replace(`var:`, ``);
this.style.setProperty(`--` + prop, attrVar.value);
};
};
};
_updateValue(delta) {
this.value += delta;
this.dispatchEvent(new Event(`change`, { bubbles: true }));
};
};

View file

@ -1,3 +0,0 @@
export const FEATURE_FLAGS = Object.freeze({
ROLLMODECONTENT: `Roll Mode Message Content`,
});

View file

@ -1,11 +0,0 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = ActiveEffect;
export const ActiveEffectProxy = createDocumentProxy(defaultClass, classes);

View file

@ -1,5 +0,0 @@
export class Player extends Actor {
getRollData() {
return this.system;
};
};

View file

@ -1,12 +0,0 @@
export class PlayerData extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
content: new fields.HTMLField({
blank: true,
trim: true,
initial: ``,
}),
};
};
};

View file

@ -1,11 +0,0 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = Actor;
export const ActorProxy = createDocumentProxy(defaultClass, classes);

View file

@ -1,11 +0,0 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = ChatMessage;
export const ChatMessageProxy = createDocumentProxy(defaultClass, classes);

View file

@ -1,11 +0,0 @@
import { createDocumentProxy } from "../../utils/createDocumentProxy.mjs";
/**
* An object of Foundry-types to in-code Document classes.
*/
const classes = {};
/** The class that will be used if no type-specific class is defined */
const defaultClass = Item;
export const ItemProxy = createDocumentProxy(defaultClass, classes);

View file

@ -1,18 +0,0 @@
import { handlebarsLocalizer, localizer } from "../utils/localizer.mjs";
import { options } from "./options.mjs";
export function registerHandlebarsHelpers() {
const helperPrefix = game.system.id;
return {
// MARK: Complex helpers
[`${helperPrefix}-i18n`]: handlebarsLocalizer,
[`${helperPrefix}-options`]: options,
// MARK: Simple helpers
[`${helperPrefix}-stringify`]: v => JSON.stringify(v, null, ` `),
[`${helperPrefix}-empty`]: v => v.length == 0,
[`${helperPrefix}-set-has`]: (s, k) => s.has(k),
[`${helperPrefix}-empty-state`]: (v) => v ?? localizer(`${game.system.id}.common.empty`),
};
};

View file

@ -1,18 +0,0 @@
const loaders = {
svg(data) {
const iconName = data.path.split(`/`).slice(-1)[0].slice(0, -4);
Logger.debug(`hot-reloading icon: ${iconName}`);
Hooks.call(`${game.system.id}-hmr:svg`, iconName, data);
},
js() {window.location.reload()},
mjs() {window.location.reload()},
css(data) {
Logger.debug(`Hot-reloading CSS: ${data.path}`);
Hooks.call(`${game.system.id}-hmr:css`, data);
},
};
Hooks.on(`hotReload`, async (data) => {
if (!loaders[data.extension]) {return}
return loaders[data.extension](data);
});

View file

@ -1,21 +0,0 @@
import { FEATURE_FLAGS } from "../consts.mjs";
Hooks.on(`renderChatMessage`, (msg, html) => {
// Short-Circuit when the flag isn't set for the message
if (msg.getFlag(`taf`, `rollModedContent`)) {
return;
}
const featureFlags = game.settings.get(game.system.id, `flags`);
const featureFlagEnabled = featureFlags.includes(FEATURE_FLAGS.ROLLMODECONTENT);
const contentElement = html.find(`.message-content`)[0];
let content = contentElement.innerHTML;
if (featureFlagEnabled && msg.blind && !game.user.isGM) {
content = content.replace(/-=.*?=-/gm, `???`);
} else {
content = content.replace(/-=|=-/gm, ``);
}
contentElement.innerHTML = content;
});

View file

@ -1,59 +0,0 @@
// Document Imports
import { ActiveEffectProxy } from "./documents/ActiveEffect/_proxy.mjs";
import { ActorProxy } from "./documents/Actor/_proxy.mjs";
import { ChatMessageProxy } from "./documents/ChatMessage/_proxy.mjs";
import { ItemProxy } from "./documents/Item/_proxy.mjs";
// DataModel Imports
import { PlayerData } from "./documents/Actor/Player/Model.mjs";
// Hook Imports
import "./hooks/renderChatMessage.mjs";
import "./hooks/hotReload.mjs";
// Misc Imports
import "./utils/globalTaf.mjs";
import "./utils/logger.mjs";
import "./utils/DialogManager.mjs";
import { registerCustomComponents } from "./components/_index.mjs";
import { registerHandlebarsHelpers } from "./helpers/_index.mjs";
import { registerSettings } from "./settings/_index.mjs";
import { registerSheets } from "./sheets/_index.mjs";
// MARK: init hook
Hooks.once(`init`, () => {
Logger.info(`Initializing`);
CONFIG.ActiveEffect.legacyTransferral = false;
registerSettings();
// Data Models
CONFIG.Actor.dataModels.player = PlayerData;
// Update document classes
CONFIG.Actor.documentClass = ActorProxy;
CONFIG.Item.documentClass = ItemProxy;
CONFIG.ActiveEffect.documentClass = ActiveEffectProxy;
CONFIG.ChatMessage.documentClass = ChatMessageProxy;
registerSheets();
registerHandlebarsHelpers();
registerCustomComponents();
});
// MARK: ready hook
Hooks.once( `ready`, () => {
Logger.info(`Ready`);
let defaultTab = game.settings.get(game.system.id, `defaultTab`);
if (defaultTab) {
if (!ui.sidebar?.tabs?.[defaultTab]) {
Logger.error(`Couldn't find a sidebar tab with ID:`, defaultTab);
} else {
Logger.debug(`Switching sidebar tab to:`, defaultTab);
ui.sidebar.tabs[defaultTab].activate();
};
};
});

View file

@ -1,10 +0,0 @@
import { registerClientSettings } from "./client_settings.mjs";
import { registerDevSettings } from "./dev_settings.mjs";
import { registerWorldSettings } from "./world_settings.mjs";
export function registerSettings() {
Logger.debug(`Registering settings`);
registerClientSettings();
registerWorldSettings();
registerDevSettings();
};

View file

@ -1,2 +0,0 @@
export function registerClientSettings() {
};

View file

@ -1,16 +0,0 @@
export function registerDevSettings() {
game.settings.register(game.system.id, `devMode`, {
scope: `client`,
type: Boolean,
config: false,
default: false,
requiresReload: true,
});
game.settings.register(game.system.id, `defaultTab`, {
scope: `client`,
type: String,
config: false,
requiresReload: false,
});
};

View file

@ -1,24 +0,0 @@
import { FEATURE_FLAGS } from "../consts.mjs";
export function registerWorldSettings() {
game.settings.register(game.system.id, `flags`, {
name: `Feature Flags`,
hint: `World-based feature flags that are used to enable/disable specific behaviours`,
scope: `world`,
type: new foundry.data.fields.SetField(
new foundry.data.fields.StringField(
{
empty: false,
trim: true,
options: Object.values(FEATURE_FLAGS),
},
),
{
required: false,
initial: new Set(),
},
),
config: true,
requiresReload: true,
});
};

View file

@ -1,26 +0,0 @@
export class PlayerSheetv1 extends ActorSheet {
static get defaultOptions() {
let opts = foundry.utils.mergeObject(
super.defaultOptions,
{
template: `systems/${game.system.id}/templates/Player/v1/main.hbs`,
classes: [],
},
);
opts.classes = [`actor--player`, `style-v1`];
return opts;
};
async getData() {
const ctx = {};
ctx.editable = this.isEditable;
const actor = ctx.actor = this.actor;
ctx.system = actor.system;
ctx.enriched = { system: {} };
ctx.enriched.system.content = await TextEditor.enrichHTML(actor.system.content);
return ctx;
};
}

View file

@ -1,11 +0,0 @@
import { PlayerSheetv1 } from "./Player/v1.mjs";
export function registerSheets() {
Logger.debug(`Registering sheets`);
Actors.registerSheet(game.system.id, PlayerSheetv1, {
makeDefault: true,
types: [`player`],
label: `Hello`,
});
};

View file

@ -1,178 +0,0 @@
import { localizer } from "./localizer.mjs";
/**
* A utility class that allows managing Dialogs that are created for various
* purposes such as deleting items, help popups, etc. This is a singleton class
* that upon instantiating after the first time will just return the first instance
*/
export class DialogManager {
/** @type {Map<string, Dialog>} */
static #dialogs = new Map();
/**
* Focuses a dialog if it already exists, or creates a new one and renders it.
*
* @param {string} dialogId The ID to associate with the dialog, should be unique
* @param {object} data The data to pass to the Dialog constructor
* @param {DialogOptions} opts The options to pass to the Dialog constructor
* @returns {Dialog} The Dialog instance
*/
static async createOrFocus(dialogId, data, opts = {}) {
if (DialogManager.#dialogs.has(dialogId)) {
const dialog = DialogManager.#dialogs.get(dialogId);
dialog.bringToTop();
return dialog;
};
/*
This makes sure that if I provide a close function as a part of the data,
that the dialog still gets removed from the set once it's closed, otherwise
it could lead to dangling references that I don't care to keep. Or if I don't
provide the close function, it just sets the function as there isn't anything
extra that's needed to be called.
*/
if (data?.close) {
const provided = data.close;
data.close = () => {
DialogManager.#dialogs.delete(dialogId);
provided();
};
} else {
data.close = () => DialogManager.#dialogs.delete(dialogId);
};
// Create the Dialog with the modified data
const dialog = new Dialog(data, opts);
DialogManager.#dialogs.set(dialogId, dialog);
dialog.render(true);
return dialog;
};
/**
* Closes a dialog if it is rendered
*
* @param {string} dialogId The ID of the dialog to close
*/
static async close(dialogId) {
const dialog = DialogManager.#dialogs.get(dialogId);
dialog?.close();
};
static async helpDialog(
helpId,
helpContent,
helpTitle = `dotdungeon.common.help`,
localizationData = {},
) {
DialogManager.createOrFocus(
helpId,
{
title: localizer(helpTitle, localizationData),
content: localizer(helpContent, localizationData),
buttons: {},
},
{ resizable: true },
);
};
/**
* Asks the user to provide a simple piece of information, this is primarily
* intended to be used within macros so that it can have better info gathering
* as needed. This returns an object of input keys/labels to the value the user
* input for that label, if there is only one input, this will return the value
* without an object wrapper, allowing for easier access.
*/
static async ask(data, opts = {}) {
if (!data.id) {
throw new Error(`Asking the user for input must contain an ID`);
}
if (!data.inputs.length) {
throw new Error(`Must include at least one input specification when prompting the user`);
}
let autofocusClaimed = false;
for (const i of data.inputs) {
i.id ??= foundry.utils.randomID(16);
i.inputType ??= `text`;
// Only ever allow one input to claim autofocus
i.autofocus &&= !autofocusClaimed;
autofocusClaimed ||= i.autofocus;
// Set the value's attribute name if it isn't specified explicitly
if (!i.valueAttribute) {
switch (i.inputType) {
case `checkbox`:
i.valueAttribute = `checked`;
break;
default:
i.valueAttribute = `value`;
};
};
};
opts.jQuery = true;
data.default ??= `confirm`;
data.title ??= `System Question`;
data.content = await renderTemplate(
`systems/${game.system.id}/templates/Dialogs/ask.hbs`,
data,
);
return new Promise((resolve, reject) => {
DialogManager.createOrFocus(
data.id,
{
...data,
buttons: {
confirm: {
label: `Confirm`,
callback: (html) => {
const answers = {};
/*
Retrieve the answer for every input provided using the ID
determined during initial data prep, and assign the value
to the property of the label in the object.
*/
for (const i of data.inputs) {
const element = html.find(`#${i.id}`)[0];
let value = element.value;
switch (i.inputType) {
case `number`:
value = parseFloat(value);
break;
case `checkbox`:
value = element.checked;
break;
}
Logger.debug(`Ask response: ${value} (type: ${typeof value})`);
answers[i.key ?? i.label] = value;
if (data.inputs.length === 1) {
resolve(value);
return;
}
}
resolve(answers);
},
},
cancel: {
label: `Cancel`,
callback: () => reject(`User cancelled the prompt`),
},
},
},
opts,
);
});
};
static get size() {
return DialogManager.#dialogs.size;
}
};
globalThis.DialogManager = DialogManager;

View file

@ -1,39 +0,0 @@
export function createDocumentProxy(defaultClass, classes) {
// eslint-disable-next-line func-names
return new Proxy(function () {}, {
construct(_target, args) {
const [data] = args;
if (!classes[data.type]) {
return new defaultClass(...args);
}
return new classes[data.type](...args);
},
get(_target, prop, _receiver) {
if ([`create`, `createDocuments`].includes(prop)) {
return (data, options) => {
if (data.constructor === Array) {
return data.map(i => this.constructor.create(i, options));
}
if (!classes[data.type]) {
return defaultClass.create(data, options);
}
return classes[data.type].create(data, options);
};
};
if (prop == Symbol.hasInstance) {
return (instance) => {
if (instance instanceof defaultClass) {return true}
return Object.values(classes).some(i => instance instanceof i);
};
};
return defaultClass[prop];
},
});
};

View file

@ -1,10 +0,0 @@
import { FEATURE_FLAGS } from "../../consts.mjs";
export function hideMessageText(content) {
const featureFlags = game.settings.get(game.system.id, `flags`);
const hideContent = featureFlags.includes(FEATURE_FLAGS.ROLLMODECONTENT);
if (hideContent) {
return `-=${content}=-`;
}
return content;
};

View file

@ -1,11 +0,0 @@
import { FEATURE_FLAGS } from "../consts.mjs";
import { hideMessageText } from "./feature_flags/rollModeMessageContent.mjs";
globalThis.taf = Object.freeze({
utils: {
hideMessageText,
},
const: {
FEATURE_FLAGS,
},
});

View file

@ -1,45 +0,0 @@
/** A handlebars helper that utilizes the recursive localizer */
export function handlebarsLocalizer(key, ...args) {
let data = args[0];
if (args.length === 1) { data = args[0].hash }
if (key instanceof Handlebars.SafeString) {key = key.toString()}
const localized = localizer(key, data);
return localized;
};
/**
* A localizer that allows recursively localizing strings so that localized strings
* that want to use other localized strings can.
*
* @param {string} key The localization key to retrieve
* @param {object?} args The arguments provided to the localizer for replacement
* @param {number?} depth The current depth of the localizer
* @returns The localized string
*/
export function localizer(key, args = {}, depth = 0) {
/** @type {string} */
let localized = game.i18n.format(key, args);
const subkeys = localized.matchAll(/@(?<key>[a-zA-Z.]+)/gm);
// Short-cut to help prevent infinite recursion
if (depth > 10) {
return localized;
};
/*
Helps prevent localization on the same key so that we aren't doing excess work.
*/
const localizedSubkeys = new Map();
for (const match of subkeys) {
const subkey = match.groups.key;
if (localizedSubkeys.has(subkey)) {continue}
localizedSubkeys.set(subkey, localizer(subkey, args, depth + 1));
};
return localized.replace(
/@(?<key>[a-zA-Z.]+)/gm,
(_fullMatch, subkey) => {
return localizedSubkeys.get(subkey);
},
);
};

55
styles/Apps/Ask.css Normal file
View file

@ -0,0 +1,55 @@
.taf.Ask {
min-width: 330px;
.prompt {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
align-items: center;
}
.window-content {
gap: 1rem;
overflow: auto;
}
.dialog-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
label {
color: var(--color-form-label);
font-weight: bold;
}
p {
margin: 0;
text-indent: 1em;
&.error {
font-size: 1.1rem;
padding: 6px 8px;
box-shadow: 0 0 10px var(--color-shadow-dark);
color: var(--color-text-light-1);
border-radius: 5px;
text-align: center;
background: var(--color-level-error-bg);
border: 1px solid var(--color-level-error);
text-indent: 0;
}
}
input[type="checkbox"] {
align-self: center;
justify-self: right;
margin: 0;
}
}

View file

@ -0,0 +1,50 @@
.taf.AttributeManager {
.attributes {
display: flex;
flex-direction: column;
gap: 8px;
}
.attribute {
display: grid;
grid-template-columns: min-content 1fr repeat(3, auto);
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid rebeccapurple;
border-radius: 4px;
label {
display: flex;
flex-direction: row;
align-items: center;
&.vertical {
flex-direction: column;
}
}
/* Used to style the actual element as dragging */
&:has(taf-icon:active) {
background: var(--background);
}
}
taf-icon {
cursor: grab;
&:active {
cursor: grabbing;
}
}
.controls {
display: flex;
flex-direction: row;
gap: 8px;
button {
flex-grow: 1;
}
}
}

View file

@ -0,0 +1,58 @@
.taf.PlayerSheet {
.sheet-header, fieldset, .content {
border-radius: 8px;
border: 1px solid rebeccapurple;
}
.sheet-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
padding: 4px;
img {
border-radius: 4px;
}
}
.attributes {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
gap: 0.5rem;
}
.attr-range {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
width: 100px;
margin: 0 auto;
> input {
text-align: center;
}
}
.content {
flex-grow: 1;
overflow: hidden;
--table-row-color-odd: var(--table-header-bg-color);
&:not(:has(> prose-mirror)) {
padding: 0.5rem;
}
}
prose-mirror {
height: 100%;
menu {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
}

View file

@ -0,0 +1,33 @@
.taf.QueryStatus {
.user-list {
display: flex;
flex-direction: column;
gap: 4px;
list-style-type: none;
margin: 0;
padding: 0;
li {
display: flex;
flex-direction: column;
margin: 0;
border: 1px solid rebeccapurple;
border-radius: 4px;
padding: 4px 8px;
> .user-summary {
display: flex;
flex-direction: row;
align-items: center;
/* Same height as the icons used for loading/disconnected */
height: 35px;
}
}
}
.control-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}

View file

@ -0,0 +1,15 @@
.taf.sheet-config {
section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tab {
display: none;
}
.tab.active {
display: unset;
}
}

9
styles/Apps/common.css Normal file
View file

@ -0,0 +1,9 @@
.taf {
> .window-content {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow: auto;
}
}

View file

@ -0,0 +1,23 @@
:host {
display: inline-block;
}
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
width: var(--size, 1rem);
height: var(--size, 1rem);
fill: var(--fill);
}
path {
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linejoin: var(--stroke-linejoin);
}

View file

@ -0,0 +1,22 @@
:host {
display: inline-block;
}
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
fill: var(--fill);
stroke: var(--stroke);
}
path {
stroke: var(--stroke);
stroke-width: var(--stroke-width);
stroke-linejoin: var(--stroke-linejoin);
}

20
styles/elements/div.css Normal file
View file

@ -0,0 +1,20 @@
.taf > .window-content div {
&.chip {
display: inline flex;
color: var(--chip-color);
background: var(--chip-background);
border: 1px solid var(--chip-border-color);
border-radius: 4px;
.key {
padding: 2px 4px;
}
.value {
padding: 2px 4px;
border-radius: 0 4px 4px 0;
color: var(--chip-value-color);
background: var(--chip-value-background);
}
}
}

View file

@ -0,0 +1,5 @@
.taf > .window-content {
h1, h2, h3, h4, h5, h6 {
margin: 0;
}
}

7
styles/elements/hr.css Normal file
View file

@ -0,0 +1,7 @@
.taf > .window-content hr {
height: 1px;
background: rebeccapurple;
border-radius: 0;
margin: 0;
padding: 0;
}

56
styles/elements/input.css Normal file
View file

@ -0,0 +1,56 @@
.taf > .window-content input {
&.large {
--input-height: 2.5rem;
font-size: 1.75rem;
}
&[type="checkbox"] {
--checkbox-checked-color: var(--color-warm-1);
width: var(--checkbox-size);
height: var(--checkbox-size);
background: var(--input-background-color);
border: 2px solid var(--color-cool-3);
position: relative;
border-radius: 4px;
cursor: pointer;
&::before, &::after {
display: none;
}
&:focus-visible {
outline: 2px solid var(--checkbox-checked-color);
outline-offset: 3px;
}
&:checked::after {
display: block;
position: absolute;
inset: 4px;
z-index: 1;
content: "";
border-radius: 4px;
background: var(--checkbox-checked-color);
cursor: pointer;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
&::before {
display: block;
position: absolute;
inset: 0;
content: "";
background: var(--color-level-error-bg);
border-radius: 2px;
cursor: not-allowed;
}
&::after {
cursor: not-allowed;
}
}
}
}

9
styles/elements/p.css Normal file
View file

@ -0,0 +1,9 @@
.taf > .window-content p {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}

View file

@ -0,0 +1,13 @@
.taf > .window-content prose-mirror {
background: var(--prosemirror-background);
gap: 0;
.editor-content {
padding: 8px;
}
.tableWrapper th,
.tableWrapper td {
border-color: rebeccapurple;
}
}

45
styles/elements/span.css Normal file
View file

@ -0,0 +1,45 @@
@keyframes rotate {
0% { transform: rotate(0deg); }
50% { transform: rotate(360deg); }
100% { transform: rotate(720deg); }
}
@keyframes prixClipFix {
0%, 100% {
clip-path: polygon(50% 50%,0 0,0 0,0 0,0 0,0 0);
}
25%, 63% {
clip-path: polygon(50% 50%,0 0,100% 0,100% 0,100% 0,100% 0);
}
37%, 50% {
clip-path: polygon(50% 50%,0 0,100% 0,100% 100%,100% 100%,100% 100%);
}
}
.taf > .window-content span {
&.loader {
--size: 35px;
width: var(--size);
height: var(--size);
border-radius: 50%;
position: relative;
animation: rotate 2s linear infinite;
display: block;
&::before, &::after {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0px;
border-radius: 50%;
border: 5px solid var(--spinner-outer-colour, #fff);
animation: prixClipFix 4s linear infinite;
}
&::after{
inset: 8px;
transform: rotate3d(90, 90, 0, 180deg );
border-color: var(--spinner-inner-colour, #ff3d00);
}
}
}

21
styles/elements/table.css Normal file
View file

@ -0,0 +1,21 @@
/*
This styling is unscoped in order to make it so that it still applies
to the chat messages which are not within a scope I control.
*/
table.taf-query-summary {
margin: 0px;
tr:hover > td {
background-color: var(--table-header-border-highlight);
}
td {
padding: 4px 8px;
border: 1px solid var(--table-header-border-color);
width: 40%;
&:first-of-type {
width: 60%;
}
}
}

View file

@ -0,0 +1,3 @@
.taf > .window-content {
.grow { flex-grow: 1; }
}

29
styles/main.css Normal file
View file

@ -0,0 +1,29 @@
@layer resets, themes, elements, components, partials, apps, exceptions;
/* Resets */
@import url("./resets/hr.css") layer(resets);
@import url("./resets/inputs.css") layer(resets);
@import url("./resets/button.css") layer(resets);
/* Themes */
@import url("./themes/dark.css") layer(themes);
@import url("./themes/light.css") layer(themes);
/* Elements */
@import url("./elements/utils.css") layer(elements);
@import url("./elements/div.css") layer(elements);
@import url("./elements/headers.css") layer(elements);
@import url("./elements/hr.css") layer(elements);
@import url("./elements/input.css") layer(elements);
@import url("./elements/p.css") layer(elements);
@import url("./elements/prose-mirror.css") layer(elements);
@import url("./elements/span.css") layer(elements);
@import url("./elements/table.css") layer(elements);
/* Apps */
@import url("./Apps/common.css") layer(apps);
@import url("./Apps/Ask.css") layer(apps);
@import url("./Apps/AttributeManager.css") layer(apps);
@import url("./Apps/PlayerSheet.css") layer(apps);
@import url("./Apps/QueryStatus.css") layer(apps);
@import url("./Apps/TAFDocumentSheetConfig.css") layer(apps);

3
styles/resets/button.css Normal file
View file

@ -0,0 +1,3 @@
.taf > .window-content button {
height: initial;
}

3
styles/resets/hr.css Normal file
View file

@ -0,0 +1,3 @@
.taf > .window-content hr {
all: initial;
}

Some files were not shown because too many files have changed in this diff Show more