Compare commits

..

177 commits

Author SHA1 Message Date
2414fde703
Merge pull request #69 from Eldritch-Oliver/content/sprint-start
Add the Sprint Start content
2025-10-12 01:12:05 -06:00
Eldritch-Oliver
de0030a875 Update the workflow to build the compendia and include it in releases 2025-10-09 18:36:16 -06:00
Eldritch-Oliver
be04ab9a26 Add data for the compendia from the SprintStart zine 2025-10-09 02:04:38 -06:00
Eldritch-Oliver
8f206baf46 Add initial pack definitions 2025-10-09 02:03:56 -06:00
Eldritch-Oliver
5b9e808ea9 Fix version identifier 2025-10-08 23:14:18 -06:00
Eldritch-Oliver
d5899aa296 Update system.json 2025-10-08 23:10:58 -06:00
Eldritch-Oliver
3dd3139281 Update the brace format from 1tbs to stroustrup 2025-10-08 23:10:15 -06:00
1c7308e188
Merge pull request #65 from Eldritch-Oliver/bug/alert-cryptic-events-globally
Make the Cryptic Event notifications appear for all active players
2025-10-08 23:08:25 -06:00
Eldritch-Oliver
dad9ab860c Broadcast the notify event when a cryptic event happens 2025-10-08 23:06:40 -06:00
Eldritch-Oliver
e06c500b5c Add a notify websocket event 2025-10-08 23:06:17 -06:00
Eldritch-Oliver
822094077b Make the devMode game unpause be broadcasted 2025-10-08 23:05:57 -06:00
a6047ff009
Merge pull request #64 from Eldritch-Oliver/feature/haste-roll-shortcut
Added a button to roll for hasty turns in the Actor Stats sheet
2025-10-08 17:55:56 -06:00
Eldritch-Oliver
e18b01e425 Apply PR feedback 2025-10-08 17:53:36 -06:00
Eldritch-Oliver
f4969667f4 Update system manifest 2025-10-08 00:17:04 -06:00
Eldritch-Oliver
4eecd15acf Add the required actions from the component parts into the combined sheet 2025-10-08 00:06:19 -06:00
Eldritch-Oliver
6e77bdd949 Add an action button for Haste checks in the Stats card UI 2025-10-08 00:05:09 -06:00
Eldritch-Oliver
59c66c20a1 Add an action for rolling a Haste Check 2025-10-07 22:22:32 -06:00
Eldritch-Oliver
fa0b1078a1 Finish the helper in the public API and broadcast the socket event 2025-10-07 22:22:09 -06:00
Eldritch-Oliver
78d02400d0 Add short circuit to ensure that socket events through the API can't trigger the sands update while disabled 2025-10-07 22:21:33 -06:00
Eldritch-Oliver
77d43f28b4 Add a setting that makes it so player rolls don't auto-update the sands of fate 2025-10-07 22:21:01 -06:00
c23c67280f
Merge pull request #60 from Eldritch-Oliver/feature/embedded-item-creation
Add a way to create items from within the Hero sheet directly
2025-10-07 19:15:56 -06:00
cc917878c3
Merge pull request #59 from Eldritch-Oliver/feature/prompt-equip-on-drop
Add a prompt when you drag and drop an item onto an actor that asks you if you want to equip the item automatically
2025-10-07 18:31:55 -06:00
Eldritch-Oliver
c0a9731b02 Add socket event handling foundations and an updateSands event in anticipation of hasty rolls 2025-10-06 23:25:27 -06:00
Eldritch-Oliver
7c0fb75e0f Get the base functions set up that are required for the roll shortcut 2025-10-05 23:47:52 -06:00
Eldritch-Oliver
92e844341d Extract the Sands changing into a function of the public API for the HUD 2025-10-05 23:46:39 -06:00
Eldritch-Oliver
5c95fec201 Add the socket enabling to the manifest 2025-10-05 23:26:45 -06:00
Eldritch-Oliver
92cb1ed7ff Add a confirmation dialog whenever deleting an item from the sheet context menus (closes #57) 2025-10-05 23:07:12 -06:00
Eldritch-Oliver
898e01f215 Update icon credits 2025-10-05 22:56:51 -06:00
Eldritch-Oliver
7e70a512f7 Added improved shield icons 2025-10-05 19:42:06 -06:00
Eldritch-Oliver
797f473c59 Remove extraneous SVG stuff 2025-10-05 16:30:04 -06:00
Eldritch-Oliver
495e669ba6 Add the buttons for creating new embedded items from the sheet 2025-10-05 16:28:40 -06:00
Eldritch-Oliver
9b239831b8 Add a grow helper class for better flexbox positioning 2025-10-05 16:28:12 -06:00
Eldritch-Oliver
a2b6fd8dfc Pass in the showEquipPrompt flag since we don't want it when making items directly from the sheet 2025-10-05 16:27:50 -06:00
Eldritch-Oliver
9e7ef02f62 Add a plus icon 2025-10-05 16:27:06 -06:00
Eldritch-Oliver
cc61a0c3ac Add a way to skip the equip prompt for API-based creation when it's not desired 2025-10-05 15:48:15 -06:00
Eldritch-Oliver
08278655dc Update the preUpdate handling to not block the entire update, bringing it more inline with Armour/Shield 2025-10-05 15:25:52 -06:00
Eldritch-Oliver
f29ab8bdaa Add a prompt for equipping an item on drag and drop 2025-10-05 15:25:23 -06:00
Eldritch-Oliver
dfc8964296 Add a getter for retrieving equipped weapons easily 2025-10-05 15:24:37 -06:00
Eldritch-Oliver
2497c492bf Update the way that the force rerender calls the render method 2025-10-05 15:24:11 -06:00
Eldritch-Oliver
98b429f941 Finish making the intellisense work properly 2025-10-05 13:31:31 -06:00
Eldritch-Oliver
0917f28fcb Fix the tab change on ready 2025-10-05 12:55:10 -06:00
1228b32823
Merge pull request #52 from Eldritch-Oliver/feature/armour-sheet-improvements
Armour/Shield Sheet Improvements
2025-10-04 21:20:02 -06:00
Eldritch-Oliver
739652e346 Add missing localizations 2025-10-04 21:19:38 -06:00
Eldritch-Oliver
e552b7d041 Make the logic use an or instead nullish coalesce 2025-10-04 21:18:10 -06:00
Eldritch-Oliver
4d36cc29a5 Clean up the armour summary markup 2025-10-04 21:17:25 -06:00
Eldritch-Oliver
514103fe0b Remove download link 2025-10-04 20:45:58 -06:00
Eldritch-Oliver
8098ede72c Update action to use variable substitution 2025-10-04 20:44:01 -06:00
Eldritch-Oliver
bf06edc5c6 Add a script for linking Foundry into the project root 2025-10-04 20:43:41 -06:00
Eldritch-Oliver
d15301663c Update system.json 2025-10-04 20:43:29 -06:00
Oliver-Akins
ae0d4fb0a2 Provide provide proper limited accessor 2025-07-31 21:32:58 -06:00
Oliver-Akins
511481e608 Finish writing the GenericAppMixin jsdoc 2025-07-31 21:32:42 -06:00
Oliver-Akins
a01c79ea2f Remove unused import 2025-07-31 20:50:50 -06:00
Oliver-Akins
b1ba33919f Localize and add missing labels 2025-07-31 20:50:44 -06:00
Oliver-Akins
7dfc1bd0c0 Add IDs for the label associations 2025-07-31 10:32:42 -06:00
Oliver-Akins
e90e90bfe0 Remove log 2025-07-31 10:29:56 -06:00
Oliver-Akins
ca0c793b56 Add cursor pointer to events 2025-07-23 23:52:23 -06:00
Oliver-Akins
3c582c77bb Update the ArmourSheets to allow for the equipped toggle to be present and work 2025-07-23 23:52:07 -06:00
Oliver-Akins
b72c9d9739 Add meta properties that indicate if the document is embedded, and if the user is able to edit it 2025-07-23 22:17:05 -06:00
Oliver-Akins
0bd099cc28 Add todo so I don't forget 2025-07-20 21:45:10 -06:00
Oliver-Akins
2215ce503b Remove the sheet inputs from the data model 2025-07-20 21:44:27 -06:00
Oliver-Akins
bfa26edd5b Prevent the AllItemSheet from being used on armour/shields 2025-07-20 21:43:41 -06:00
Oliver-Akins
99c1281da8 Add lang entry for the new sheet 2025-07-20 21:43:23 -06:00
Oliver-Akins
caa3fbbda0 Localize name based on the core translation 2025-07-20 21:35:39 -06:00
Oliver-Akins
94942c8eab Make a unique ArmourSheet so that it can have a better UX for indicating protection locations 2025-07-20 21:35:27 -06:00
Oliver-Akins
2b88bcc2ef Add a component that handles displaying the person silhouette with some content inside of it 2025-07-20 21:32:46 -06:00
Oliver-Akins
26a2e0f3ff Cleanup a few logs 2025-07-20 21:28:25 -06:00
Oliver-Akins
e49fa03fed Rename the elements folder to components 2025-07-20 14:14:46 -06:00
Oliver-Akins
e489da9666 Update manifest for release 2025-07-14 21:51:55 -06:00
Oliver-Akins
a314ee5c18 Remove main from the branch protection rules 2025-07-14 21:44:11 -06:00
Oliver-Akins
eccfd96e67 Make it so that the setting change doesn't error when no combat is started 2025-07-14 21:43:27 -06:00
Oliver-Akins
5d8a4495a1 Add way to change whether heroes or geist go first in a combat 2025-07-14 21:43:12 -06:00
2a4ba73934
Merge pull request #48 from Oliver-Akins/dev
Merge the dev branch into main
2025-07-14 19:49:56 -06:00
Oliver-Akins
f0829556e0 Remove player text reference 2025-07-10 21:40:18 -06:00
Oliver-Akins
0df7bcdea9 Make the combined sheet scrollable and more consistent in design (closes #43) 2025-07-10 21:39:44 -06:00
Oliver-Akins
38c83586e5 Enable the ammo popover on the combined sheet (closes #44) 2025-07-09 20:12:31 -06:00
Oliver-Akins
88131aabe0 Update the release github action 2025-07-07 22:43:19 -06:00
Oliver-Akins
bd3f2a9f8b Style the Drag/Edge inputs to look and feel better 2025-07-07 22:31:58 -06:00
Oliver-Akins
a05adca9d8 Add final newline 2025-07-07 22:31:16 -06:00
Oliver-Akins
966d9f2f41 Ignore text files 2025-07-07 20:52:17 -06:00
Oliver-Akins
9739995a12 Fix v13.341 issues to prep for release 2025-04-30 13:27:51 -06:00
Oliver-Akins
d5680bb209 Complete the Geist sheet and data 2025-04-12 13:54:07 -06:00
Oliver-Akins
647f1a9aac Initialize the geist actor type & refactor the HeroData data model 2025-04-12 13:13:04 -06:00
Oliver-Akins
cfc744e42f Update the Actor sheets to not be prefixed with Hero in their files/classes 2025-04-12 13:12:28 -06:00
Oliver-Akins
5b74fd6beb Finish the Skills Card redesign (closes #11) 2025-04-12 00:09:45 -06:00
Oliver-Akins
1bf1b2cd91 Correct my 2 off-by-one errors so that the gear list only spans 12 rows instead of 14 2025-04-11 23:52:34 -06:00
Oliver-Akins
dc4411a2b3 Prevent the resets from happening where we aren't taking full control. 2025-04-11 22:02:54 -06:00
Oliver-Akins
524ddee9d4 Redesign the Hero Summary Card to match the book 2025-04-11 21:46:49 -06:00
Oliver-Akins
0135ca1124 Update the system CSS to use sub-layers
This is so I have more granular control over interactions between resets and intentional styles
2025-04-11 21:06:40 -06:00
Oliver-Akins
714da335e8 Add cost data entry into the rest of the item types that need it 2025-04-11 19:13:16 -06:00
86ddac1aa4
Ammo Tracking
- Adds the sum of all ammo into the skills sheet
- Initializes a Popover system so that we can have rerenderable popovers in the system that can be rendered like tooltips, or popped out into their own window without the need for separate templating for each method. The "tooltip"-presentation is just a frameless application.
- Utilizes the Popover system to create an ammo popover that lists each ammo independently and allows the user to "star" up to three ammos that will be visible on the sheet at all times.
- Bumps the verified from from `13`->`13.339`
2025-04-10 21:28:30 -06:00
Oliver-Akins
e049ad9eb3 Remove redundant comment 2025-04-10 21:27:26 -06:00
Oliver-Akins
05a3db98c8 Get the AmmoTracker's in-popover editing working using the foreign document updating 2025-04-09 22:13:52 -06:00
Oliver-Akins
053ab05dcb Pull the foreign document updating into a utility method and add it to the GenericPopoverMixin 2025-04-09 21:51:49 -06:00
Oliver-Akins
55cff3c048 Set verified version Foundry v13.339 2025-04-09 21:43:30 -06:00
Oliver-Akins
01f9fba593 Add the ability to update the ammo quantity from the starred shortcuts 2025-04-09 21:42:32 -06:00
Oliver-Akins
4e89763901 Add localization string override for GM -> Keeper 2025-04-09 21:08:52 -06:00
Oliver-Akins
f1487bd9b8 Correctly forward the parameters to the super method 2025-04-09 21:08:35 -06:00
Oliver-Akins
26134b0390 Correct the height of the HUD element to prevent collapse only when not a GM 2025-04-05 23:33:20 -06:00
Oliver-Akins
8de50185c1 Remove unused CSS 2025-04-05 15:32:55 -06:00
Oliver-Akins
228cc21de7 Make the ID publicly readonly, privately writable 2025-04-05 15:32:31 -06:00
Oliver-Akins
bfddf855a4 Move the magic string into an enum 2025-04-05 15:31:28 -06:00
Oliver-Akins
95443d3709 Pull the tooltip delay from the Foundry tooltip class 2025-04-05 15:30:26 -06:00
Oliver-Akins
7ae5d1b814 Get rid of extraneous function override 2025-04-05 15:30:00 -06:00
Oliver-Akins
2e08064ebb Localize the app title 2025-04-05 15:29:49 -06:00
Oliver-Akins
a7e0fe899a Display all of the pinned ammo on the sheet and tweak the list header style. 2025-04-05 00:43:44 -06:00
Oliver-Akins
c495f45015 Get the base favourite mechanism working so the items are visible on the skills card 2025-03-22 21:20:02 -06:00
Oliver-Akins
7d39c487dc Prevent deprecation warnings as of v13.338 2025-03-22 14:34:20 -06:00
Oliver-Akins
3437dadb9b Reduce the z-index of the popovers a little bit 2025-03-16 10:57:53 -06:00
Oliver-Akins
032f2564c1 Make the stopEvent methods private 2025-03-16 10:53:57 -06:00
Oliver-Akins
3ae7e9489a Add ammo to the gear types, and get more design stuff for the AmmoTracker 2025-03-15 18:36:04 -06:00
Oliver-Akins
96e4d09e7b Update the popover management to work with origin rerenders, and rerendering the popovers directly. 2025-03-15 18:35:24 -06:00
Oliver-Akins
69bf712eca Have the PopoverManager call a hook to get additional options for the popover Application 2025-03-15 17:05:14 -06:00
Oliver-Akins
e8fdf6e952 Enable generic styling in frameless popovers 2025-03-15 14:43:08 -06:00
Oliver-Akins
88a47ba02b Tweak default popover styles 2025-03-14 23:55:48 -06:00
Oliver-Akins
9ea2eebdd9 Make sure the moving works when the width/height are auto 2025-03-14 23:55:12 -06:00
Oliver-Akins
e594b6beb0 Get the reusable foundations of custom popovers finished. 2025-03-14 22:52:40 -06:00
Oliver-Akins
4b75526708 Await render call 2025-03-14 16:34:13 -06:00
Oliver-Akins
77abcb11a9 Get the non-framed popover working initially 2025-03-14 16:33:58 -06:00
Oliver-Akins
8e8202f8a6 Getting the popover Application working on the most superficial level, and creating a generic popover mixin 2025-03-13 23:36:25 -06:00
Oliver-Akins
af5cf4acd5 Begin working on laying the groundwork for the Ammo Tracker / popover 2025-03-13 00:19:03 -06:00
8ebdc506ea
Merge pull request #36 from Oliver-Akins/feature/craft-card
Implement the Hero Craft Card
2025-03-12 22:41:23 -06:00
Oliver-Akins
96ef2ba56f Add JSdoc for the API 2025-03-12 22:40:19 -06:00
Oliver-Akins
fd28993952 Clean-up data preparation 2025-03-10 22:04:36 -06:00
Oliver-Akins
f46bd6b5d3 Localize the craft card (closes #35) 2025-03-10 21:52:49 -06:00
Oliver-Akins
4f35db01b6 Add the derived data for the aura ranges 2025-03-09 00:16:21 -07:00
Oliver-Akins
89b51a01e6 Get the aura display finished for the Craft Card (closes #23) 2025-03-08 22:39:07 -07:00
Oliver-Akins
a830adbd2d Get most of the Aura display implemented added to the HTML 2025-03-07 22:30:12 -07:00
Oliver-Akins
f1d9fe187c Add fract to the craft card (closes #22) 2025-03-07 19:50:45 -07:00
Oliver-Akins
bd3c8d9acc Remove unused CSS import 2025-03-07 19:48:08 -07:00
Oliver-Akins
5876d5fe98 Add system summary 2025-03-07 19:47:18 -07:00
Oliver-Akins
67753ce3e7 Add the Flect craft list (closes #21) 2025-03-06 18:47:41 -07:00
Oliver-Akins
b6f3539a95 Replace the Move - Run label with just Move 2025-03-05 23:06:28 -07:00
Oliver-Akins
17cd5532f4 Begin work on the Hero Craft card itself 2025-03-05 23:05:59 -07:00
Oliver-Akins
7c72b71436 Remove unneeded files 2025-03-05 22:13:33 -07:00
c1a7cacf6e
Merge pull request #26 from Oliver-Akins/feature/delve-dice-hud
Delve Dice HUD
2025-03-05 22:09:52 -07:00
Oliver-Akins
02a7016be8 Remove extra line 2025-03-04 16:59:56 -07:00
Oliver-Akins
3e17f2c376 Update CSS to accomodate HUD applications without being too broad 2025-03-04 16:59:41 -07:00
Oliver-Akins
8896903008 PR feedback pt 1 2025-03-04 15:45:33 -07:00
Oliver-Akins
ebed79e93d Fix incorrect import 2025-03-04 13:55:43 -07:00
Oliver-Akins
f3f0444ef0 Merge remote-tracking branch 'origin/dev' into feature/delve-dice-hud 2025-03-04 13:52:37 -07:00
a71a22bf19
Merge pull request #27 from Oliver-Akins/feature/combatant-groups
Restore Combat functionality that v13.337 broke
2025-03-02 20:40:37 -07:00
Oliver-Akins
ee325989df Restore functionality that was lost because v13.337's CombatantGroup document 2025-03-02 20:32:52 -07:00
Oliver-Akins
69dac6a0df Clean-up after the old attempt at the crypt summary 2025-03-02 19:49:42 -07:00
Oliver-Akins
155685a6c3 Get the delve conditions editable 2025-03-02 19:46:39 -07:00
Oliver-Akins
110823a26b Get the delve tour incrementer changes working and affecting fate as well 2025-03-01 23:40:39 -07:00
Oliver-Akins
7639962130 Add animation and editing support for the fate compass 2025-03-01 19:22:02 -07:00
Oliver-Akins
7c8d6a7208 Make the distanceBetweenFates more situation-complete 2025-03-01 19:20:21 -07:00
Oliver-Akins
507913139f Begin working on HUD interactivity 2025-03-01 00:44:57 -07:00
Oliver-Akins
c9ed4142e6 Finalize the general layout of the HUD 2025-03-01 00:34:25 -07:00
Oliver-Akins
6ae412c787 Get hourglass improved 2025-02-28 16:47:41 -07:00
Oliver-Akins
3d6710dd18 Get the fate compass looking good 2025-02-28 16:18:31 -07:00
Oliver-Akins
77979f5550 Add arrow icons for the HUD controls 2025-02-28 16:17:16 -07:00
Oliver-Akins
c7342b6402 Begin work on the updated delve dice HUD that is better in every way than the other version 2025-02-27 22:56:36 -07:00
Oliver-Akins
00228d3aae Begin adding cost to items 2025-02-22 19:21:35 -07:00
Oliver-Akins
dc5bf7aa07 Remove the v12 compatibility layer that isn't needed 2025-02-22 19:21:09 -07:00
Oliver-Akins
14b6f85137 Make a helper function for token bars that only store the current value in the DB 2025-02-22 18:38:57 -07:00
Oliver-Akins
de6ded9a39 Allow currency to be tracked on tokens if for some reason you want that 2025-02-22 18:38:07 -07:00
Oliver-Akins
8c5d7b6469 Add autocomplete for the rc-border element 2025-02-22 18:36:26 -07:00
Oliver-Akins
564e27de01 Make it so that item creation actually embeds if possible 2025-02-20 22:18:51 -07:00
Oliver-Akins
5eedea5001 Add foundation for being able to create embedded items without requiring going through the sidebar 2025-02-20 21:30:26 -07:00
Oliver-Akins
4f138202ce Deprecation warning fix 2025-02-20 20:36:39 -07:00
Oliver-Akins
329c45dad9 Add weight ratings and localize the Access field (and add it to items that should but don't have it) 2025-02-20 20:36:27 -07:00
Oliver-Akins
40ba46fc6b Get the DicePool to support edge/drag modifications 2025-02-20 00:29:02 -07:00
Oliver-Akins
43fe433900 RC-103 | Add Access to the rest of the item types that require it 2025-02-16 12:19:56 -07:00
Oliver-Akins
4ccfc03e59 RC-110 | Prevent Armour/Shield Equipping based on slots 2025-02-16 11:41:32 -07:00
b33d7b59eb
Add the ability for the Combat Tracker to make it multiple actor's turns at the same time
Combat Groups
2025-02-15 15:43:24 -07:00
Oliver-Akins
ec5ad470f8 Apply PR feedback 2025-02-15 15:41:48 -07:00
Oliver-Akins
26534ec0ca Update the turn marker settings on first load 2025-02-15 02:10:32 -07:00
Oliver-Akins
463b0c4553 Make the logger bind the function with the prefixed args rather than returning an anon function 2025-02-15 01:53:51 -07:00
Oliver-Akins
064e2fda7e Lint and cleanup 2025-02-15 01:46:16 -07:00
Oliver-Akins
e1be6675e0 Make the multi-turn indicator work fully 2025-02-15 01:43:25 -07:00
Oliver-Akins
c549a59c13 More progress
* Make it so that the initiative isn't actually saved to the database
* Add .25 for unknown disposition entities
* Add .5 for entities that lose the coinflip
2025-02-14 23:23:53 -07:00
Oliver-Akins
e302b56a4e Get most of the custom Initiative sorting stuff through the door 2025-02-14 00:04:48 -07:00
Oliver-Akins
2168162530 Move Kýnan in the credits file higher up since I don't care that much about alphabetical ordering 2025-02-13 19:56:45 -07:00
Oliver-Akins
837c2012c5 Add the caster silhouette SVG 2025-02-13 19:53:02 -07:00
Oliver-Akins
ed93e9f927 Add some resources to the relevant combat files 2025-02-13 00:49:28 -07:00
Oliver-Akins
2d804e7aa2 Add support for the Good item type to represent all misc items in the game 2025-02-11 23:54:59 -07:00
207 changed files with 7238 additions and 1051 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

@ -29,20 +29,22 @@ jobs:
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
- name: "Building compendia"
run: "npm run data:build"
- name: Move system.json to a temp file
id: manifest-move
run: mv system.json module.temp.json
- name: "Removing compendium source"
run: "rm -rf packs/**/_source"
- name: Update the download property in the manifest
- name: Update the manifest with the relevant properties
id: manifest-update
run: cat module.temp.json | jq -r --tab '.download = "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/release.zip"' > system.json
uses: microsoft/variable-substitution@v1
with:
files: "system.json"
env:
download: "https://github.com/${{ github.repository }}/releases/download/v${{ steps.version.outputs.version }}/release.zip"
- name: Create the zip
run: zip -r release.zip ${{ vars.files_to_release }}
run: zip -r release.zip system.json packs module langs assets templates README.md
- name: Create the draft release
uses: ncipollo/release-action@v1
@ -50,5 +52,6 @@ jobs:
tag: "v${{ steps.version.outputs.version }}"
commit: ${{ github.ref }}
draft: true
body: <img aria-hidden="true" src="https://img.shields.io/github/downloads/${{ github.repository }}/v${{ steps.version.outputs.version }}/release.zip?style=flat-square&color=%2300aa00">
generateReleaseNotes: true
artifacts: "release.zip,system.json"

9
.gitignore vendored
View file

@ -1,5 +1,7 @@
dist/
*.link
*.txt
/foundry
# Dependency directories
node_modules/
@ -11,3 +13,10 @@ jspm_packages/
.env.test.local
.env.production.local
.env.local
# Ignore all of the binaries and stuff that gets built for Foundry from the raw
# JSON data because it's annoying seeing it in my git changes when it isn't actually
# needed.
/packs/**/*
!/packs/**/*/
!/packs/**/*.json

View file

@ -26,6 +26,20 @@
{ "name": "var:stroke-width", "description": "The stroke width of the icon, must be a valid CSS unit" },
{ "name": "var:stroke-linejoin", "description": "The stroke linejoin of the icon, must be a valid CSS value" }
]
},
{
"name": "rc-border",
"description": "Creates a stylized border in the same sort of design that the published RipCrypt book uses",
"attributes": [
{ "name": "var:vertical-displacement", "description": "How much vertical displacement the title receives, defaults to 12.5px" },
{ "name": "var:padding", "description": "How much padding the border container has" },
{ "name": "var:border-color", "description": "The CSS value that is used as the colour of the border" },
{ "name": "var:padding-top", "description": "How much padding the top of the border element has, if not provided, defaults to the value of vertical displacement plus 4px" },
{ "name": "var:margin-top", "description": "How much margin the top of the border element has, if not provided, defaults to the value of vertical displacement" },
{ "name": "var:border-mask", "description": "The CSS colour used to mask out the border element, if not provided defaults to the --base-background CSS variable"},
{ "name": "var:title-height", "description": "The CSS height for the title, defaults to 20px" },
{ "name": "var:title-background", "description": "The CSS colour to make the title element, defaults to var:border-color" }
]
}
]
}

View file

@ -1,9 +1,10 @@
{
"files.exclude": {
"**/node_modules": true
"**/node_modules": true,
"foundry": true
},
"search.exclude": {
"foundry.*.link": true
"foundry": true
},
"html.customData": [
"./.vscode/foundry.html-data.json",
@ -11,5 +12,6 @@
],
"workbench.editorAssociations": {
"*.svg": "default",
}
},
"git.branchProtection": []
}

View file

@ -1,5 +1,14 @@
Oliver Akins:
Eldritch-Oliver:
- geist-silhouette.v2.svg : All rights reserved.
- caster-silhouette.v1.svg : All rights reserved.
- icons/star-empty.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
- icons/star.svg : Modified from https://thenounproject.com/icon/star-7711815/ by Llisole
- icons/shield/checked.v1.svg : Modified from https://thenounproject.com/icon/shield-5565751/ by Corner Pixel
- icons/shield/crossed.v1.svg : Modified from https://thenounproject.com/icon/shield-5565751/ by Corner Pixel
- icons/shield/solid.v1.svg : Modified from https://thenounproject.com/icon/shield-5565751/ by Corner Pixel
Kýnan Antos (Gritsilk Games):
- hero-silhouette.svg : Licensed to Distribute and Modify within the bounds of the "Foundry-RipCrypt" system.
ARISO:
- icons/hourglass.svg (https://thenounproject.com/icon/hourglass-7546736/) : Rights Purchased
@ -7,8 +16,13 @@ ARISO:
Abdulloh Fauzan:
- icons/info-circle.svg (https://thenounproject.com/icon/information-4176576/) : Rights Purchased
Kýnan Antos (Gritsilk Games):
- hero-silhouette.svg : Licensed to Distribute and Modify within the bounds of the "Foundry-RipCrypt" system.
hanifmuhammad:
- icons/plus.svg (https://thenounproject.com/icon/plus-7363257/) : Rights Purchased
QOLBIN SALIIM:
- icons/arrow-left.svg (https://thenounproject.com/icon/arrow-1933583/) : Rights Purchased
- icons/arrow-right.svg (https://thenounproject.com/icon/arrow-1933581/) : Rights Purchased
- icons/arrow-compass.svg (https://thenounproject.com/icon/arrow-2052607/) : Rights Purchased
Soetarman Atmodjo:
- icons/roll.svg (https://thenounproject.com/icon/dice-5195278/) : Rights Purchased

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 124 45"><path d="M57.684.377c-.627-.016-1.63.187-2.633 1.111-1.003.925-.169 1.578-.485 1.569-.549-.016-1.913 1.268-3.511 2.992s-1.222 4.388-1.05 5.14c.173.753.987 1.317.987 1.317s-.218-.972-.297-1.897c-.078-.924.392-1.833 1.27-3.322.877-1.489 1.746-1.92 1.746-1.92s-.367 1.026-.531 1.41-.448 2.813.242 3.409.407.572.258 1.16c-.15.587 0 .79.525 1.465.525.673.47 1.582.611 2.632s.323.948 1.67.885c1.348-.063 1.159.714.446 1.043-.713.33-.508.49-.065 1.377s.654 1.287-1.418 1.242c-6.249-.133-21.563 9.817-25.441 9.684s-9.995-2.105-10.86-2.504c-.864-.399-2.66-1.062-3.435-1.129-.776-.066-1.662-.532-2.77-1.086s-4.084-.338-4.084-.338-.232-.399-.398-.715-.322-.476-.787-.338c-.466.139.021 1.053.021 1.053s-.537.082-.78-.056c-.245-.139-.333-.45-.644-.893-.31-.443-.287-.73-.93-.441-.642.288.2 1.373.2 1.373s-.2.022-.775.244c-.577.221-.178.974-.178.974s-.865-.665-.998-1.086c-.133-.42-.421-.953-1.086-.554s.665 1.884.576 1.884-.53-.154-1.062-.597-.866-.644-1.309-.422.31 1.041 1.219 1.861c.908.82 1.64 1.197 3.258 1.397 1.617.2 2.24 1.196 3.28 1.152 1.042-.044 2.06-.62 4.698-.265 2.637.354 8.51 3.9 8.953 4.232s.466.643.488 1.02c.023.376-.377 1.862-.421 2.216-.045.355-.975 1.153-1.75 1.907-.776.753-1.33 1.86-1.33 2.28 0 .422-.887 1.663-.887 1.663s23.27.489 24.156.467c.887-.023 3.325-2.194 4.522-3.258 1.196-1.064 2.989-3.073 3.283-3.7.294-.626.33-.364.58-.05.25.313.345.203.69.234.344.032.218.032.25.502.03.47.753 1.52 1.505 2.256.752.737 1.88 2.852 2.334 4.106.455 1.253 21.03.753 20.592.22s-1.207-2.978-1.285-3.605c-.079-.627.25-2.036.25-2.506s-.313-1.553-.094-1.568c.22-.016 4.922 6.66 6.176 7.914 1.253 1.253 18.396.392 19.117.377.72-.016.627.313.58-1.223s-1.034-3.574-1.394-4.373-.064-.861.265-1.002c.33-.141 2.273-.847 3.244-1.254s3.87-1.347 5.438-1.582c.863-.13 1.983-.43 3.12-.78.559-.15 1.682-.535 4.089-1.574 3.071-1.325 5.214-3.823 6.363-4.906.265-.22.818-.84 1.746-2.033s.442-.949-.154-.97c-.597-.023-2.498 1.81-2.498 1.81s.044-1.326-.22-2.873c-.266-1.547-.73-.617-.84-.264-.111.354-.067 1.768-.067 1.768s-.774-1.68-.973-1.68-.508.75-.154 1.436c.353.685.441 1.79.441 1.79s-.44-.332-1.015-1.591c-.575-1.26-.795-.132-.729.398s.883 1.613.883 2.188c0 .574-.486.95-1.502.927s-.796-.619-.553-1.26c.243-.64.73-1.944.752-2.519.022-.574-.509-1.28-.885-1.258-.375.022-.309.574-.574 2.01s-2.365 2.475-3.426 3.094c-.827.483-.957.818-1.257 1.152-.578.456-1.311.876-1.684.793-.564-.125-.565.095-.91.408-.345.314-1.221.532-3.133.94 0 0-2.79.345-3.824.408s-2.07-.156-2.916-.752c-.846-.595-1.88-1.004-4.543-.283-2.664.72-4.17.564-4.17.564s-.39-1.512-1.785-3.306-2.586-2.186-2.586-2.186.54-.07 1.55-.125 1.898.548 2.446.877c.548.33.728 1.417.814 1.723.087.305-.148.855-.015.87s.478-1.12.478-1.41-.345-1.286-.455-1.513-.729-.744-.588-.877.698.063 1.207.51c.51.446.791.775.862 2.1.07 1.324-.893 2.09-.698 2.287.196.195 1.584-1.535 1.569-3s-1.05-2.703-1.912-3.495c-.862-.791-2.328-1.066-2.313-1.207.016-.14.385-.556.377-1.425-.008-.87-.745-1.968-1.027-2.336-.282-.369-1.41-1.018-1.15-1.057.258-.04.837.188 1.746.705.908.517 1.396 1.762 1.56 1.691s-.165-1.05-.635-1.92-1.81-1.308-1.81-1.308.312-.163.94-1.096c.626-.932.046-1.7-.071-1.912-.118-.211-.822-.572-.328.04.493.61-.337 1.268-1.434 2.052-1.097.783-3.918.156-3.918.156s-.125.024 0-.916c.126-.94-.596-2.539-2.445-3.949-1.85-1.41-3.448-.282-6.645-.094s-3.95-2.757-5.861-6.174c-1.912-3.416-5.093-3.996-6.049-3.996S58.31.393 57.684.377m21.06 12.541a3.3 3.3 0 0 1 1.844.582c1.208.79 1.01 2.57 1.01 2.57s-1.325-.392-2.547-1.841c-.833-.988-2.155-1.223-2.155-1.223s.568-.069 1.202-.086c.227-.006.44-.01.646-.002m2.465 5.38c.124-.012.623.257 1.277.507.698.266 1.774.277 2.627.343.854.067 1.386.111 2.416 1.086s.287 2.371.287 2.371l-1.33-.056s.312-.388.534-.71c.221-.32.375-1.428-.467-1.76s-.366-.056-.3.52c.067.577-.73 1.075-1.405 1.075-.676 0-1.496-.598-2.205-1.24-1.197-.998-1.531-2.072-1.454-2.127a.04.04 0 0 1 .02-.008" style="paint-order:markers stroke fill"/></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m38.418 8.5469 59.746 36.863c2.2617 1.2695 2.4023 7.9102 0 9.3203l-59.746 36.863c-51.27 31.781-51.129-115.39 0-83.051z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m41.461 8.6953-41.461 39.906c14.129 13.355 28.105 26.707 42.234 40.062 2.6406 2.6406 8.2305-1.707 8.2305-5.4336v-15.684c44.875 7.918 49.379 27.176 49.535 24.379 2.1758-50.621-49.535-56.523-49.535-61.801v-15.219c0-4.1914-5.9023-9.1602-9.0078-6.2109z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m58.539 8.6953 41.461 39.906c-14.129 13.355-28.105 26.707-42.234 40.062-2.6406 2.6406-8.2305-1.707-8.2305-5.4336v-15.684c-44.875 7.918-49.379 27.176-49.535 24.379-2.1758-50.621 49.535-56.523 49.535-61.801v-15.219c0-4.1914 6.0547-9.1602 9.0078-6.2109z" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

4
assets/icons/evil.svg Normal file
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="m92.719 4.8594s-4.6914 7.8398-17.219 13.52c-7.1094-5.3203-15.941-8.4688-25.5-8.4688s-18.391 3.1484-25.5 8.4688c-12.531-5.6797-17.219-13.52-17.219-13.52s-6.9609 17.73 3.4297 31.109c-2.1289 5.0781-3.3203 10.691-3.3203 16.551 0 23.539 19.078 42.621 42.621 42.621 23.539 0 42.621-19.078 42.621-42.621 0-5.8594-1.1914-11.469-3.3203-16.551 10.379-13.379 3.4297-31.109 3.4297-31.109zm-56.719 57.762c-2.4609 1.1211-5.3086 1.2383-8.0391 0.32812-2.7305-0.91016-4.9414-2.7109-6.2305-5.0781-1.25-2.2891-1.5508-4.9805-0.89844-7.8516l20.602 6.8711c-1.1992 2.6914-3.0508 4.6602-5.4297 5.7383zm42.27-4.7617c-1.2891 2.3711-3.5117 4.1797-6.2305 5.0781s-5.5781 0.78906-8.0391-0.32812c-2.3789-1.0781-4.2305-3.0508-5.4297-5.7383l20.602-6.8711c0.66016 2.8711 0.35156 5.5586-0.89844 7.8516z"/>
</svg>

After

Width:  |  Height:  |  Size: 933 B

6
assets/icons/hero.svg Normal file
View file

@ -0,0 +1,6 @@
<?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="m39.898 56.199-7.6992-10.801c-1.8984-2.6016-2-6.3008-0.19922-9 0.19922-0.30078 2.3984-3.5 6.8008-6.6992-16.5 7.3984-34.5 21.398-36 23.801-2.5 4 11.5 5.6016 14 19.199 2.1016 11.898 18 17.602 20.398 11.898 2.3008-5.6016 5-10.801 7.8984-15.398l0.80078-9.6992c-2.5-0.19922-4.5977-1.3984-6-3.3008z"/>
<path d="m93.102 26.602h-33.102c-17 0-24.801 11.301-25.102 11.801-1 1.5-1 3.5 0.10156 5l7.6992 10.801c0.89844 1.1992 2.1992 1.8008 3.6016 1.8008 0.89844 0 1.8008-0.30078 2.5-0.80078 0.19922-0.19922 0.39844-0.39844 0.60156-0.60156v3.1016l-2.6992 32.602c-0.19922 2.6992 1.8008 5 4.5 5.3008 2.6992 0.19922 5-1.8008 5.3008-4.5l2.6016-31.301 1.8945-0.003907 2.6016 31.199c0.19922 2.5 2.3008 4.5 4.8984 4.5h0.39844c2.6992-0.19922 4.6992-2.6016 4.5-5.3008l-2.6992-32.602-0.19922-22.301h22.699c2.3984 0 4.3984-2 4.3984-4.3984-0.097656-2.3984-2.0977-4.2969-4.4961-4.2969zm-43.602 22-5.3008-7.3984c1.1992-1.1016 3-2.3984 5.3984-3.6016z"/>
<path d="m69.801 14.199c0 5.4141-4.3867 9.8008-9.8008 9.8008s-9.8008-4.3867-9.8008-9.8008c0-5.4102 4.3867-9.8008 9.8008-9.8008s9.8008 4.3906 9.8008 9.8008z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

3
assets/icons/plus.svg Normal file
View file

@ -0,0 +1,3 @@
<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<path d="m83.59 16.41c-18.547-18.547-48.633-18.547-67.18 0s-18.547 48.633 0 67.18 48.633 18.547 67.18 0 18.547-48.633 0-67.18zm-6.3438 39.922h-20.914v20.91c0 3.5039-2.8398 6.332-6.332 6.332s-6.332-2.8281-6.332-6.332v-20.91h-20.914c-3.5039 0-6.332-2.8398-6.332-6.332s2.8281-6.332 6.332-6.332h20.914v-20.914c0-3.5039 2.8398-6.332 6.332-6.332s6.332 2.8281 6.332 6.332v20.91h20.91c3.5039 0 6.332 2.8398 6.332 6.332 0.003906 3.4961-2.8242 6.3359-6.3281 6.3359z"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M50 6.9q-2.1 0-3.9 1.4a47 47 0 0 1-24.8 10c-3.2.3-5.7 3-5.7 6.2v31c0 13 7.3 24.8 19 30.7l12.6 6.2a6 6 0 0 0 5.6 0l12.6-6.2a34 34 0 0 0 19-30.8V24.5c0-3.2-2.5-5.9-5.7-6.2a47 47 0 0 1-24.8-10A6 6 0 0 0 50 6.9m20.4 25.6a5 5 0 0 1 4 8.2L52.2 69a5 5 0 0 1-7.2.8L28.2 55.9a5 5 0 0 1 6.4-7.7l12.8 10.5 19.1-24.2a5 5 0 0 1 3.9-2"/></svg>

After

Width:  |  Height:  |  Size: 401 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M50 6.9q-2.1 0-3.9 1.4a47 47 0 0 1-24.8 10c-3.2.3-5.7 3-5.7 6.2v31c0 13 7.3 24.8 19 30.7l12.6 6.2a6 6 0 0 0 5.6 0l12.6-6.2a34 34 0 0 0 19-30.8V24.5c0-3.2-2.5-5.9-5.7-6.2a47 47 0 0 1-24.8-10A6 6 0 0 0 50 6.9M36.7 27.6a6 6 0 0 1 4.6 2.3l9 11.2L59 30a6 6 0 0 1 8.4-1l.4.3a6 6 0 0 1 1 8.4L58.2 51.2l10.5 13.3a6 6 0 0 1-1 8.4l-.4.3a6 6 0 0 1-8.4-1l-8.7-11-8.7 11.1a6 6 0 0 1-8.4 1l-.3-.3a6 6 0 0 1-1-8.4l10.5-13.4-10.8-13.6a6 6 0 0 1 1-8.4l.4-.3a6 6 0 0 1 3.8-1.3"/></svg>

After

Width:  |  Height:  |  Size: 539 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M49.996 6.9a6.2 6.2 0 0 0-3.869 1.362 46.9 46.9 0 0 1-24.828 10.035c-3.238.308-5.68 2.984-5.68 6.219V55.41c0 13.102 7.281 24.883 19.004 30.746l12.578 6.29a6.32 6.32 0 0 0 5.59 0l12.578-6.29c11.72-5.863 19.004-17.645 19.004-30.746l.004-30.894c0-3.239-2.441-5.91-5.68-6.22h-.002a46.9 46.9 0 0 1-24.83-10.034A6.2 6.2 0 0 0 49.996 6.9"/></svg>

After

Width:  |  Height:  |  Size: 411 B

View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M93.824 44.383 80.058 61.379a6.3 6.3 0 0 0-1.398 4.3l1.144 21.84c.2 3.802-3.578 6.548-7.133 5.18l-20.418-7.84a6.3 6.3 0 0 0-4.52 0L27.317 92.7c-3.551 1.364-7.332-1.382-7.133-5.18l1.145-21.84a6.3 6.3 0 0 0-1.399-4.3L6.163 44.383C3.77 41.426 5.21 36.985 8.886 36l21.125-5.66a6.3 6.3 0 0 0 3.656-2.656L45.577 9.34c2.07-3.192 6.742-3.192 8.816 0l11.91 18.344a6.3 6.3 0 0 0 3.657 2.656L91.085 36c3.675.985 5.128 5.43 2.734 8.387z" style="stroke-width: 8px; stroke: black; fill: transparent;"/></svg>

After

Width:  |  Height:  |  Size: 565 B

1
assets/icons/star.svg Normal file
View file

@ -0,0 +1 @@
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="M93.824 44.383 80.058 61.379a6.3 6.3 0 0 0-1.398 4.3l1.144 21.84c.2 3.802-3.578 6.548-7.133 5.18l-20.418-7.84a6.3 6.3 0 0 0-4.52 0L27.317 92.7c-3.551 1.364-7.332-1.382-7.133-5.18l1.145-21.84a6.3 6.3 0 0 0-1.399-4.3L6.163 44.383C3.77 41.426 5.21 36.985 8.886 36l21.125-5.66a6.3 6.3 0 0 0 3.656-2.656L45.577 9.34c2.07-3.192 6.742-3.192 8.816 0l11.91 18.344a6.3 6.3 0 0 0 3.657 2.656L91.085 36c3.675.985 5.128 5.43 2.734 8.387z"/></svg>

After

Width:  |  Height:  |  Size: 504 B

BIN
assets/turn-marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

14
augments.d.ts vendored Normal file
View file

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

View file

@ -4,7 +4,7 @@ import stylistic from "@stylistic/eslint-plugin";
export default [
// Tell eslint to ignore files that I don't mind being formatted slightly differently
{ ignores: [ `scripts/` ] },
{ ignores: [ `scripts/`, `foundry/` ] },
{
languageOptions: {
globals: globals.browser,
@ -16,14 +16,13 @@ export default [
languageOptions: {
globals: {
CONFIG: `writable`,
CONST: `readonly`,
game: `readonly`,
Handlebars: `readonly`,
Hooks: `readonly`,
ui: `readonly`,
Actor: `readonly`,
Actors: `readonly`,
Item: `readonly`,
Items: `readonly`,
foundry: `readonly`,
ChatMessage: `readonly`,
ActiveEffect: `readonly`,
@ -31,6 +30,11 @@ export default [
renderTemplate: `readonly`,
TextEditor: `readonly`,
fromUuid: `readonly`,
Combat: `readonly`,
Combatant: `readonly`,
canvas: `readonly`,
Token: `readonly`,
Tour: `readonly`,
},
},
},
@ -71,7 +75,7 @@ export default [
"@stylistic/eol-last": `warn`,
"@stylistic/operator-linebreak": [`warn`, `before`],
"@stylistic/indent": [`warn`, `tab`],
"@stylistic/brace-style": [`warn`, `1tbs`, { "allowSingleLine": true }],
"@stylistic/brace-style": [`warn`, `stroustrup`, { "allowSingleLine": 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-style": [`warn`, `last`],

View file

@ -1,11 +1,19 @@
{
"compilerOptions": {
"module": "ES2020",
"target": "ES2020"
},
"exclude": ["node_modules", "**/node_modules/*"],
"include": ["module/**/*", "foundry.v13.link/client/**/*.js", "foundry.v13.link/**/*.mjs"],
"typeAcquisition": {
"include": ["jquery"]
"module": "es2022",
"target": "es2022",
"types": [
"./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"
]
}

View file

@ -1,12 +1,14 @@
{
"TYPES": {
"Actor": {
"hero": "Hero"
"hero": "Hero",
"geist": "Geist"
},
"Item": {
"ammo": "Ammo",
"armour": "Armour",
"craft": "Craft",
"good": "Good",
"shield": "Shield",
"skill": "Skill",
"weapon": "Weapon"
@ -15,9 +17,14 @@
"RipCrypt": {
"sheet-names": {
"AllItemsSheetV1": "RipCrypt Item Sheet",
"ArmourSheet": "Armour Sheet",
"CombinedHeroSheet": "Hero Sheet",
"HeroSummaryCardV1": "Hero Stat Card",
"HeroSkillsCardV1": "Hero Skill Card"
"StatsCardV1": "Hero Stat Card",
"CraftCardV1": "Hero Craft Card",
"SkillsCardV1": "Hero Skill Card"
},
"app-titles": {
"AmmoTracker": "Ammo Tracker"
},
"common": {
"abilities": {
@ -28,7 +35,8 @@
"thin-glim": "Thin Glim"
},
"ability": "Ability",
"access": {
"access": "Access",
"accessLevels": {
"Common": "Common",
"Uncommon": "Uncommon",
"Rare": "Rare",
@ -49,6 +57,8 @@
"fract": "Fract",
"focus": "Focus"
},
"aura": "Aura",
"cost": "Cost",
"currency": {
"gold": "Gold",
"silver": "Silver",
@ -57,26 +67,44 @@
"damage": "Damage",
"delete": "Delete",
"description": "Description",
"details": "Details",
"difficulties": {
"easy": "Easy",
"normal": "Normal",
"tough": "Tough",
"hard": "Hard"
"hard": "Hard",
"random": "Random Condition"
},
"difficulty": "Difficulty",
"drag": "Drag",
"edit": "Edit",
"edge": "Edge",
"empty": "---",
"equipped": "Equipped",
"fate": "Fate",
"gear": "Gear",
"glimcraft": "Glimcraft",
"glory": "Glory",
"guts": "Guts",
"location": "Location",
"move": "Move",
"path": {
"North": "North",
"East": "East",
"South": "South",
"West": "West"
"ordinals": {
"North": {
"full": "North",
"abbv": "N"
},
"East": {
"full": "East",
"abbv": "E"
},
"South": {
"full": "South",
"abbv": "S"
},
"West": {
"full": "West",
"abbv": "W"
}
},
"protection": "Protection",
"quantity": "Quantity",
@ -97,7 +125,13 @@
"singular": "Weapon",
"plural": "Weapons"
},
"wear": "Wear"
"wear": "Wear",
"weightRating": "Weight",
"weightRatings": {
"light": "Light",
"modest": "Modest",
"heavy": "Heavy"
}
},
"setting": {
"abbrAccess": {
@ -107,11 +141,29 @@
"condensedRange": {
"name": "Condense Weapon Range Input",
"hint": "With this enabled, the weapon range will be displayed as \"X / Y\" when editing a weapon. While disabled it will be as displayed as two different rows, one for Short Range and one for Long Range"
},
"sandsOfFateInitial": {
"name": "Sands of Fate Initial",
"hint": "What value should The Hourglass reset to when a Cryptic Event occurs"
},
"onCrypticEvent": {
"name": "Cryptic Event Alert",
"hint": "What happens when a cryptic event occurs by clicking the \"Next Delve Tour\" button in the HUD",
"options": {
"notif": "Notification",
"pause": "Pause Game",
"both": "Notification and Pause Game",
"nothing": "Do Nothing"
}
},
"allowUpdateSandsSocket": {
"name": "Player Haste Updates the Sands of Fate",
"hint": "This setting determines if when a player makes a haste check that the result will automatically be applied to the global Sands of Fate. Disabling this is good if you want to let players roll without needing to worry about automation messing anything up while they spam rolls."
}
},
"Apps": {
"move-run": "@RipCrypt.common.move • @RipCrypt.common.run",
"traits-range": "@RipCrypt.common.traits • @RipCrypt.common.range",
"damage-reduction": "@RipCrypt.common.damage reduction",
"traits-range": "@RipCrypt.common.traits & @RipCrypt.common.range",
"grit-skills": "@RipCrypt.common.abilities.grit Skills",
"gait-skills": "@RipCrypt.common.abilities.gait Skills",
"grip-skills": "@RipCrypt.common.abilities.grip Skills",
@ -130,19 +182,48 @@
"numberOfDice": "# of Dice",
"rollTarget": "Target",
"difficulty": "(DC: {dc})",
"RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost."
"RichEditor-no-collaborative": "Warning: This editor is not collaborative, that means that if you and someone else are editing it at the same time, you won't see that someone else is making changes until they save, and then your changes will be lost.",
"starred-ammo-placeholder": "Starred Ammo Slot",
"AmmoTracker": {
"no-ammo": "You don't have any ammo!",
"star-button": "Add {name} as a starred ammo",
"star-button-tooltip": "Add Star",
"unstar-button": "Remove {name} as a starred ammo",
"unstar-button-tooltip": "Remove Star"
},
"protects-the-location": "Protects your {part}"
},
"notifs": {
"error": {
"cannot-equip-not-embedded": "Cannot equip an {itemType} when it isn't within an Actor",
"invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action."
"cannot-equip": "Cannot equip the {itemType}, see console for more details.",
"invalid-delta": "The delta for \"{name}\" is not a number, cannot finish processing the action.",
"at-favourite-limit": "Cannot favourite more than three items, unfavourite one to make space.",
"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}",
"no-active-gm": "No active @USER.GM is logged in, you must wait for a @USER.GM to be active before you can do that.",
"malformed-socket-payload": "Socket event \"{event}\" received with malformed payload. Details: {details}"
},
"warn": {
"cannot-go-negative": "\"{name}\" is unable to be a negative number."
},
"info": {
"cryptic-event-alert": "A Cryptic Event Has Occured!"
}
},
"tooltips": {
"shield-bonus": "Shield Bonus: {value}"
"shield-bonus": "Shield Bonus: {value}",
"set-fate-to": "Set Fate to {ordinal}",
"current-tour": "Current Delve Tour",
"create-new-item": "Create new item",
"next-tour": "Next Delve Tour",
"prev-tour": "Previous Delve Tour",
"auras": {
"normal": "The distance of your aura normally",
"heavy": "The distance of your aura when using Heavycraft"
}
}
},
"USER": {
"GM": "Keeper"
}
}

View file

@ -1,9 +1,8 @@
import { CraftCardV1 } from "./CraftCardV1.mjs";
import { filePath } from "../../consts.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { HeroCraftCardV1 } from "./HeroCraftCardV1.mjs";
import { HeroSkillsCardV1 } from "./HeroSkillsCardV1.mjs";
import { HeroSummaryCardV1 } from "./HeroSummaryCardV1.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { SkillsCardV1 } from "./SkillsCardV1.mjs";
import { StatsCardV1 } from "./StatsCardV1.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
@ -23,7 +22,9 @@ export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixi
window: {
resizable: false,
},
actions: {},
actions: {
...StatsCardV1.DEFAULT_OPTIONS.actions,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
@ -32,10 +33,10 @@ export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixi
static PARTS = {
summary: {
template: filePath(`templates/Apps/HeroSummaryCardV1/content.hbs`),
template: filePath(`templates/Apps/StatsCardV1/content.hbs`),
},
skills: {
template: filePath(`templates/Apps/HeroSkillsCardV1/content.hbs`),
template: filePath(`templates/Apps/SkillsCardV1/content.hbs`),
},
craft: {
template: filePath(`templates/Apps/CombinedHeroSheet/crafts.hbs`),
@ -47,8 +48,8 @@ export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixi
async _onRender(context, options) {
await super._onRender(context, options);
const summaryElement = this.element.querySelector(`.HeroSummaryCardV1`);
HeroSummaryCardV1._onRender(
const summaryElement = this.element.querySelector(`.StatsCardV1`);
StatsCardV1._onRender(
context,
{
...options,
@ -57,8 +58,9 @@ export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixi
},
);
const skillsElement = this.element.querySelector(`.HeroSkillsCardV1`);
HeroSkillsCardV1._onRender.bind(this)(
const skillsElement = this.element.querySelector(`.SkillsCardV1`);
SkillsCardV1._createPopoverListeners.bind(this)();
SkillsCardV1._onRender.bind(this)(
context,
{
...options,
@ -68,7 +70,7 @@ export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixi
);
const craftsElement = this.element.querySelector(`.crafts-summary`);
HeroCraftCardV1._onRender.bind(this)(
CraftCardV1._onRender.bind(this)(
context,
{
...options,
@ -84,28 +86,27 @@ export class CombinedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixi
switch (partId) {
case `summary`: {
ctx = await HeroSummaryCardV1.prepareGuts(ctx);
ctx = await HeroSummaryCardV1.prepareWeapons(ctx);
ctx = await HeroSummaryCardV1.prepareArmor(ctx);
ctx = await HeroSummaryCardV1.prepareFatePath(ctx);
ctx = await HeroSummaryCardV1.prepareAbilityRow(ctx);
ctx = await HeroSummaryCardV1.prepareSpeed(ctx);
ctx = await HeroSummaryCardV1.prepareLevelData(ctx);
ctx = await StatsCardV1.prepareGuts(ctx);
ctx = await StatsCardV1.prepareWeapons(ctx);
ctx = await StatsCardV1.prepareArmor(ctx);
ctx = await StatsCardV1.prepareFatePath(ctx);
ctx = await StatsCardV1.prepareAbilityRow(ctx);
ctx = await StatsCardV1.prepareSpeed(ctx);
ctx = await StatsCardV1.prepareLevelData(ctx);
break;
};
case `skills`: {
ctx = await HeroSkillsCardV1.prepareGear(ctx);
ctx = await HeroSkillsCardV1.prepareAmmo(ctx);
ctx = await HeroSkillsCardV1.prepareSkills(ctx);
ctx = await SkillsCardV1.prepareGear(ctx);
ctx = await SkillsCardV1.prepareAmmo(ctx);
ctx = await SkillsCardV1.prepareSkills(ctx);
break;
};
case `craft`: {
ctx = await HeroCraftCardV1.prepareCraft(ctx);
ctx = await CraftCardV1.prepareCraft(ctx);
break;
};
};
Logger.debug(`Context keys:`, Object.keys(ctx));
return ctx;
};
// #endregion

View file

@ -7,15 +7,16 @@ import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { ContextMenu } = foundry.applications.ui;
const { ContextMenu } = foundry.applications.ux;
const { deepClone } = foundry.utils;
export class HeroCraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
export class CraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--HeroCraftCardV1`,
`ripcrypt--CraftCardV1`,
],
position: {
width: `auto`,
@ -34,7 +35,7 @@ export class HeroCraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(
static PARTS = {
content: {
template: filePath(`templates/Apps/HeroCraftCardV1/content.hbs`),
template: filePath(`templates/Apps/CraftCardV1/content.hbs`),
},
};
// #endregion
@ -42,7 +43,7 @@ export class HeroCraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
HeroCraftCardV1._onRender.bind(this)(context, options);
CraftCardV1._onRender.bind(this)(context, options);
};
static async _onRender(_context, options) {
@ -79,12 +80,18 @@ export class HeroCraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx = await HeroCraftCardV1.prepareCraft(ctx);
ctx = await CraftCardV1.prepareAura(ctx);
ctx = await CraftCardV1.prepareCraft(ctx);
Logger.debug(`Context:`, ctx);
return ctx;
};
static async prepareAura(ctx) {
ctx.aura = deepClone(ctx.actor.system.aura);
return ctx;
};
static async prepareCraft(ctx) {
ctx.craft = {};
const aspects = Object.values(gameTerms.Aspects);
@ -108,7 +115,8 @@ export class HeroCraftCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(
const length = crafts.length;
if (length >= limit) {
crafts = crafts.slice(0, limit);
} else {
}
else {
crafts = crafts
.concat(Array(limit - length).fill(null))
.slice(0, limit);

View file

@ -1,21 +1,25 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { documentSorter, filePath } from "../../consts.mjs";
import { AmmoTracker } from "../popovers/AmmoTracker.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { PopoverEventManager } from "../../utils/PopoverEventManager.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { ContextMenu } = foundry.applications.ui;
const { ContextMenu } = foundry.applications.ux;
const { deepClone } = foundry.utils;
export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
export class SkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--HeroSkillsCardV1`,
`ripcrypt--SkillsCardV1`,
],
position: {
width: `auto`,
@ -34,7 +38,7 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
static PARTS = {
content: {
template: filePath(`templates/Apps/HeroSkillsCardV1/content.hbs`),
template: filePath(`templates/Apps/SkillsCardV1/content.hbs`),
},
};
// #endregion
@ -42,7 +46,8 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
HeroSkillsCardV1._onRender.bind(this)(context, options);
SkillsCardV1._onRender.bind(this)(context, options);
SkillsCardV1._createPopoverListeners.bind(this)();
};
static async _onRender(_context, options) {
@ -75,13 +80,27 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
);
};
/** @this {SkillsCardV1} */
static async _createPopoverListeners() {
const ammoInfoIcon = this.element.querySelector(`.ammo-info-icon`);
const idPrefix = this.actor.uuid;
const manager = new PopoverEventManager(`${idPrefix}.ammo-info-icon`, ammoInfoIcon, AmmoTracker);
this._popoverManagers.set(`.ammo-info-icon`, manager);
this._hookIDs.set(Hooks.on(`prepare${manager.id}Context`, (ctx) => {
ctx.ammos = this.actor.itemTypes.ammo;
}), `prepare${manager.id}Context`);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx = await HeroSkillsCardV1.prepareGear(ctx);
ctx = await HeroSkillsCardV1.prepareAmmo(ctx);
ctx = await HeroSkillsCardV1.prepareSkills(ctx);
ctx = await SkillsCardV1.prepareGear(ctx);
ctx = await SkillsCardV1.prepareAmmo(ctx);
ctx = await SkillsCardV1.prepareSkills(ctx);
ctx.aura = deepClone(ctx.actor.system.aura);
Logger.debug(`Context:`, ctx);
return ctx;
@ -106,7 +125,7 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
};
if (ctx.gear.length < limit) {
for (let i = ctx.gear.length - 1; i <= limit; i++) {
for (let i = ctx.gear.length; i < limit; i++) {
ctx.gear.push({
index: ctx.gear.length,
uuid: ``,
@ -120,7 +139,24 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
};
static async prepareAmmo(ctx) {
ctx.ammo = 0;
let total = 0;
let favouriteCount = 0;
ctx.favouriteAmmo = new Array(3).fill(null);
for (const ammo of ctx.actor.itemTypes.ammo) {
total += ammo.system.quantity;
if (favouriteCount < 3 && ammo.getFlag(game.system.id, ItemFlags.FAVOURITE)) {
ctx.favouriteAmmo[favouriteCount] = {
uuid: ammo.uuid,
name: ammo.name,
quantity: ammo.system.quantity,
};
favouriteCount++;
};
};
ctx.ammo = total;
return ctx;
};
@ -158,7 +194,8 @@ export class HeroSkillsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin
const length = ctx.skills[ability].length;
if (length >= limit) {
ctx.skills[ability] = ctx.skills[ability].slice(0, limit);
} else {
}
else {
ctx.skills[ability] = ctx.skills[ability]
.concat(Array(limit - length).fill(null))
.slice(0, limit);

View file

@ -1,4 +1,5 @@
import { deleteItemFromElement, editItemFromElement } from "../utils.mjs";
import { DelveDiceHUD } from "../DelveDiceHUD.mjs";
import { filePath } from "../../consts.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
@ -7,15 +8,15 @@ import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
const { ContextMenu } = foundry.applications.ui;
const { ContextMenu } = foundry.applications.ux;
export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
export class StatsCardV1 extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--HeroSummaryCardV1`,
`ripcrypt--StatsCardV1`,
],
position: {
width: `auto`,
@ -25,6 +26,7 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi
resizable: false,
},
actions: {
rollForHaste: DelveDiceHUD.rollForHaste,
},
form: {
submitOnChange: true,
@ -34,7 +36,7 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi
static PARTS = {
content: {
template: filePath(`templates/Apps/HeroSummaryCardV1/content.hbs`),
template: filePath(`templates/Apps/StatsCardV1/content.hbs`),
},
};
// #endregion
@ -42,7 +44,7 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
HeroSummaryCardV1._onRender.bind(this)(context, options);
StatsCardV1._onRender.bind(this)(context, options);
};
static async _onRender(context, options) {
@ -79,13 +81,13 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx = await HeroSummaryCardV1.prepareGuts(ctx);
ctx = await HeroSummaryCardV1.prepareWeapons(ctx);
ctx = await HeroSummaryCardV1.prepareArmor(ctx);
ctx = await HeroSummaryCardV1.prepareFatePath(ctx);
ctx = await HeroSummaryCardV1.prepareAbilityRow(ctx);
ctx = await HeroSummaryCardV1.prepareSpeed(ctx);
ctx = await HeroSummaryCardV1.prepareLevelData(ctx);
ctx = await StatsCardV1.prepareGuts(ctx);
ctx = await StatsCardV1.prepareWeapons(ctx);
ctx = await StatsCardV1.prepareArmor(ctx);
ctx = await StatsCardV1.prepareFatePath(ctx);
ctx = await StatsCardV1.prepareAbilityRow(ctx);
ctx = await StatsCardV1.prepareSpeed(ctx);
ctx = await StatsCardV1.prepareLevelData(ctx);
Logger.debug(`Context:`, ctx);
return ctx;
@ -116,8 +118,8 @@ export class HeroSummaryCardV1 extends GenericAppMixin(HandlebarsApplicationMixi
ctx.fate.selected = ctx.actor.system.fate;
ctx.fate.options = [
{ label: `RipCrypt.common.empty`, v: `` },
...gameTerms.FatePath
.map(v => ({ label: `RipCrypt.common.path.${v}`, value: v })),
...Object.values(gameTerms.FatePath)
.map(v => ({ label: `RipCrypt.common.ordinals.${v}.full`, value: v })),
];
return ctx;
};

View file

@ -1,137 +0,0 @@
import { filePath } from "../../consts.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
import { HeroCraftCardV1 } from "./HeroCraftCardV1.mjs";
import { HeroSkillsCardV1 } from "./HeroSkillsCardV1.mjs";
import { HeroSummaryCardV1 } from "./HeroSummaryCardV1.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ActorSheetV2 } = foundry.applications.sheets;
export class TabbedHeroSheet extends GenericAppMixin(HandlebarsApplicationMixin(ActorSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--actor`,
`ripcrypt--TabbedHeroSheet`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
actions: {},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
nav: {
template: filePath(`templates/Apps/TabbedHeroSheet/tabs.hbs`),
},
summary: {
template: filePath(`templates/Apps/HeroSummaryCardV1/content.hbs`),
},
skills: {
template: filePath(`templates/Apps/HeroSkillsCardV1/content.hbs`),
},
};
// #endregion
// #region Instance Data
#tabs = {
root: `HeroSummaryCardV1`,
};
// #endregion
// #region Lifecycle
async _onRender(context, options) {
await super._onRender(context, options);
const summaryElement = this.element.querySelector(`.HeroSummaryCardV1`);
HeroSummaryCardV1._onRender(
context,
{
...options,
element: summaryElement,
isEditable: this.isEditable,
},
);
const skillsElement = this.element.querySelector(`.HeroSkillsCardV1`);
HeroSkillsCardV1._onRender.bind(this)(
context,
{
...options,
element: skillsElement,
isEditable: this.isEditable,
},
);
const craftsElement = this.element.querySelector(`.crafts-summary`);
HeroCraftCardV1._onRender.bind(this)(
context,
{
...options,
element: craftsElement,
isEditable: this.isEditable,
},
);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.actor = this.document;
ctx.classes = {
tab: true,
visible: false,
};
ctx.attrs = {};
let tabName;
switch (partId) {
case `summary`: {
tabName = `HeroSummaryCardV1`;
ctx = await HeroSummaryCardV1.prepareGuts(ctx);
ctx = await HeroSummaryCardV1.prepareWeapons(ctx);
ctx = await HeroSummaryCardV1.prepareArmor(ctx);
ctx = await HeroSummaryCardV1.prepareFatePath(ctx);
ctx = await HeroSummaryCardV1.prepareAbilityRow(ctx);
ctx = await HeroSummaryCardV1.prepareSpeed(ctx);
ctx = await HeroSummaryCardV1.prepareLevelData(ctx);
break;
};
case `skills`: {
tabName = `HeroSkillsCardV1`;
ctx = await HeroSkillsCardV1.prepareGear(ctx);
ctx = await HeroSkillsCardV1.prepareAmmo(ctx);
ctx = await HeroSkillsCardV1.prepareSkills(ctx);
break;
};
case `craft`: {
tabName = `HeroCraftCardV1`;
ctx = await HeroCraftCardV1.prepareCraft(ctx);
break;
};
};
if (tabName) {
ctx.attrs[`data-tab`] = tabName;
ctx.attrs[`data-group`] = `root`;
ctx.classes.visible = this.#tabs.root === tabName;
};
Logger.debug(`Context keys:`, Object.keys(ctx));
return ctx;
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,305 @@
import { distanceBetweenFates, nextFate, previousFate } from "../utils/fates.mjs";
import { filePath } from "../consts.mjs";
import { gameTerms } from "../gameTerms.mjs";
import { localizer } from "../utils/Localizer.mjs";
import { Logger } from "../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const { ContextMenu } = foundry.applications.ux;
const { Roll } = foundry.dice;
const { FatePath } = gameTerms;
const CompassRotations = {
[FatePath.NORTH]: -90,
[FatePath.EAST]: 0,
[FatePath.SOUTH]: 90,
[FatePath.WEST]: 180,
};
const conditions = [
{ label: `RipCrypt.common.difficulties.easy`, value: 4 },
{ label: `RipCrypt.common.difficulties.normal`, value: 5 },
{ label: `RipCrypt.common.difficulties.tough`, value: 6 },
{ label: `RipCrypt.common.difficulties.hard`, value: 7 },
];
export class DelveDiceHUD extends HandlebarsApplicationMixin(ApplicationV2) {
// #region Options
static DEFAULT_OPTIONS = {
id: `ripcrypt-delve-dice`,
tag: `aside`,
classes: [
`ripcrypt`,
`ripcrypt--DelveDiceHUD`,
`hud`,
],
window: {
frame: false,
positioned: false,
},
actions: {
tourDelta: this.#tourDelta,
setFate: this.#setFate,
},
};
static PARTS = {
previousTour: {
template: filePath(`templates/Apps/DelveDiceHUD/tour/previous.hbs`),
},
difficulty: {
template: filePath(`templates/Apps/DelveDiceHUD/difficulty.hbs`),
},
fateCompass: {
template: filePath(`templates/Apps/DelveDiceHUD/fateCompass.hbs`),
},
sandsOfFate: {
template: filePath(`templates/Apps/DelveDiceHUD/tour/current.hbs`),
},
nextTour: {
template: filePath(`templates/Apps/DelveDiceHUD/tour/next.hbs`),
},
};
// #endregion
// #region Instance Data
/**
* The current number of degrees the compass pointer should be rotated, this
* is not stored in the DB since we only care about the initial rotation on
* reload, which is derived from the current fate.
* @type {Number}
*/
_rotation;
constructor(...args) {
super(...args);
this._sandsOfFate = game.settings.get(`ripcrypt`, `sandsOfFate`);
this._currentFate = game.settings.get(`ripcrypt`, `currentFate`);
this._rotation = CompassRotations[this._currentFate];
this._difficulty = game.settings.get(`ripcrypt`, `dc`);
};
// #endregion
// #region Lifecycle
/**
* Injects the element into the Foundry UI in the top middle
*/
_insertElement(element) {
const existing = document.getElementById(element.id);
if (existing) {
existing.replaceWith(element);
}
else {
const parent = document.getElementById(`ui-top`);
parent.prepend(element);
};
};
async _onRender(context, options) {
await super._onRender(context, options);
// Shortcut because users can't edit
if (!game.user.isGM) { return };
new ContextMenu(
this.element,
`#delve-difficulty`,
[
...conditions.map(condition => ({
name: localizer(condition.label),
callback: DelveDiceHUD.#setDifficulty.bind(this, condition.value),
})),
{
name: localizer(`RipCrypt.common.difficulties.random`),
callback: () => {
const condition = conditions[Math.floor(Math.random() * conditions.length)];
DelveDiceHUD.#setDifficulty.bind(this)(condition.value);
},
},
],
{ jQuery: false, fixed: true },
);
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.meta ??= {};
ctx.meta.editable = game.user.isGM;
switch (partId) {
case `sandsOfFate`: {
ctx.sandsOfFate = this._sandsOfFate;
break;
};
case `difficulty`: {
ctx.dc = this._difficulty;
break;
};
case `fateCompass`: {
ctx.fate = this._currentFate;
ctx.rotation = `${this._rotation}deg`;
break;
};
};
Logger.log(`${partId} Context`, ctx);
return ctx;
};
async animate({ parts = [] } = {}) {
if (parts.includes(`fateCompass`)) {
this.#animateCompassTo();
};
if (parts.includes(`sandsOfFate`)) {
this.#animateSandsTo();
};
};
#animateCompassTo(newFate) {
if (newFate === this._currentFate) { return };
/** @type {HTMLElement|undefined} */
const pointer = this.element.querySelector(`.compass-pointer`);
if (!pointer) { return };
newFate ??= game.settings.get(`ripcrypt`, `currentFate`);
let distance = distanceBetweenFates(this._currentFate, newFate);
if (distance === 3) { distance = -1 };
this._rotation += distance * 90;
pointer.style.setProperty(`transform`, `rotate(${this._rotation}deg)`);
this._currentFate = newFate;
};
#animateSandsTo(newSands) {
/** @type {HTMLElement|undefined} */
const sands = this.element.querySelector(`.sands-value`);
if (!sands) { return };
newSands ??= game.settings.get(`ripcrypt`, `sandsOfFate`);
sands.innerHTML = newSands;
this._sandsOfFate = newSands;
};
// #endregion
// #region Actions
/** @this {DelveDiceHUD} */
static async #tourDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
await this.sandsOfFateDelta(delta);
switch (Math.sign(delta)) {
case -1: {
game.settings.set(`ripcrypt`, `currentFate`, nextFate(this._currentFate));
break;
}
case 1: {
game.settings.set(`ripcrypt`, `currentFate`, previousFate(this._currentFate));
break;
}
};
};
/** @this {DelveDiceHUD} */
static async #setFate(_event, element) {
const fate = element.dataset.toFate;
this.#animateCompassTo(fate);
game.settings.set(`ripcrypt`, `currentFate`, fate);
};
/** @this {DelveDiceHUD} */
static async #setDifficulty(value) {
this._difficulty = value;
game.settings.set(`ripcrypt`, `dc`, value);
};
// #endregion
// #region Public API
async alertCrypticEvent() {
const alertType = game.settings.get(`ripcrypt`, `onCrypticEvent`);
if (alertType === `nothing`) { return };
if ([`both`, `notif`].includes(alertType)) {
ui.notifications.info(
localizer(`RipCrypt.notifs.info.cryptic-event-alert`),
{ console: false },
);
game.socket.emit(`system.ripcrypt`, {
event: `notify`,
payload: {
message: `RipCrypt.notifs.info.cryptic-event-alert`,
type: `info`,
},
});
};
if ([`both`, `pause`].includes(alertType) && game.user.isGM) {
game.togglePause(true, { broadcast: true });
};
};
/**
* Changes the current Sands of Fate by an amount provided, animating the
* @param {number} delta The amount of change
*/
async sandsOfFateDelta(delta) {
const initial = game.settings.get(`ripcrypt`, `sandsOfFateInitial`);
let newSands = this._sandsOfFate + delta;
if (newSands > initial) {
Logger.info(`Cannot increase the Sands of Fate to a value about the initial`);
return;
};
if (newSands === 0) {
newSands = initial;
await this.alertCrypticEvent();
};
this.#animateSandsTo(newSands);
game.settings.set(`ripcrypt`, `sandsOfFate`, newSands);
};
/**
* A helper method that rolls the dice required for hasty turns while delving
* and adjusts the Sands of Fate accordingly
*/
static async rollForHaste() {
const shouldUpdateSands = game.settings.get(`ripcrypt`, `allowUpdateSandsSocket`);
if (shouldUpdateSands && game.users.activeGM == null) {
ui.notifications.error(localizer(`RipCrypt.notifs.error.no-active-gm`));
return;
};
const roll = new Roll(`1d8xo=1`);
await roll.evaluate();
let delta = 0;
if (roll.dice[0].results[0].exploded) {
delta = -1;
if (roll.dice[0].results[1].result === 1) {
delta = -2;
};
};
roll.toMessage({ flavor: `Haste Check` });
// Change the Sands of Fate setting if required
if (delta === 0 || !shouldUpdateSands) { return };
if (game.user.isActiveGM) {
ui.delveDice.sandsOfFateDelta(delta);
}
else {
game.socket.emit(`system.ripcrypt`, {
event: `updateSands`,
payload: { delta },
});
};
};
// #endregion
};

View file

@ -1,104 +0,0 @@
import { filePath } from "../consts.mjs";
import { GenericAppMixin } from "./GenericApp.mjs";
import { Logger } from "../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
const conditions = [
{ label: `RipCrypt.common.difficulties.easy`, value: 4 },
{ label: `RipCrypt.common.difficulties.normal`, value: 5 },
{ label: `RipCrypt.common.difficulties.tough`, value: 6 },
{ label: `RipCrypt.common.difficulties.hard`, value: 7 },
];
export class DelveTourApp extends GenericAppMixin(HandlebarsApplicationMixin(ApplicationV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--CryptApp`,
],
window: {
title: `Delve Tour`,
frame: true,
positioned: true,
resizable: false,
minimizable: false,
},
position: {
width: `auto`,
},
actions: {
randomCondition: this.#randomCondition,
},
};
static PARTS = {
turnCount: {
template: filePath(`templates/Apps/CryptApp/turnCount.hbs`),
},
delveConditions: {
template: filePath(`templates/Apps/CryptApp/delveConditions.hbs`),
},
fate: {
template: filePath(`templates/Apps/CryptApp/fate.hbs`),
},
};
// #endregion
// #region Lifecycle
async _renderFrame(options) {
const frame = await super._renderFrame(options);
this.window.close.remove(); // Prevent closing
return frame;
};
async _onRender(context, options) {
await super._onRender(context, options);
// Shortcut because users can't edit
if (!game.user.isGM) { return };
// Add event listener for the dropdown
if (options.parts.includes(`delveConditions`)) {
const select = this.element.querySelector(`#${this.id}-difficulty`);
select.addEventListener(`change`, async (ev) => {
const newDifficulty = parseInt(ev.target.value);
if (!Number.isNaN(newDifficulty)) {
await game.settings.set(`ripcrypt`, `dc`, newDifficulty);
};
this.render({ parts: [`delveConditions`] });
});
};
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
ctx.meta.editable = game.user.isGM;
switch (partId) {
case `delveConditions`: {
ctx = this._prepareDifficulty(ctx);
break;
};
};
Logger.log(`${partId} Context`, ctx);
return ctx;
};
_prepareDifficulty(ctx) {
ctx.options = conditions;
ctx.difficulty = game.settings.get(`ripcrypt`, `dc`);
return ctx;
};
// #endregion
// #region Actions
static async #randomCondition() {
const dc = conditions[Math.floor(Math.random() * conditions.length)];
await game.settings.set(`ripcrypt`, `dc`, dc.value);
await this.render({ parts: [`delveConditions`] });
};
// #endregion
};

View file

@ -26,6 +26,8 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
actions: {
diceCountDelta: this.#diceCountDelta,
targetDelta: this.#targetDelta,
edgeDelta: this.#edgeDelta,
dragDelta: this.#dragDelta,
roll: this.#roll,
},
};
@ -37,6 +39,12 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
target: {
template: filePath(`templates/Apps/DicePool/target.hbs`),
},
drag: {
template: filePath(`templates/Apps/DicePool/drag.hbs`),
},
edge: {
template: filePath(`templates/Apps/DicePool/edge.hbs`),
},
buttons: {
template: filePath(`templates/Apps/DicePool/buttons.hbs`),
},
@ -46,15 +54,20 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
// #region Instance Data
_diceCount;
_target;
_drag;
_edge;
constructor({
diceCount = 1,
target,
drag = 0, edge = 0,
flavor = ``,
...opts
} = {}) {
super(opts);
this._drag = drag;
this._edge = edge;
this._flavor = flavor;
this._diceCount = diceCount;
this._target = target ?? game.settings.get(`ripcrypt`, `dc`) ?? 1;
@ -74,11 +87,19 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
switch (partId) {
case `numberOfDice`: {
this._prepareNumberOfDice(ctx);
await this._prepareNumberOfDice(ctx);
break;
};
case `target`: {
this._prepareTarget(ctx);
await this._prepareTarget(ctx);
break;
};
case `edge`: {
await this._prepareEdge(ctx);
break;
};
case `drag`: {
await this._prepareDrag(ctx);
break;
};
case `buttons`: {
@ -92,7 +113,7 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
async _prepareNumberOfDice(ctx) {
ctx.numberOfDice = this._diceCount;
ctx.decrementDisabled = this._diceCount <= 0;
ctx.decrementDisabled = this._diceCount <= 1;
};
async _prepareTarget(ctx) {
@ -100,6 +121,18 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
ctx.incrementDisabled = this._target >= 8;
ctx.decrementDisabled = this._target <= 1;
};
async _prepareEdge(ctx) {
ctx.edge = this._edge;
ctx.incrementDisabled = false;
ctx.decrementDisabled = this._edge <= 0;
};
async _prepareDrag(ctx) {
ctx.drag = this._drag;
ctx.incrementDisabled = false;
ctx.decrementDisabled = this._drag <= 0;
};
// #endregion
// #region Actions
@ -137,8 +170,39 @@ export class DicePool extends GenericAppMixin(HandlebarsApplicationMixin(Applica
this.render({ parts: [`target`] });
};
static async #edgeDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
if (Number.isNaN(delta)) {
ui.notifications.error(
localizer(`RipCrypt.notifs.error.invalid-delta`, { name: `@RipCrypt.common.edge` }),
);
return;
};
this._edge += delta;
this.render({ parts: [`edge`] });
};
static async #dragDelta(_event, element) {
const delta = parseInt(element.dataset.delta);
if (Number.isNaN(delta)) {
ui.notifications.error(
localizer(`RipCrypt.notifs.error.invalid-delta`, { name: `@RipCrypt.common.drag` }),
);
return;
};
this._drag += delta;
this.render({ parts: [`drag`] });
};
static async #roll() {
const formula = `${this._diceCount}d8rc${this._target}`;
let target = this._target;
target -= this._edge;
target += this._drag;
target = Math.max(target, 1);
const formula = `${this._diceCount}d8rc${target}`;
Logger.debug(`Attempting to roll formula: ${formula}`);
let flavor = this._flavor;

View file

@ -1,10 +1,11 @@
import { deleteItemFromElement, editItemFromElement } from "./utils.mjs";
import { createItemFromElement, deleteItemFromElement, editItemFromElement, updateForeignDocumentFromEvent } from "./utils.mjs";
import { DicePool } from "./DicePool.mjs";
import { RichEditor } from "./RichEditor.mjs";
import { toBoolean } from "../consts.mjs";
/**
* A mixin that takes the class from HandlebarsApplicationMixin and
* A mixin that takes the class from HandlebarsApplicationMixin and combines it
* with utility functions / data that is used across all RipCrypt applications
*/
export function GenericAppMixin(HandlebarsApp) {
class GenericRipCryptApp extends HandlebarsApp {
@ -16,6 +17,10 @@ export function GenericAppMixin(HandlebarsApp) {
],
actions: {
roll: this.#rollDice,
createItem(_event, target) { // uses arrow-less function for "this"
const parent = this.document;
createItemFromElement(target, { parent });
},
editItem: (_event, target) => editItemFromElement(target),
deleteItem: (_event, target) => deleteItemFromElement(target),
openRichEditor: this.#openRichEditor,
@ -27,6 +32,13 @@ export function GenericAppMixin(HandlebarsApp) {
};
// #endregion
// #region Instance Data
/** @type {Map<string, PopoverEventManager>} */
_popoverManagers = new Map();
/** @type {Map<number, string>} */
_hookIDs = new Map();
// #endregion
// #region Lifecycle
/**
* @override
@ -34,13 +46,43 @@ export function GenericAppMixin(HandlebarsApp) {
* top after being re-rendered as normal
*/
async render(options = {}, _options = {}) {
super.render(options, _options);
await super.render(options, _options);
const instance = foundry.applications.instances.get(this.id);
if (instance !== undefined && options.orBringToFront) {
instance.bringToFront();
};
};
/** @override */
async _onRender(...args) {
await super._onRender(...args);
/*
Rendering each of the popover managers associated with this app allows us
to have them be dynamic and update when their parent application is rerendered,
this could eventually be something we can move into the Document's apps
collection so Foundry auto-rerenders it, but because it isn't actually
associated with the Document (as it's dependendant on the Application), I
decided that it would be best to do my own handling for it.
*/
for (const manager of this._popoverManagers.values()) {
manager.render();
};
/*
Foreign update listeners so that we can easily update items that may not
be this document itself, but are useful to be able to be edited from this
sheet. Primarily useful for editing the Actors' Item collection, or an Items'
ActiveEffect collection.
*/
this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
const events = el.dataset.foreignUpdateOn.split(`,`);
for (const event of events) {
el.addEventListener(event, updateForeignDocumentFromEvent);
};
});
};
async _preparePartContext(partId, ctx, opts) {
ctx = await super._preparePartContext(partId, ctx, opts);
delete ctx.document;
@ -50,12 +92,29 @@ export function GenericAppMixin(HandlebarsApp) {
ctx.meta.idp = this.document?.uuid ?? this.id;
if (this.document) {
ctx.meta.limited = this.document.limited;
ctx.meta.editable = ctx.editable;
}
ctx.meta.editable = this.isEditable || game.user.isGM;
ctx.meta.embedded = this.document.isEmbedded;
};
delete ctx.editable;
return ctx;
};
_tearDown(options) {
// Clear all popovers associated with the app
for (const manager of this._popoverManagers.values()) {
manager.destroy();
};
this._popoverManagers.clear();
// Remove any hooks added for this app
for (const [id, hook] of this._hookIDs.entries()) {
Hooks.off(hook, id);
};
this._hookIDs.clear();
super._tearDown(options);
};
// #endregion
// #region Actions

View file

@ -43,6 +43,27 @@ export class AllItemSheetV1 extends GenericAppMixin(HandlebarsApplicationMixin(I
Logger.debug(`Context:`, ctx);
return ctx;
};
async _onRender() {
// remove the flag if it exists when we render the sheet
delete this.document?.system?.forceRerender;
};
/**
* Used to make it so that items that don't get updated because of the
* _preUpdate hook removing/changing the data submitted, can still get
* re-rendered when the diff is empty. If the document does get updated,
* this rerendering does not happen.
*
* @override
*/
async _processSubmitData(...args) {
await super._processSubmitData(...args);
if (this.document.system.forceRerender) {
await this.render();
};
};
// #endregion
// #region Actions

View file

@ -0,0 +1,135 @@
import { filePath } from "../../consts.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { GenericAppMixin } from "../GenericApp.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
const { ItemSheetV2 } = foundry.applications.sheets;
const { getProperty, hasProperty, setProperty } = foundry.utils;
export class ArmourSheet extends GenericAppMixin(HandlebarsApplicationMixin(ItemSheetV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt--item`,
`ArmourSheet`,
],
position: {
width: `auto`,
height: `auto`,
},
window: {
resizable: false,
},
form: {
submitOnChange: true,
closeOnSubmit: false,
},
};
static PARTS = {
header: {
template: filePath(`templates/Apps/partials/item-header.hbs`),
},
content: {
template: filePath(`templates/Apps/ArmourSheet/content.hbs`),
},
};
// #endregion
// #region Lifecycle
async _onRender() {
// remove the flag if it exists when we render the sheet
delete this.document?.system?.forceRerender;
};
/**
* Used to make it so that items that don't get updated because of the
* _preUpdate hook removing/changing the data submitted, can still get
* re-rendered when the diff is empty. If the document does get updated,
* this rerendering does not happen.
*
* @override
*/
async _processSubmitData(...args) {
await super._processSubmitData(...args);
if (this.document.system.forceRerender) {
await this.render();
};
};
/**
* Customize how form data is extracted into an expanded object.
* @param {SubmitEvent|null} event The originating form submission event
* @param {HTMLFormElement} form The form element that was submitted
* @param {FormDataExtended} formData Processed data for the submitted form
* @returns {object} An expanded object of processed form data
* @throws {Error} Subclasses may throw validation errors here to prevent form submission
* @protected
*/
_processFormData(event, form, formData) {
const data = super._processFormData(event, form, formData);
if (hasProperty(data, `system.location`)) {
let locations = getProperty(data, `system.location`);
locations = locations.filter(value => value != null);
setProperty(data, `system.location`, locations);
};
return data;
};
// #endregion
// #region Data Prep
async _preparePartContext(partId, _, opts) {
const ctx = await super._preparePartContext(partId, {}, opts);
ctx.item = this.document;
ctx.system = this.document.system;
switch (partId) {
case `content`: {
this._prepareContentContext(ctx, opts);
break;
};
};
return ctx;
};
async _prepareContentContext(ctx) {
ctx.weights = [
{
label: `RipCrypt.common.empty`,
value: null,
},
...Object.values(gameTerms.WeightRatings).map(opt => ({
label: `RipCrypt.common.weightRatings.${opt}`,
value: opt,
})),
];
ctx.accesses = [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
];
ctx.protects = {
head: this.document.system.location.has(gameTerms.Anatomy.HEAD),
body: this.document.system.location.has(gameTerms.Anatomy.BODY),
arms: this.document.system.location.has(gameTerms.Anatomy.ARMS),
legs: this.document.system.location.has(gameTerms.Anatomy.LEGS),
};
};
// #endregion
// #region Actions
// #endregion
};

View file

@ -0,0 +1,56 @@
import { filePath } from "../../consts.mjs";
import { StyledShadowElement } from "./mixins/StyledShadowElement.mjs";
const { renderTemplate } = foundry.applications.handlebars;
export class ArmourSummary extends StyledShadowElement(HTMLElement) {
static elementName = `armour-summary`;
static formAssociated = false;
/* Stuff for the mixin to use */
static _stylePath = `css/components/armour-summary.css`;
#container;
get type() {
return this.getAttribute(`type`) ?? `hero`;
};
set type(newValue) {
this.setAttribute(`type`, newValue);
};
_mounted = false;
async connectedCallback() {
super.connectedCallback();
if (this._mounted) { return };
/*
This converts all of the double-dash 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);
};
};
this.#container = document.createElement(`div`);
this.#container.classList = `person`;
this.#container.innerHTML = await renderTemplate(
filePath(`templates/components/armour-summary.hbs`),
{ type: this.type },
);
this._shadow.appendChild(this.#container);
this._mounted = true;
};
disconnectedCallback() {
super.disconnectedCallback();
if (!this._mounted) { return };
this._mounted = false;
};
};

View file

@ -1,9 +1,11 @@
import { ArmourSummary } from "./ArmourSummary.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { RipCryptBorder } from "./RipCryptBorder.mjs";
import { RipCryptIcon } from "./Icon.mjs";
import { RipCryptSVGLoader } from "./svgLoader.mjs";
const components = [
ArmourSummary,
RipCryptIcon,
RipCryptSVGLoader,
RipCryptBorder,

View file

@ -51,7 +51,8 @@ export function StyledShadowElement(Base) {
const stylePath = this.constructor._stylePath;
if (this.constructor._styles.has(stylePath)) {
this._style.innerHTML = this.constructor._styles.get(stylePath);
} else {
}
else {
fetch(`./systems/${game.system.id}/templates/${stylePath}`)
.then(r => r.text())
.then(t => {

View file

@ -0,0 +1,96 @@
import { filePath } from "../../consts.mjs";
import { GenericPopoverMixin } from "./GenericPopoverMixin.mjs";
import { ItemFlags } from "../../flags/item.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { HandlebarsApplicationMixin, ApplicationV2 } = foundry.applications.api;
export class AmmoTracker extends GenericPopoverMixin(HandlebarsApplicationMixin(ApplicationV2)) {
// #region Options
static DEFAULT_OPTIONS = {
classes: [
`ripcrypt`,
],
window: {
title: `RipCrypt.app-titles.AmmoTracker`,
contentClasses: [
`ripcrypt--AmmoTracker`,
],
},
actions: {
favourite: this.#favourite,
unfavourite: this.#unfavourite,
},
};
static PARTS = {
ammoList: {
template: filePath(`templates/Apps/popovers/AmmoTracker/ammoList.hbs`),
},
};
// #endregion
// #region Instance Data
_favouriteCount = 0;
// #endregion
// #region Lifecycle
async _preparePartContext(partId, data) {
const ctx = {
meta: { idp: this.id },
partId,
};
let favouriteCount = 0;
ctx.ammos = data.ammos.map(ammo => {
const favourite = ammo.getFlag(game.system.id, ItemFlags.FAVOURITE) ?? false;
if (favourite) { favouriteCount++ };
return {
ammo,
favourite,
};
});
this._favouriteCount = favouriteCount;
ctx.atFavouriteLimit = favouriteCount >= 3;
return ctx;
};
// #endregion
// #region Actions
static async #favourite(_, el) {
const targetEl = el.closest(`[data-item-id]`);
if (!targetEl) {
Logger.warn(`Cannot find a parent element with data-item-id`);
return;
};
if (this._favouriteCount > 3) {
ui.notifications.error(localizer(`RipCrypt.notifs.error.at-favourite-limit`));
return;
};
const data = targetEl.dataset;
const item = await fromUuid(data.itemId);
if (!item) { return };
item.setFlag(game.system.id, ItemFlags.FAVOURITE, true);
};
static async #unfavourite(_, el) {
const targetEl = el.closest(`[data-item-id]`);
if (!targetEl) {
Logger.warn(`Cannot find a parent element with data-item-id`);
return;
};
const data = targetEl.dataset;
const item = await fromUuid(data.itemId);
if (!item) { return };
item.unsetFlag(game.system.id, ItemFlags.FAVOURITE);
};
// #endregion
};

View file

@ -0,0 +1,188 @@
import { updateForeignDocumentFromEvent } from "../utils.mjs";
const { ApplicationV2 } = foundry.applications.api;
/**
* This mixin provides the ability to designate an Application as a "popover",
* which means that it will spawn near the x/y coordinates provided it won't
* overflow the bounds of the screen. This also implements a _preparePartContext
* in order to allow the parent application passing new data into the popover
* whenever it rerenders; how the popover handles this data is up to the
* specific implementation.
*/
export function GenericPopoverMixin(HandlebarsApp) {
class GenericRipCryptPopover extends HandlebarsApp {
static DEFAULT_OPTIONS = {
id: `popover-{id}`,
classes: [
`popover`,
],
window: {
frame: false,
positioned: true,
resizable: false,
minimizable: false,
},
actions: {},
};
popover = {};
constructor({ popover, ...options}) {
// For when the caller doesn't provide anything, we want this to behave
// like a normal Application instance.
popover.framed ??= true;
popover.locked ??= false;
if (popover.framed) {
options.window ??= {};
options.window.frame = true;
options.window.minimizable = true;
}
options.classes ??= [];
options.classes.push(popover.framed ? `framed` : `frameless`);
super(options);
this.popover = popover;
};
toggleLock() {
this.popover.locked = !this.popover.locked;
this.classList.toggle(`locked`, this.popover.locked);
};
/**
* This render utility is intended in order to make the popovers able to be
* used in both framed and frameless mode, making sure that the content classes
* from the framed mode get shunted onto the frameless Application's root
* element.
*/
async _onFirstRender(...args) {
await super._onFirstRender(...args);
const hasContentClasses = this.options?.window?.contentClasses?.length > 0;
if (!this.popover.framed && hasContentClasses) {
this.classList.add(...this.options.window.contentClasses);
};
};
async _onRender(...args) {
await super._onRender(...args);
/*
Foreign update listeners so that we can easily update items that may not
be this document itself, but are useful to be able to be edited from this
sheet. Primarily useful for editing the Actors' Item collection, or an Items'
ActiveEffect collection.
*/
this.element.querySelectorAll(`input[data-foreign-update-on]`).forEach(el => {
const events = el.dataset.foreignUpdateOn.split(`,`);
for (const event of events) {
el.addEventListener(event, updateForeignDocumentFromEvent);
};
});
};
async close(options = {}) {
// prevent locked popovers from being closed
if (this.popover.locked && !options.force) { return };
if (!this.popover.framed) {
options.animate = false;
};
return super.close(options);
};
/**
* @override
* Custom implementation in order to make it show up approximately where I
* want it to when being created.
*
* Most of this implementation is identical to the ApplicationV2
* implementation, the biggest difference is how targetLeft and targetTop
* are calculated.
*/
_updatePosition(position) {
if (!this.element) { return position };
if (this.popover.framed) { return super._updatePosition(position) };
const el = this.element;
let {width, height, left, top, scale} = position;
scale ??= 1.0;
const computedStyle = getComputedStyle(el);
let minWidth = ApplicationV2.parseCSSDimension(computedStyle.minWidth, el.parentElement.offsetWidth) || 0;
let maxWidth = ApplicationV2.parseCSSDimension(computedStyle.maxWidth, el.parentElement.offsetWidth) || Infinity;
let minHeight = ApplicationV2.parseCSSDimension(computedStyle.minHeight, el.parentElement.offsetHeight) || 0;
let maxHeight = ApplicationV2.parseCSSDimension(computedStyle.maxHeight, el.parentElement.offsetHeight) || Infinity;
let bounds = el.getBoundingClientRect();
const {clientWidth, clientHeight} = document.documentElement;
// Explicit width
const autoWidth = width === `auto`;
if ( !autoWidth ) {
const targetWidth = Number(width || bounds.width);
minWidth = parseInt(minWidth) || 0;
maxWidth = parseInt(maxWidth) || (clientWidth / scale);
width = Math.clamp(targetWidth, minWidth, maxWidth);
}
// Explicit height
const autoHeight = height === `auto`;
if ( !autoHeight ) {
const targetHeight = Number(height || bounds.height);
minHeight = parseInt(minHeight) || 0;
maxHeight = parseInt(maxHeight) || (clientHeight / scale);
height = Math.clamp(targetHeight, minHeight, maxHeight);
}
// Implicit height
if ( autoHeight ) {
Object.assign(el.style, {width: `${width}px`, height: ``});
bounds = el.getBoundingClientRect();
height = bounds.height;
}
// Implicit width
if ( autoWidth ) {
Object.assign(el.style, {height: `${height}px`, width: ``});
bounds = el.getBoundingClientRect();
width = bounds.width;
}
// Left Offset
const scaledWidth = width * scale;
const targetLeft = left ?? (this.popover.x - Math.floor( scaledWidth / 2 ));
const maxLeft = Math.max(clientWidth - scaledWidth, 0);
left = Math.clamp(targetLeft, 0, maxLeft);
// Top Offset
const scaledHeight = height * scale;
const targetTop = top ?? (this.popover.y - scaledHeight);
const maxTop = Math.max(clientHeight - scaledHeight, 0);
top = Math.clamp(targetTop, 0, maxTop);
// Scale
scale ??= 1.0;
return {
width: autoWidth ? `auto` : width,
height: autoHeight ? `auto` : height,
left,
top,
scale,
};
};
/**
* This is here in order allow things that are not this Application
* to provide / augment the context data for the lifecycle of the app.
*/
async _prepareContext(_partId, _context, options) {
const context = {};
Hooks.callAll(`prepare${this.constructor.name}Context`, context, options);
Hooks.callAll(`prepare${this.popover.managerId}Context`, context, options);
return context;
};
};
return GenericRipCryptPopover;
};

View file

@ -0,0 +1,89 @@
const { CombatTracker } = foundry.applications.sidebar.tabs;
function createButtonInnerHTML() {
const whoFirst = game.settings.get(`ripcrypt`, `whoFirst`);
let icon = `evil`;
let ariaLabel = `Geists go first, click to make heroes go first`;
if (whoFirst === `friendly`) {
icon = `hero`;
ariaLabel = `Heroes go first, click to make geists go first`;
};
return `<rc-icon
name="icons/${icon}"
var:fill="currentColor"
aria-label="${ariaLabel}"
></rc-icon>`;
};
function createButtonTooltip() {
const whoFirst = game.settings.get(`ripcrypt`, `whoFirst`);
if (whoFirst === `friendly`) {
return `Heroes currently go first`;
};
return `Geists currently go first`;
};
export class RipCryptCombatTracker extends CombatTracker {
static DEFAULT_OPTIONS = {
actions: {
toggleFirst: this.#toggleFirst,
},
};
/**
* Changes the way the combat tracker renders combatant rows to account for
* multiple combatants being in the same combat "group", thus all going at the
* same time.
*
* @override
*/
async _prepareTurnContext(combat, combatant, index) {
const turn = await super._prepareTurnContext(combat, combatant, index);
turn.hasDecimals = true;
turn.initiative = combatant.dynamicInitiative;
const groupKey = combatant?.groupKey;
if (groupKey && combat.started) {
turn.active ||= combat.combatant?.groupKey === groupKey;
if (turn.active && !turn.css.includes(`active`)) {
turn.css += ` active`;
};
};
return turn;
};
async _onRender(...args) {
await super._onRender(...args);
const spacer = document.createElement(`div`);
spacer.classList.add(`spacer`);
const button = document.createElement(`button`);
button.classList.add(`inline-control`, `combat-control`, `icon`);
button.type = `button`;
button.dataset.tooltip = createButtonTooltip();
button.dataset.action = `toggleFirst`;
button.innerHTML = createButtonInnerHTML();
button.disabled = !game.user.isGM;
// Purge the combat controls that I don't want to exist because they don't
// make sense in the system.
this.element?.querySelector(`[data-action="rollNPC"]`)?.replaceWith(spacer.cloneNode(true));
this.element?.querySelector(`[data-action="rollAll"]`)?.replaceWith(button.cloneNode(true));
};
static async #toggleFirst(_event, element) {
game.tooltip.deactivate();
const whoFirst = game.settings.get(`ripcrypt`, `whoFirst`);
const otherFirst = whoFirst === `friendly` ? `hostile` : `friendly`;
await game.settings.set(`ripcrypt`, `whoFirst`, otherFirst);
element.innerHTML = createButtonInnerHTML();
element.dataset.tooltip = createButtonTooltip();
game.tooltip.activate(element);
};
};

View file

@ -3,7 +3,24 @@ This file contains utilities used by Applications in order to be DRYer
*/
/**
* @param {HTMLElement} target The element that gets
* @param {HTMLElement} target The element to operate on
*/
export async function createItemFromElement(target, { parent } = {}) {
const data = target.dataset;
const types = data.itemTypes?.split(`,`);
const type = data.defaultItemType;
await Item.createDialog(
{ type },
{ parent, showEquipPrompt: false },
{
types,
folders: [],
},
);
};
/**
* @param {HTMLElement} target The element to operate on
*/
export async function editItemFromElement(target) {
const itemEl = target.closest(`[data-item-id]`);
@ -15,7 +32,7 @@ export async function editItemFromElement(target) {
};
/**
* @param {HTMLElement} target The element that gets
* @param {HTMLElement} target The element to operate on
*/
export async function deleteItemFromElement(target) {
const itemEl = target.closest(`[data-item-id]`);
@ -23,5 +40,26 @@ export async function deleteItemFromElement(target) {
const itemId = itemEl.dataset.itemId;
if (!itemId) { return };
const item = await fromUuid(itemId);
item.delete();
item.deleteDialog();
};
/**
* Updates a document using the UUID, expects there to be the following
* dataset attributes:
* - "data-foreign-uuid" : The UUID of the document to update
* - "data-foreign-name" : The dot-separated path of the value to update
*
* @param {Event} event
*/
export async function updateForeignDocumentFromEvent(event) {
const target = event.currentTarget;
const data = target.dataset;
const document = await fromUuid(data.foreignUuid);
let value = target.value;
switch (target.type) {
case `checkbox`: value = target.checked; break;
};
await document?.update({ [data.foreignName]: value });
};

View file

@ -1,12 +1,18 @@
// App imports
import { AmmoTracker } from "./Apps/popovers/AmmoTracker.mjs";
import { CombinedHeroSheet } from "./Apps/ActorSheets/CombinedHeroSheet.mjs";
import { DicePool } from "./Apps/DicePool.mjs";
import { HeroSkillsCardV1 } from "./Apps/ActorSheets/HeroSkillsCardV1.mjs";
import { HeroSummaryCardV1 } from "./Apps/ActorSheets/HeroSummaryCardV1.mjs";
import { RichEditor } from "./Apps/RichEditor.mjs";
import { SkillsCardV1 } from "./Apps/ActorSheets/SkillsCardV1.mjs";
import { StatsCardV1 } from "./Apps/ActorSheets/StatsCardV1.mjs";
// Util imports
import { distanceBetweenFates, nextFate, previousFate } from "./utils/fates.mjs";
import { documentSorter } from "./consts.mjs";
import { rankToInteger } from "./utils/rank.mjs";
// Misc Imports
import { ItemFlags } from "./flags/item.mjs";
const { deepFreeze } = foundry.utils;
@ -16,15 +22,21 @@ Object.defineProperty(
{
value: deepFreeze({
Apps: {
AmmoTracker,
DicePool,
CombinedHeroSheet,
HeroSummaryCardV1,
HeroSkillsCardV1,
StatsCardV1,
SkillsCardV1,
RichEditor,
},
utils: {
documentSorter,
distanceBetweenFates,
nextFate,
previousFate,
rankToInteger,
},
ItemFlags,
}),
writable: false,
},

View file

@ -42,9 +42,11 @@ export function toBoolean(val) {
export function documentSorter(a, b) {
if (!a && !b) {
return 0;
} else if (!a) {
}
else if (!a) {
return 1;
} else if (!b) {
}
else if (!b) {
return -1;
};
@ -54,3 +56,14 @@ export function documentSorter(a, b) {
};
return Math.sign(a.name.localeCompare(b.name));
};
// MARK: getTooltipDelay
/**
* Retrieves the configured minimum delay between the user hovering an element
* and a tooltip showing up. Used for the pseudo-tooltip Applications that I use.
*
* @returns The number of milliseconds for the timeout
*/
export function getTooltipDelay() {
return game.tooltip.constructor.TOOLTIP_ACTIVATION_MS;
};

View file

@ -0,0 +1,201 @@
import { derivedMaximumBar } from "../helpers.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { rankToInteger } from "../../utils/rank.mjs";
import { sumReduce } from "../../utils/sumReduce.mjs";
const { fields } = foundry.data;
export class EntityData extends foundry.abstract.TypeDataModel {
// MARK: Token Attrs
static get trackableAttributes() {
return {
bar: [
`guts`,
],
value: [
`ability.grit`,
`ability.gait`,
`ability.grip`,
`ability.glim`,
`level.glory`,
`level.step`,
`level.rank`,
`coin.gold`,
`coin.silver`,
`coin.copper`,
],
};
};
// MARK: Schema
static defineSchema() {
return {
ability: new fields.SchemaField({
grit: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
gait: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
grip: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
glim: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
}),
guts: derivedMaximumBar(0, 5),
coin: new fields.SchemaField({
gold: new fields.NumberField({
initial: 5,
integer: true,
required: true,
nullable: false,
}),
silver: new fields.NumberField({
initial: 0,
integer: true,
required: true,
nullable: false,
}),
copper: new fields.NumberField({
initial: 0,
integer: true,
required: true,
nullable: false,
}),
}),
fate: new fields.StringField({
initial: ``,
blank: true,
trim: true,
nullable: false,
choices: () => {
return Object.values(gameTerms.FatePath).concat(``);
},
}),
level: new fields.SchemaField({
glory: new fields.NumberField({
min: 0,
initial: 0,
integer: true,
required: true,
nullable: false,
}),
step: new fields.NumberField({
min: 1,
initial: 1,
max: 3,
integer: true,
required: true,
nullable: false,
}),
rank: new fields.StringField({
initial: gameTerms.Rank.NOVICE,
required: true,
nullable: false,
blank: false,
trim: true,
choices: Object.values(gameTerms.Rank),
}),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
// Calculate the person's base Crafting aura
const rank = rankToInteger(this.level.rank);
this.aura = {
normal: ( rank + 1 ) * 2,
heavy: ( rank + 2 ) * 2,
};
this.guts.max = 0;
// The limitations imposed on things like inventory spaces and equipped
// weapon count
this.limit = {
weapons: 4,
equipment: 12,
skills: 4,
};
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
this.guts.max += Object.values(this.ability).reduce(sumReduce);
// Movement speeds
this.speed = {
move: this.ability.gait + 3,
run: (this.ability.gait + 3) * 2,
};
};
// #region Getters
get equippedWeapons() {
const weapons = this.parent.itemTypes.weapon;
return weapons.filter(w => w.system.equipped);
};
get equippedArmour() {
const armours = this.parent.itemTypes.armour;
const slots = Object.fromEntries(
Object.values(gameTerms.Anatomy).map(v => [v, null]),
);
for (const armour of armours) {
if (!armour.system.equipped) { continue };
for (const locationTag of [...armour.system.location.values()]) {
const location = locationTag.toLowerCase();
slots[location] = armour;
};
};
return slots;
};
get equippedShield() {
const shields = this.parent.itemTypes.shield;
return shields.find(item => item.system.equipped);
};
get defense() {
const defenses = {};
const armour = this.equippedArmour;
for (const slot in armour) {
defenses[slot] = armour[slot]?.system.protection ?? 0;
};
const shield = this.equippedShield;
if (shield) {
for (const location of [...shield.system.location.values()]) {
const slot = location.toLowerCase();
defenses[slot] += shield.system.protection;
};
};
return defenses;
};
// #endregion
};

View file

@ -0,0 +1,3 @@
import { EntityData } from "./Entity.mjs";
export class GeistData extends EntityData {};

View file

@ -1,191 +1,3 @@
import { gameTerms } from "../../gameTerms.mjs";
import { sumReduce } from "../../utils/sumReduce.mjs";
import { EntityData } from "./Entity.mjs";
const { fields } = foundry.data;
export class HeroData extends foundry.abstract.TypeDataModel {
// MARK: Token Attrs
static get trackableAttributes() {
return {
bar: [
`guts`,
],
value: [
`ability.grit`,
`ability.gait`,
`ability.grip`,
`ability.glim`,
`level.glory`,
`level.step`,
`level.rank`,
],
};
};
// MARK: Schema
static defineSchema() {
return {
ability: new fields.SchemaField({
grit: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
gait: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
grip: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
glim: new fields.NumberField({
min: 0,
initial: 1,
integer: true,
required: true,
nullable: false,
}),
}),
guts: new fields.SchemaField({
value: new fields.NumberField({
min: 0,
initial: 5,
integer: true,
nullable: false,
}),
}),
coin: new fields.SchemaField({
gold: new fields.NumberField({
initial: 5,
integer: true,
required: true,
nullable: false,
}),
silver: new fields.NumberField({
initial: 0,
integer: true,
required: true,
nullable: false,
}),
copper: new fields.NumberField({
initial: 0,
integer: true,
required: true,
nullable: false,
}),
}),
fate: new fields.StringField({
initial: ``,
blank: true,
trim: true,
nullable: false,
choices: () => {
return gameTerms.FatePath.concat(``);
},
}),
level: new fields.SchemaField({
glory: new fields.NumberField({
min: 0,
initial: 0,
integer: true,
required: true,
nullable: false,
}),
step: new fields.NumberField({
min: 1,
initial: 1,
max: 3,
integer: true,
required: true,
nullable: false,
}),
rank: new fields.StringField({
initial: gameTerms.Rank.NOVICE,
required: true,
nullable: false,
blank: false,
trim: true,
choices: Object.values(gameTerms.Rank),
}),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
this.guts.max = 0;
// The limitations imposed on things like inventory spaces and equipped
// weapon count
this.limit = {
weapons: 4,
equipment: 12,
skills: 4,
};
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
this.guts.max += Object.values(this.ability).reduce(sumReduce);
// Movement speeds
this.speed = {
move: this.ability.gait + 3,
run: (this.ability.gait + 3) * 2,
};
};
// #region Getters
get equippedArmour() {
const armours = this.parent.itemTypes.armour;
const slots = Object.fromEntries(
Object.values(gameTerms.Anatomy).map(v => [v, null]),
);
for (const armour of armours) {
if (!armour.system.equipped) { continue };
for (const locationTag of [...armour.system.location.values()]) {
const location = locationTag.toLowerCase();
slots[location] = armour;
};
};
return slots;
};
get equippedShield() {
const shields = this.parent.itemTypes.shield;
return shields.find(item => item.system.equipped);
};
get defense() {
const defenses = {};
const armour = this.equippedArmour;
for (const slot in armour) {
defenses[slot] = armour[slot]?.system.protection ?? 0;
};
const shield = this.equippedShield;
if (shield) {
for (const location of [...shield.system.location.values()]) {
const slot = location.toLowerCase();
defenses[slot] += shield.system.protection;
};
};
return defenses;
};
// #endregion
};
export class HeroData extends EntityData {};

View file

@ -1,4 +1,5 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
export class AmmoData extends CommonItemData {
// MARK: Base Data
@ -25,6 +26,32 @@ export class AmmoData extends CommonItemData {
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
];
return fields;
};

118
module/data/Item/Armour.mjs Normal file
View file

@ -0,0 +1,118 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
import { requiredInteger } from "../helpers.mjs";
const { diffObject, getProperty, setProperty } = foundry.utils;
const { DialogV2 } = foundry.applications.api;
const { fields } = foundry.data;
/** Used for Armour and Shields */
export class ArmourData extends CommonItemData {
// #region Schema
static defineSchema() {
return {
...super.defineSchema(),
protection: requiredInteger({ min: 0, initial: 1 }),
location: new fields.SetField(
new fields.StringField({
blank: false,
trim: true,
nullable: false,
required: true,
options: Object.values(gameTerms.Anatomy),
}),
{
nullable: false,
initial: [],
},
),
equipped: new fields.BooleanField({
initial: false,
nullable: false,
}),
weight: new fields.StringField({
blank: false,
nullable: true,
initial: null,
options: Object.values(gameTerms.WeightRatings),
}),
};
};
// #endregion Schema
// #region Lifecycle
async _preCreate(item, options) {
const showEquipPrompt = options.showEquipPrompt ?? true;
if (showEquipPrompt && this.parent.isEmbedded && this._canEquip()) {
const shouldEquip = await DialogV2.confirm({
window: { title: `Equip Item?` },
content: `Do you want to equip ${item.name}?`,
});
if (shouldEquip) {
this.updateSource({ "equipped": true });
};
};
};
async _preUpdate(changes, options, user) {
if (options.force && game.settings.get(`ripcrypt`, `devMode`)) { return };
// Ensure changes is a diffed object
const diff = diffObject(this.parent._source, changes);
let valid = await super._preUpdate(changes, options, user);
if (getProperty(diff, `system.equipped`) && !this._canEquip()) {
ui.notifications.error(
localizer(
`RipCrypt.notifs.error.cannot-equip`,
{ itemType: `@TYPES.Item.${this.parent.type}` },
),
{ console: false },
);
// Don't stop the update, but don't allow changing the equipped status
setProperty(changes, `system.equipped`, false);
// Set a flag so that we can tell the sheet that it needs to rerender
this.forceRerender = true;
};
return valid;
};
// #endregion Lifecycle
// #region Helpers
/**
* Used to tell the preUpdate logic whether or not to prevent the item from
* being equipped or not.
*/
_canEquip() {
const parent = this.parent;
if (!parent.isEmbedded || !(parent.parent instanceof Actor)) {
Logger.error(`Unable to equip item when it's not embedded`);
return false;
};
if (this.location.size === 0) {
Logger.error(`Unable to equip an item without any locations`);
return false;
};
const slots = parent.parent.system.equippedArmour ?? {};
for (const locationTag of this.location) {
if (slots[locationTag.toLowerCase()] != null) {
Logger.error(`Unable to equip multiple items in the same slot`);
return false;
};
};
return true;
};
get locationString() {
return [...this.location].join(`, `);
};
// #endregion Helpers
};

View file

@ -1,10 +1,24 @@
import { requiredInteger } from "../helpers.mjs";
import { optionalInteger, requiredInteger } from "../helpers.mjs";
import { gameTerms } from "../../gameTerms.mjs";
const { fields } = foundry.data;
export class CommonItemData extends foundry.abstract.TypeDataModel {
// MARK: Schema
static defineSchema() {
return {
quantity: requiredInteger({ min: 0, initial: 1 }),
access: new fields.StringField({
blank: true,
nullable: false,
trim: true,
choices: gameTerms.Access,
}),
cost: new fields.SchemaField({
gold: optionalInteger(),
silver: optionalInteger(),
copper: optionalInteger(),
}),
};
};

83
module/data/Item/Good.mjs Normal file
View file

@ -0,0 +1,83 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
const { fields } = foundry.data;
export class GoodData extends CommonItemData {
// MARK: Schema
static defineSchema() {
const schema = super.defineSchema();
schema.description = new fields.HTMLField({
blank: true,
nullable: false,
trim: true,
});
return schema;
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Getters
// #endregion
// #region Sheet Data
async getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
type: `integer`,
label: `RipCrypt.common.quantity`,
path: `system.quantity`,
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
{
id: `description`,
type: `prosemirror`,
label: `RipCrypt.common.description`,
path: `system.description`,
uuid: this.parent.uuid,
value: await TextEditor.enrichHTML(this.description),
collaborative: false,
},
];
return fields;
};
// #endregion
};

View file

@ -1,115 +0,0 @@
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { requiredInteger } from "../helpers.mjs";
const { fields } = foundry.data;
const { hasProperty, mergeObject } = foundry.utils;
/** Used for Armour and Shields */
export class ProtectorData extends CommonItemData {
// MARK: Schema
static defineSchema() {
return {
...super.defineSchema(),
protection: requiredInteger({ min: 0, initial: 1 }),
location: new fields.SetField(
new fields.StringField({
blank: false,
trim: true,
nullable: false,
options: Object.values(gameTerms.Anatomy),
}),
{
nullable: false,
required: true,
},
),
equipped: new fields.BooleanField({
initial: false,
required: true,
nullable: false,
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #region Lifecycle
async _preUpdate(changes, options, user) {
if (options.force && game.settings.get(`ripcrypt`, `devMode`)) { return };
let valid = super._preUpdate(changes, options, user);
if (hasProperty(changes, `system.equipped`) && !this.parent.isEmbedded) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.cannot-equip-not-embedded`,
{ itemType: `@TYPES.Item.${this.parent.type}` },
));
mergeObject(
changes,
{ "-=system.equipped": null },
{ inplace: true, performDeletions: true },
);
return false;
};
return valid;
};
// #endregion
// #region Getters
get locationString() {
return [...this.location].join(`, `);
};
// #endregion
// #region Sheet Data
getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
type: `integer`,
label: `RipCrypt.common.quantity`,
path: `system.quantity`,
value: this.quantity,
min: 0,
},
{
id: `location`,
type: `string-set`,
label: `RipCrypt.common.location`,
placeholder: `RipCrypt.Apps.location-placeholder`,
path: `system.location`,
value: this.locationString,
},
{
id: `protection`,
type: `integer`,
label: `RipCrypt.common.protection`,
value: this.protection,
path: `system.protection`,
min: 0,
},
];
if (this.parent.isEmbedded) {
fields.push({
id: `equipped`,
type: `boolean`,
label: `RipCrypt.common.equipped`,
value: this.equipped,
path: `system.equipped`,
});
};
return fields;
};
// #endregion
};

View file

@ -0,0 +1,19 @@
import { ArmourData } from "./Armour.mjs";
import { Logger } from "../../utils/Logger.mjs";
export class ShieldData extends ArmourData {
_canEquip() {
const parent = this.parent;
if (!parent.isEmbedded || !(parent.parent instanceof Actor)) {
Logger.error(`Unable to equip item when it's not embedded`);
return false;
};
const shield = parent.parent.system.equippedShield;
if (shield) {
Logger.error(`Unable to equip multiple shields`);
return false;
};
return true;
};
};

View file

@ -2,12 +2,14 @@ import { barAttribute, optionalInteger, requiredInteger } from "../helpers.mjs";
import { CommonItemData } from "./Common.mjs";
import { gameTerms } from "../../gameTerms.mjs";
import { localizer } from "../../utils/Localizer.mjs";
import { Logger } from "../../utils/Logger.mjs";
const { diffObject, getProperty, setProperty } = foundry.utils;
const { DialogV2 } = foundry.applications.api;
const { fields } = foundry.data;
const { hasProperty, mergeObject } = foundry.utils;
export class WeaponData extends CommonItemData {
// MARK: Schema
// #region Schema
static defineSchema() {
return {
...super.defineSchema(),
@ -28,52 +30,82 @@ export class WeaponData extends CommonItemData {
}),
damage: requiredInteger({ min: 0, initial: 0 }),
wear: barAttribute(0, 0, 4),
access: new fields.StringField({
blank: true,
nullable: false,
trim: true,
choices: gameTerms.Access,
}),
equipped: new fields.BooleanField({
initial: false,
required: true,
nullable: false,
}),
weight: new fields.StringField({
blank: false,
nullable: true,
initial: null,
options: Object.values(gameTerms.WeightRatings),
}),
};
};
// MARK: Base Data
prepareBaseData() {
super.prepareBaseData();
};
// MARK: Derived Data
prepareDerivedData() {
super.prepareDerivedData();
};
// #endregion Schema
// #region Lifecycle
async _preCreate(item, options) {
const showEquipPrompt = options.showEquipPrompt ?? true;
if (showEquipPrompt && this.parent.isEmbedded && this._canEquip()) {
const shouldEquip = await DialogV2.confirm({
window: { title: `Equip Item?` },
content: `Do you want to equip ${item.name}?`,
});
if (shouldEquip) {
this.updateSource({ "equipped": true });
};
};
};
/**
*
* @param {*} changes The expanded object that was used for the update
* @param {*} options
* @param {*} user
* @returns
*/
async _preUpdate(changes, options, user) {
if (options.force && game.settings.get(`ripcrypt`, `devMode`)) { return };
const diff = diffObject(this.parent._source, changes);
let valid = super._preUpdate(changes, options, user);
if (hasProperty(changes, `system.equipped`) && !this.parent.isEmbedded) {
if (getProperty(diff, `system.equipped`) && !this._canEquip()) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.cannot-equip-not-embedded`,
`RipCrypt.notifs.error.cannot-equip`,
{ itemType: `@TYPES.Item.${this.parent.type}` },
));
mergeObject(
changes,
{ "-=system.equipped": null },
{ inplace: true, performDeletions: true },
);
return false;
// Don't stop the update, but don't allow changing the equipped status
setProperty(changes, `system.equipped`, false);
// Set a flag so that we can tell the sheet that it needs to rerender
this.forceRerender = true;
};
return valid;
};
// #endregion
// #endregion Lifecycle
// #region Helpers
/**
* Used to tell the preUpdate logic whether or not to prevent the item from
* being equipped or not.
*/
_canEquip() {
const parent = this.parent;
if (!parent.isEmbedded || !(parent.parent instanceof Actor)) {
Logger.error(`Unable to equip item when it's not embedded`);
return false;
};
const actor = this.parent.parent.system;
if (actor.equippedWeapons?.length >= actor.limit.weapons) {
return false;
};
return true;
};
// #region Getters
get traitString() {
return [...this.traits].join(`, `);
};
@ -84,10 +116,10 @@ export class WeaponData extends CommonItemData {
};
return String(this.range.short ?? this.range.long ?? ``);
};
// #endregion
// #endregion Helpers
// #region Sheet Data
getFormFields(_ctx) {
async getFormFields(_ctx) {
const fields = [
{
id: `quantity`,
@ -97,6 +129,49 @@ export class WeaponData extends CommonItemData {
value: this.quantity,
min: 0,
},
{
id: `access`,
type: `dropdown`,
label: `RipCrypt.common.access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.accessLevels.${opt}`,
value: opt,
})),
],
},
{
id: `cost`,
type: `cost`,
label: `RipCrypt.common.cost`,
gold: this.cost.gold,
silver: this.cost.silver,
copper: this.cost.copper,
},
{
id: `weight`,
type: `dropdown`,
label: `RipCrypt.common.weightRating`,
path: `system.weight`,
value: this.weight,
options: [
{
label: `RipCrypt.common.empty`,
value: null,
},
...Object.values(gameTerms.WeightRatings).map(opt => ({
label: `RipCrypt.common.weightRatings.${opt}`,
value: opt,
})),
],
},
{
id: `traits`,
type: `string-set`,
@ -124,7 +199,8 @@ export class WeaponData extends CommonItemData {
value: this.range.long,
},
});
} else {
}
else {
fields.push({
id: `short-range`,
type: `integer`,
@ -168,24 +244,6 @@ export class WeaponData extends CommonItemData {
min: 0,
},
},
{
id: `access`,
type: `dropdown`,
label: `Access`,
path: `system.access`,
value: this.access,
limited: false,
options: [
{
label: `RipCrypt.common.empty`,
value: ``,
},
...gameTerms.Access.map(opt => ({
label: `RipCrypt.common.access.${opt}`,
value: opt,
})),
],
},
);
if (this.parent.isEmbedded) {
@ -200,5 +258,5 @@ export class WeaponData extends CommonItemData {
return fields;
};
// #endregion
// #endregion Sheet Data
};

View file

@ -19,6 +19,17 @@ export function barAttribute(min, initial, max = undefined) {
});
};
export function derivedMaximumBar(min, initial) {
return new fields.SchemaField({
value: new fields.NumberField({
min,
initial,
integer: true,
nullable: false,
}),
});
};
export function optionalInteger({min, initial = null, max} = {}) {
return new fields.NumberField({
min,

View file

@ -38,7 +38,8 @@ export class CryptDie extends Die {
if (almostCrypted) {
this.ripCryptState = `crypted`;
break;
} else {
}
else {
almostCrypted = true;
}
}

141
module/documents/combat.mjs Normal file
View file

@ -0,0 +1,141 @@
/*
Resources:
- Combat : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/documents/combat.mjs
- Combatant : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/documents/combatant.mjs
- CombatTracker : https://github.com/foundryvtt/dnd5e/blob/4.3.x/module/applications/combat/combat-tracker.mjs
*/
export class RipCryptCombat extends Combat {
get customGroups() {
let groups = new Map();
for (const combatant of this.combatants) {
const groupKey = combatant.groupKey;
if (!groupKey) { continue };
if (groups.has(groupKey)) {
groups.get(groupKey).push(combatant);
}
else {
groups.set(groupKey, [combatant]);
};
};
return groups;
};
/**
* @override
* Sorts combatants for the combat tracker in the following way:
* - Distance from the current fate ordinal. (0 -> 3)
* - Coin Flip result (if disposition matches flip result, then 0, otherwise, 0.5)
*/
_sortCombatants(a, b) {
const ia = Number.isNumeric(a.dynamicInitiative) ? a.dynamicInitiative : -Infinity;
const ib = Number.isNumeric(b.dynamicInitiative) ? b.dynamicInitiative : -Infinity;
const delta = ia - ib;
if (Math.sign(delta) !== 0) {
return delta;
};
// fallback to alphabetical sort
return a.name < b.name ? -1 : 1;
};
async nextTurn() {
if (this.round === 0) {return this.nextRound()}
const turn = this.turn ?? -1;
const groupKey = this.turns[turn]?.groupKey;
// Determine the next turn number
let nextTurn = null;
for (let i = turn + 1; i < this.turns.length; i++) {
const combatant = this.turns[i];
if (combatant.groupKey !== groupKey) {
nextTurn = i;
break;
};
};
// Maybe advance to the next round
if ((nextTurn === null) || (nextTurn >= this.turns.length)) {return this.nextRound()}
const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, nextTurn);
// Update the document, passing data through a hook first
const updateData = {round: this.round, turn: nextTurn};
const updateOptions = {direction: 1, worldTime: {delta: advanceTime}};
Hooks.callAll(`combatTurn`, this, updateData, updateOptions);
await this.update(updateData, updateOptions);
return this;
};
async previousTurn() {
if (this.round === 0) { return this }
if ((this.turn === 0) || (this.turns.length === 0)) {return this.previousRound()}
const currentTurn = (this.turn ?? this.turns.length) - 1;
let previousTurn = null;
const groupKey = this.combatant.groupKey;
for (let i = currentTurn; i >= 0; i--) {
const combatant = this.turns[i];
if (combatant.groupKey !== groupKey) {
previousTurn = i;
break;
}
}
if (previousTurn < 0) {
if (this.round === 1) {
this.round = 0;
return this;
};
return this.previousRound();
}
const advanceTime = this.getTimeDelta(this.round, this.turn, this.round, previousTurn);
// Update the document, passing data through a hook first
const updateData = {round: this.round, turn: previousTurn};
const updateOptions = {direction: -1, worldTime: {delta: advanceTime}};
Hooks.callAll(`combatTurn`, this, updateData, updateOptions);
await this.update(updateData, updateOptions);
return this;
};
/**
* Overridden to make it so that there can be multiple tokens with turn markers
* visible at the same time.
*
* @protected
* @internal
* @override
*/
_updateTurnMarkers() {
if (!canvas.ready) { return };
const tokenGroup = this.combatant?.groupKey;
for (const token of canvas.tokens.turnMarkers) {
const actor = token.actor ?? token.baseActor;
if (actor?.groupKey !== tokenGroup) {
token.renderFlags.set({refreshTurnMarker: true});
}
}
if (!this.active) { return };
const currentToken = this.combatant?.token?._object;
if (!tokenGroup && currentToken) {
currentToken.renderFlags.set({refreshTurnMarker: true});
}
else {
const group = this.customGroups.get(tokenGroup) ?? [];
for (const combatant of group) {
combatant.token?._object?.renderFlags.set({ refreshTurnMarker: true });
}
}
}
};

View file

@ -0,0 +1,71 @@
import { distanceBetweenFates } from "../utils/fates.mjs";
export class RipCryptCombatant extends Combatant {
get disposition() {
switch (this.token?.disposition) {
case CONST.TOKEN_DISPOSITIONS.HOSTILE:
return `hostile`;
case CONST.TOKEN_DISPOSITIONS.FRIENDLY:
return `friendly`;
};
return `unknown`;
};
/**
* Used by the Combat tracker to order combatants according to their
* fate path and the coin flip.
*/
get dynamicInitiative() {
let total = 0;
const start = game.settings.get(`ripcrypt`, `currentFate`);
const end = this.actor?.system?.fate || this.baseActor?.system?.fate;
total += distanceBetweenFates(start, end);
const whoFirst = game.settings.get(`ripcrypt`, `whoFirst`);
if (whoFirst) {
const disposition = this.disposition;
if (disposition === `unknown`) {
total += 0.25;
}
else if (whoFirst !== disposition) {
total += 0.5;
};
};
return total;
};
get groupKey() {
const path = this.token?.actor?.system?.fate;
// Disallow grouping things that don't have a fate path
if (!path) { return null };
// Token Disposition (group into: friendlies, unknown, hostiles)
let disposition = this.disposition;
return `${path}:${disposition}`;
};
/**
* Used to create the turn marker when the combatant is added if they're in
* the group whose turn it is.
*
* @override
*/
_onCreate() {
this.token?._object?._refreshTurnMarker();
};
/**
* Used to remove the turn marker when the combatant is removed from combat
* if they had it visible so that it doesn't stick around infinitely.
*
* @override
*/
_onDelete() {
this.token?._object?._refreshTurnMarker();
};
};

View file

@ -2,7 +2,7 @@ export class RipCryptItem extends Item {
get quantifiedName() {
if (this.system.quantity != null && this.system.quantity !== 1) {
return `${this.name} (${this.system.quantity})`;
}
};
return this.name;
};
};

View file

@ -0,0 +1,36 @@
const { Token } = foundry.canvas.placeables;
const { TokenTurnMarker } = foundry.canvas.placeables.tokens;
export class RipCryptToken extends Token {
/**
* Overridden using a slightly modified implementation in order to make it so
* that the turn marker shows up on tokens if they're in the same group as the
* currently active combatant
*
* @override
*/
_refreshTurnMarker() {
// Should a Turn Marker be active?
const {turnMarker} = this.document;
const markersEnabled = CONFIG.Combat.settings.turnMarker.enabled
&& (turnMarker.mode !== CONST.TOKEN_TURN_MARKER_MODES.DISABLED);
const combatant = game.combat?.active ? game.combat.combatant : null;
const isTurn = combatant && (combatant.groupKey === this.combatant?.groupKey);
const isDefeated = combatant && combatant.isDefeated;
const markerActive = markersEnabled && isTurn && !isDefeated;
// Activate a Turn Marker
if (markerActive) {
if (!this.turnMarker) {
this.turnMarker = this.addChildAt(new TokenTurnMarker(this), 0);
};
canvas.tokens.turnMarkers.add(this);
this.turnMarker.draw();
}
else if (this.turnMarker) {
canvas.tokens.turnMarkers.delete(this);
this.turnMarker.destroy();
this.turnMarker = null;
}
}
};

4
module/flags/item.mjs Normal file
View file

@ -0,0 +1,4 @@
export const ItemFlags = Object.freeze({
/** The boolean value to indicate if an item is considered favourited/starred or not */
FAVOURITE: `favourited`,
});

View file

@ -11,12 +11,12 @@ export const gameTerms = Object.preventExtensions({
FLECT: `flect`,
FRACT: `fract`,
}),
FatePath: [
`North`,
`East`,
`South`,
`West`,
],
FatePath: Object.freeze({
NORTH: `North`,
EAST: `East`,
SOUTH: `South`,
WEST: `West`,
}),
Access: [
`Common`,
`Uncommon`,
@ -37,8 +37,15 @@ export const gameTerms = Object.preventExtensions({
}),
/** The types of items that contribute to the gear limit */
gearItemTypes: new Set([
`ammo`,
`armour`,
`weapon`,
`shield`,
`good`,
]),
WeightRatings: Object.freeze({
LIGHT: `light`,
MODEST: `modest`,
HEAVY: `heavy`,
}),
});

View file

@ -1,16 +1,12 @@
import { handlebarsLocalizer, localizer } from "../utils/Localizer.mjs";
import { formFields } from "./inputs/formFields.mjs";
import { options } from "./options.mjs";
import { toAttributes } from "./toAttributes.mjs";
import { toClasses } from "./toClasses.mjs";
export default {
// #region Complex
"rc-formFields": formFields,
"rc-i18n": handlebarsLocalizer,
"rc-options": options,
"rc-toAttributes": toAttributes,
"rc-toClasses": toClasses,
// #region Simple
"rc-empty-state": (v) => v ?? localizer(`RipCrypt.common.empty`),

View file

@ -0,0 +1,33 @@
import { groupInput } from "./groupInput.mjs";
export function costInput(input, data) {
return groupInput({
title: input.label,
fields: [
{
id: input.id + `-gold`,
type: `integer`,
label: `RipCrypt.common.currency.gold`,
value: input.gold,
path: `system.cost.gold`,
limited: input.limited,
},
{
id: input.id + `-silver`,
type: `integer`,
label: `RipCrypt.common.currency.silver`,
value: input.silver,
path: `system.cost.silver`,
limited: input.limited,
},
{
id: input.id + `-copper`,
type: `integer`,
label: `RipCrypt.common.currency.copper`,
value: input.copper,
path: `system.cost.copper`,
limited: input.limited,
},
],
}, data);
};

View file

@ -1,5 +1,6 @@
import { barInput } from "./barInput.mjs";
import { booleanInput } from "./booleanInput.mjs";
import { costInput } from "./currency.mjs";
import { dropdownInput } from "./dropdownInput.mjs";
import { groupInput } from "./groupInput.mjs";
import { numberInput } from "./numberInput.mjs";
@ -18,6 +19,7 @@ const inputTypes = {
boolean: booleanInput,
group: groupInput,
text: textInput,
cost: costInput,
};
const typesToSanitize = new Set([ `string`, `number` ]);

View file

@ -16,7 +16,7 @@ export function groupInput(input, data) {
data-input-type="group"
var:border-color="${input.borderColor ?? `var(--accent-1)`}"
var:vertical-displacement="${input.verticalDisplacement ?? `12px`}"
var:padding-top="${input.paddingTop ?? `16px`}"
var:padding-top="${input.paddingTop ?? `20px`}"
>
<div slot="title">${title}</div>
<div slot="content" class="content">

View file

@ -1,13 +0,0 @@
/**
* Allows converting an object of <attribute names, value> into HTML
* attribute-value pairs that can be inserted into the DOM
*
* @param {Record<string, any>} obj The object of attributes to their value
*/
export function toAttributes(obj = {}) {
let attributes = [];
for (const [ attr, value] of Object.entries(obj)) {
attributes.push(`${attr}=${Handlebars.escapeExpression(value)}`);
};
return new Handlebars.SafeString(attributes.join(` `));
};

View file

@ -1,14 +0,0 @@
/**
* Allows converting an object of <class names, boolean-likes> into an HTML-compatible class list.
*
* @param {Record<string, any>} obj The object of class names to boolean-like values for if that class should be included.
*/
export function toClasses(obj = {}) {
let classes = [];
for (const [ klass, include ] of Object.entries(obj)) {
if (include) {
classes.push(klass);
};
};
return new Handlebars.SafeString(classes.join(` `));
};

View file

@ -1,16 +1,21 @@
// Applications
import { AllItemSheetV1 } from "../Apps/ItemSheets/AllItemSheetV1.mjs";
import { ArmourSheet } from "../Apps/ItemSheets/ArmourSheet.mjs";
import { CombinedHeroSheet } from "../Apps/ActorSheets/CombinedHeroSheet.mjs";
import { DelveTourApp } from "../Apps/DelveTourApp.mjs";
import { HeroSkillsCardV1 } from "../Apps/ActorSheets/HeroSkillsCardV1.mjs";
import { HeroSummaryCardV1 } from "../Apps/ActorSheets/HeroSummaryCardV1.mjs";
import { TabbedHeroSheet } from "../Apps/ActorSheets/TabbedHeroSheet.mjs";
import { CraftCardV1 } from "../Apps/ActorSheets/CraftCardV1.mjs";
import { DelveDiceHUD } from "../Apps/DelveDiceHUD.mjs";
import { RipCryptCombatTracker } from "../Apps/sidebar/CombatTracker.mjs";
import { SkillsCardV1 } from "../Apps/ActorSheets/SkillsCardV1.mjs";
import { StatsCardV1 } from "../Apps/ActorSheets/StatsCardV1.mjs";
// Data Models
import { AmmoData } from "../data/Item/Ammo.mjs";
import { ArmourData } from "../data/Item/Armour.mjs";
import { CraftData } from "../data/Item/Craft.mjs";
import { GeistData } from "../data/Actor/Geist.mjs";
import { GoodData } from "../data/Item/Good.mjs";
import { HeroData } from "../data/Actor/Hero.mjs";
import { ProtectorData } from "../data/Item/Protector.mjs";
import { ShieldData } from "../data/Item/Shield.mjs";
import { SkillData } from "../data/Item/Skill.mjs";
import { WeaponData } from "../data/Item/Weapon.mjs";
@ -18,21 +23,28 @@ import { WeaponData } from "../data/Item/Weapon.mjs";
import { CryptDie } from "../dice/CryptDie.mjs";
// Documents
import { RipCryptCombat } from "../documents/combat.mjs";
import { RipCryptCombatant } from "../documents/combatant.mjs";
import { RipCryptItem } from "../documents/item.mjs";
import { RipCryptToken } from "../documents/token.mjs";
// Misc
import helpers from "../handlebarHelpers/_index.mjs";
import { Logger } from "../utils/Logger.mjs";
import { registerCustomComponents } from "../Apps/elements/_index.mjs";
import { registerCustomComponents } from "../Apps/components/_index.mjs";
import { registerDevSettings } from "../settings/devSettings.mjs";
import { registerMetaSettings } from "../settings/metaSettings.mjs";
import { registerSockets } from "../sockets/_index.mjs";
import { registerUserSettings } from "../settings/userSettings.mjs";
import { registerWorldSettings } from "../settings/worldSettings.mjs";
const { Items, Actors } = foundry.documents.collections;
Hooks.once(`init`, () => {
Logger.log(`Initializing`);
CONFIG.ui.crypt = DelveTourApp;
CONFIG.Combat.initiative.decimals = 2;
CONFIG.ui.delveDice = DelveDiceHUD;
// #region Settings
registerMetaSettings();
@ -43,26 +55,26 @@ Hooks.once(`init`, () => {
// #region Datamodels
CONFIG.Actor.dataModels.hero = HeroData;
CONFIG.Item.dataModels.ammo = AmmoData,
CONFIG.Item.dataModels.armour = ProtectorData;
CONFIG.Actor.dataModels.geist = GeistData;
CONFIG.Item.dataModels.ammo = AmmoData;
CONFIG.Item.dataModels.armour = ArmourData;
CONFIG.Item.dataModels.craft = CraftData;
CONFIG.Item.dataModels.shield = ProtectorData;
CONFIG.Item.dataModels.good = GoodData;
CONFIG.Item.dataModels.shield = ShieldData;
CONFIG.Item.dataModels.skill = SkillData;
CONFIG.Item.dataModels.weapon = WeaponData;
// #endregion
// #region Class Changes
CONFIG.ui.combat = RipCryptCombatTracker;
CONFIG.Combat.documentClass = RipCryptCombat;
CONFIG.Combatant.documentClass = RipCryptCombatant;
CONFIG.Token.objectClass = RipCryptToken;
CONFIG.Item.documentClass = RipCryptItem;
CONFIG.Dice.terms.d = CryptDie;
// #endregion
// #region Sheets
// Unregister core sheets
/* eslint-disable no-undef */
Items.unregisterSheet(`core`, ItemSheet);
Actors.unregisterSheet(`core`, ActorSheet);
/* eslint-enabled no-undef */
// #region Actors
Actors.registerSheet(game.system.id, CombinedHeroSheet, {
makeDefault: true,
@ -70,21 +82,26 @@ Hooks.once(`init`, () => {
label: `RipCrypt.sheet-names.CombinedHeroSheet`,
themes: CombinedHeroSheet.themes,
});
Actors.registerSheet(game.system.id, TabbedHeroSheet, {
makeDefault: false,
Actors.registerSheet(game.system.id, StatsCardV1, {
types: [`hero`],
label: `RipCrypt.sheet-names.TabbedHeroSheet`,
themes: TabbedHeroSheet.themes,
label: `RipCrypt.sheet-names.StatsCardV1`,
themes: StatsCardV1.themes,
});
Actors.registerSheet(game.system.id, HeroSummaryCardV1, {
types: [`hero`],
label: `RipCrypt.sheet-names.HeroSummaryCardV1`,
themes: HeroSummaryCardV1.themes,
Actors.registerSheet(game.system.id, StatsCardV1, {
makeDefault: true,
types: [`geist`],
label: `RipCrypt.sheet-names.StatsCardV1`,
themes: StatsCardV1.themes,
});
Actors.registerSheet(game.system.id, HeroSkillsCardV1, {
types: [`hero`],
label: `RipCrypt.sheet-names.HeroSkillsCardV1`,
themes: HeroSkillsCardV1.themes,
Actors.registerSheet(game.system.id, SkillsCardV1, {
types: [`hero`, `geist`],
label: `RipCrypt.sheet-names.SkillsCardV1`,
themes: SkillsCardV1.themes,
});
Actors.registerSheet(game.system.id, CraftCardV1, {
types: [`hero`, `geist`],
label: `RipCrypt.sheet-names.CraftCardV1`,
themes: CraftCardV1.themes,
});
// #endregion
@ -94,12 +111,24 @@ Hooks.once(`init`, () => {
label: `RipCrypt.sheet-names.AllItemsSheetV1`,
themes: AllItemSheetV1.themes,
});
Items.registerSheet(game.system.id, ArmourSheet, {
makeDefault: true,
types: [`armour`, `shield`],
label: `RipCrypt.sheet-names.ArmourSheet`,
themes: ArmourSheet.themes,
});
Items.unregisterSheet(game.system.id, AllItemSheetV1, {
types: [`armour`, `shield`],
});
// #endregion
// #endregion
// #region Token Attrs
CONFIG.Actor.trackableAttributes.hero = HeroData.trackableAttributes;
// #endregion
registerSockets();
registerCustomComponents();
Handlebars.registerHelper(helpers);
});

View file

@ -1,3 +1,4 @@
import { filePath } from "../consts.mjs";
import { Logger } from "../utils/Logger.mjs";
Hooks.once(`ready`, () => {
@ -5,20 +6,30 @@ Hooks.once(`ready`, () => {
let defaultTab = game.settings.get(`ripcrypt`, `defaultTab`);
if (defaultTab) {
if (!ui.sidebar?.TABS?.[defaultTab]) {
Logger.error(`Couldn't find a sidebar tab with ID:`, defaultTab);
} else {
try {
Logger.debug(`Switching sidebar tab to:`, defaultTab);
ui.sidebar.activateTab(defaultTab);
ui.sidebar.changeTab(defaultTab, `primary`);
}
catch {
Logger.error(`Failed to change to sidebar tab:`, defaultTab);
};
};
if (game.settings.get(`ripcrypt`, `devMode`)) {
ui.sidebar.expand();
if (game.paused) { game.togglePause() };
if (game.paused) { game.togglePause(false, { broadcast: true }) };
};
if (game.settings.get(`ripcrypt`, `showDelveTour`)) {
ui.crypt.render({ force: true });
ui.delveDice.render({ force: true });
// MARK: 1-time updates
if (!game.settings.get(`ripcrypt`, `firstLoadFinished`)) {
// Update the turnMarker to be the RipCrypt defaults
const combatConfig = game.settings.get(`core`, `combatTrackerConfig`);
combatConfig.turnMarker.src = filePath(`assets/turn-marker.png`);
combatConfig.turnMarker.animation = `spinPulse`;
game.settings.set(`core`, `combatTrackerConfig`, combatConfig);
};
game.settings.set(`ripcrypt`, `firstLoadFinished`, true);
});

View file

@ -1,11 +1,61 @@
import { gameTerms } from "../gameTerms.mjs";
const { StringField } = foundry.data.fields;
const { FatePath } = gameTerms;
export function registerMetaSettings() {
game.settings.register(`ripcrypt`, `dc`, {
scope: `world`,
type: Number,
default: 5,
config: false,
requiresReload: false,
onChange: () => {
ui.crypt.render({ parts: [ `delveConditions` ]});
ui.delveDice.render({ parts: [`difficulty`] });
},
});
game.settings.register(`ripcrypt`, `sandsOfFate`, {
scope: `world`,
type: Number,
default: 8,
config: false,
requiresReload: false,
onChange: async () => {
ui.delveDice.animate({ parts: [`sandsOfFate`] });
},
});
game.settings.register(`ripcrypt`, `currentFate`, {
scope: `world`,
type: new StringField({
blank: false,
nullable: false,
initial: FatePath.NORTH,
}),
config: false,
requiresReload: false,
onChange: async () => {
ui.delveDice.animate({ parts: [`fateCompass`] });
},
});
game.settings.register(`ripcrypt`, `whoFirst`, {
scope: `world`,
type: String,
config: false,
requiresReload: false,
default: `friendly`,
onChange: async () => {
await game.combat?.setupTurns();
await ui.combat.render({ parts: [ `tracker` ] });
},
});
game.settings.register(`ripcrypt`, `firstLoadFinished`, {
scope: `world`,
type: Boolean,
default: false,
requiresReload: false,
});
};

View file

@ -1,20 +1,20 @@
export function registerUserSettings() {
const userScope = game.release.generation >= 13 ? `user` : `client`;
/* ! Non-Functional
game.settings.register(`ripcrypt`, `abbrAccess`, {
name: `RipCrypt.setting.abbrAccess.name`,
hint: `RipCrypt.setting.abbrAccess.hint`,
scope: userScope,
scope: `user`,
type: Boolean,
config: true,
default: false,
requiresReload: false,
});
*/
game.settings.register(`ripcrypt`, `condensedRange`, {
name: `RipCrypt.setting.condensedRange.name`,
hint: `RipCrypt.setting.condensedRange.hint`,
scope: userScope,
scope: `user`,
type: Boolean,
config: true,
default: true,

View file

@ -1,10 +1,52 @@
const { NumberField, StringField } = foundry.data.fields;
export function registerWorldSettings() {
game.settings.register(`ripcrypt`, `showDelveTour`, {
name: `Delve Tour Popup`,
game.settings.register(`ripcrypt`, `sandsOfFateInitial`, {
name: `RipCrypt.setting.sandsOfFateInitial.name`,
hint: `RipCrypt.setting.sandsOfFateInitial.hint`,
scope: `world`,
type: Boolean,
config: true,
default: true,
requiresReload: false,
type: new NumberField({
required: true,
min: 1,
step: 1,
max: 10,
initial: 8,
}),
onChange: async (newInitialSands) => {
const currentSands = game.settings.get(`ripcrypt`, `sandsOfFate`);
if (newInitialSands <= currentSands) {
game.settings.set(`ripcrypt`, `sandsOfFate`, newInitialSands);
};
},
});
game.settings.register(`ripcrypt`, `onCrypticEvent`, {
name: `RipCrypt.setting.onCrypticEvent.name`,
hint: `RipCrypt.setting.onCrypticEvent.hint`,
scope: `world`,
config: true,
requiresReload: false,
type: new StringField({
required: true,
initial: `notif`,
choices: {
"notif": `RipCrypt.setting.onCrypticEvent.options.notif`,
"pause": `RipCrypt.setting.onCrypticEvent.options.pause`,
"both": `RipCrypt.setting.onCrypticEvent.options.both`,
"nothing": `RipCrypt.setting.onCrypticEvent.options.nothing`,
},
}),
});
game.settings.register(`ripcrypt`, `allowUpdateSandsSocket`, {
name: `RipCrypt.setting.allowUpdateSandsSocket.name`,
hint: `RipCrypt.setting.allowUpdateSandsSocket.hint`,
scope: `world`,
config: true,
requiresReload: false,
type: Boolean,
default: true,
});
};

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

@ -0,0 +1,29 @@
import { localizer } from "../utils/Localizer.mjs";
import { Logger } from "../utils/Logger.mjs";
import { notify } from "./notify.mjs";
import { updateSands } from "./updateSands.mjs";
const events = {
notify,
updateSands,
};
export function registerSockets() {
Logger.info(`Setting up socket listener`);
game.socket.on(`system.ripcrypt`, (data, userID) => {
const { event, payload } = data ?? {};
if (event == null || payload === undefined) {
ui.notifications.error(localizer(`RipCrypt.notifs.error.invalid-socket`));
return;
};
if (events[event] == null) {
ui.notifications.error(localizer(`RipCrypt.notifs.error.unknown-socket-event`, { event }));
return;
};
const user = game.users.get(userID);
events[event](payload, user);
});
};

56
module/sockets/notify.mjs Normal file
View file

@ -0,0 +1,56 @@
import { localizer } from "../utils/Localizer.mjs";
export function notify(payload) {
// #region Payload Validity
const {
message,
users = [],
type = `info`,
permanent = false,
} = payload;
if (!message) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.malformed-socket-payload`,
{
event: `notify`,
details: `A message must be provided`,
},
));
return;
};
if (users && !Array.isArray(users)) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.malformed-socket-payload`,
{
event: `notify`,
details: `"users" must be an array of user IDs`,
},
));
return;
};
if (![`info`, `error`, `success`].includes(type)) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.malformed-socket-payload`,
{
event: `notify`,
details: `An invalid notification type was provided.`,
},
));
return;
}
// #endregion Payload Validity
// Act
if (users.length === 0 || users.includes(game.user.id)) {
ui.notifications[type]?.(
localizer(message),
{
console: false,
permanent,
},
);
};
};

View file

@ -0,0 +1,38 @@
import { clamp } from "../utils/clamp.mjs";
import { localizer } from "../utils/Localizer.mjs";
export function updateSands(payload) {
if (!game.user.isActiveGM) { return };
if (!game.settings.get(game.system.id, `allowUpdateSandsSocket`)) { return };
// Assert payload validity
const { value, delta } = payload;
if (value == null && delta == null) {
ui.notifications.error(localizer(
`RipCrypt.notifs.error.malformed-socket-payload`,
{
event: `updateSands`,
details: `Either value or delta must be provided`,
},
));
return;
};
// Take action
if (value != null) {
const initial = game.settings.get(game.system.id, `sandsOfFateInitial`);
let sands = clamp(0, value, initial);
if (sands === 0) {
ui.delveDice.alertCrypticEvent();
sands = initial;
};
game.settings.set(
game.system.id,
`sandsOfFate`,
sands,
);
}
else if (delta != null) {
ui.delveDice.sandsOfFateDelta(delta);
};
};

View file

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

View file

@ -0,0 +1,184 @@
import { getTooltipDelay } from "../consts.mjs";
import { Logger } from "./Logger.mjs";
export class PopoverEventManager {
#options;
#id;
get id() {
return this.#id;
};
/** @type {Map<string, PopoverEventManager>} */
static #existing = new Map();
/**
* @param {HTMLElement} element The element to attach the listeners to.
* @param {GenericPopoverMixin} popoverClass The class reference that represents the popover app
*/
constructor(id, element, popoverClass, options = {}) {
id = `${id}-${popoverClass.name}`;
this.#id = id;
if (PopoverEventManager.#existing.has(id)) {
const manager = PopoverEventManager.#existing.get(id);
manager.#addListeners(element);
return manager;
};
options.managerId = id;
options.locked ??= false;
options.lockable ??= true;
this.#options = options;
this.#element = element;
this.#class = popoverClass;
this.#addListeners(element);
PopoverEventManager.#existing.set(id, this);
};
/**
* @param {HTMLElement} element
*/
#addListeners(element) {
element.addEventListener(`pointerenter`, this.#pointerEnterHandler.bind(this));
element.addEventListener(`pointerout`, this.#pointerOutHandler.bind(this));
element.addEventListener(`click`, this.#clickHandler.bind(this));
if (this.#options.lockable) {
element.addEventListener(`pointerup`, this.#pointerUpHandler.bind(this));
};
};
destroy() {
this.close();
this.#element.removeEventListener(`pointerenter`, this.#pointerEnterHandler);
this.#element.removeEventListener(`pointerout`, this.#pointerOutHandler);
this.#element.removeEventListener(`click`, this.#clickHandler);
if (this.#options.lockable) {
this.#element.removeEventListener(`pointerup`, this.#pointerUpHandler);
};
this.#stopOpen();
this.#stopClose();
};
close() {
this.#frameless?.close({ force: true });
this.#framed?.close({ force: true });
};
#stopOpen() {
if (this.#openTimeout != null) {
clearTimeout(this.#openTimeout);
this.#openTimeout = null;
};
};
#stopClose() {
if (this.#closeTimeout != null) {
clearTimeout(this.#closeTimeout);
this.#closeTimeout = null;
}
};
get rendered() {
return Boolean(this.#frameless?.rendered || this.#framed?.rendered);
};
render(options) {
if (this.#framed?.rendered) {
this.#framed.render(options);
};
if (this.#frameless?.rendered) {
this.#frameless.render(options);
};
};
#element;
#class;
#openTimeout = null;
#closeTimeout = null;
#frameless;
#framed;
#construct(options) {
options.popover ??= {};
options.popover.managerId = this.#id;
return new this.#class(options);
};
#clickHandler() {
Logger.debug(`click event handler`);
// Cleanup for the frameless lifecycle
this.#stopOpen();
this.#stopClose();
this.#frameless?.close({ force: true });
if (!this.#framed) {
this.#framed = this.#construct({ popover: { ...this.#options, framed: true } });
}
this.#framed?.render({ force: true });
};
#pointerEnterHandler(event) {
this.#stopClose();
const pos = event.target.getBoundingClientRect();
const x = pos.x + Math.floor(pos.width / 2);
const y = pos.y;
this.#openTimeout = setTimeout(
() => {
this.#openTimeout = null;
// When we have the framed version rendered, we might as well just focus
// it instead of rendering a new application
if (this.#framed?.rendered) {
this.#framed.bringToFront();
return;
};
// When the frameless is already rendered, we should just move it to the
// new location instead of spawning a new one
if (this.#frameless?.rendered) {
const { width, height } = this.#frameless.element.getBoundingClientRect();
const top = y - height;
const left = x - Math.floor(width / 2);
this.#frameless.setPosition({ left, top });
return;
}
this.#frameless = this.#construct({
popover: {
...this.#options,
framed: false,
x, y,
},
});
this.#frameless?.render({ force: true });
},
getTooltipDelay(),
);
};
#pointerOutHandler() {
this.#stopOpen();
this.#closeTimeout = setTimeout(
() => {
this.#closeTimeout = null;
this.#frameless?.close();
},
getTooltipDelay(),
);
};
#pointerUpHandler(event) {
if (event.button !== 1 || !this.#frameless?.rendered || Tour.tourInProgress) { return };
event.preventDefault();
this.#frameless.toggleLock();
};
};

3
module/utils/clamp.mjs Normal file
View file

@ -0,0 +1,3 @@
export function clamp(min, ideal, max) {
return Math.max(min, Math.min(ideal, max));
};

51
module/utils/fates.mjs Normal file
View file

@ -0,0 +1,51 @@
import { gameTerms } from "../gameTerms.mjs";
import { Logger } from "./Logger.mjs";
const { FatePath } = gameTerms;
export function isOppositeFates(a, b) {
return (a === FatePath.NORTH && b === FatePath.SOUTH)
|| (a === FatePath.EAST && b === FatePath.WEST);
};
export function distanceBetweenFates(start, end) {
if (!start || !end) {
Logger.error(`Start and End must both have a defined value, given`, {start, end});
return undefined;
};
if (start === end) {
return 0;
};
if (isOppositeFates(start, end) || isOppositeFates(end, start)) {
return 2;
};
let isForward = start === FatePath.SOUTH && end === FatePath.WEST;
isForward ||= start === FatePath.NORTH && end === FatePath.EAST;
isForward ||= start === FatePath.WEST && end === FatePath.NORTH;
isForward ||= start === FatePath.EAST && end === FatePath.SOUTH;
if (isForward) {
return 1;
};
return 3;
};
const fateOrder = [
FatePath.WEST, // to make the .find not integer overflow
FatePath.NORTH,
FatePath.EAST,
FatePath.SOUTH,
FatePath.WEST,
];
export function nextFate(fate) {
const fateIndex = fateOrder.findIndex(f => f === fate);
return fateOrder[fateIndex + 1];
};
export function previousFate(fate) {
const fateIndex = fateOrder.lastIndexOf(fate);
return fateOrder[fateIndex - 1];
};

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

@ -0,0 +1,13 @@
import { gameTerms } from "../gameTerms.mjs";
/**
* Converts a rank's name into an integer form for use in mathematical calculations
* that rely on rank.
*
* @param {Novice|Adept|Expert|Master} rankName The rank to convert into an integer
* @returns An integer between 1 and 4
*/
export function rankToInteger(rankName) {
return Object.values(gameTerms.Rank)
.findIndex(r => r === rankName) + 1;
};

14
package-lock.json generated
View file

@ -8,6 +8,7 @@
"@eslint/js": "^9.16.0",
"@foundryvtt/foundryvtt-cli": "^1.0.3",
"@stylistic/eslint-plugin": "^2.12.0",
"dotenv": "^17.2.3",
"eslint": "^9.16.0"
}
},
@ -775,6 +776,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dotenv": {
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz",

View file

@ -3,9 +3,13 @@
"@eslint/js": "^9.16.0",
"@foundryvtt/foundryvtt-cli": "^1.0.3",
"@stylistic/eslint-plugin": "^2.12.0",
"dotenv": "^17.2.3",
"eslint": "^9.16.0"
},
"scripts": {
"data:build": "node scripts/buildCompendia.mjs",
"data:extract": "node scripts/extractCompendia.mjs",
"link": "node scripts/linkFoundry.mjs",
"lint": "eslint --fix",
"lint:nofix": "eslint"
}

View file

@ -0,0 +1,23 @@
{
"type": "Item",
"folder": null,
"name": "Armour",
"color": "#04262a",
"sorting": "m",
"_id": "pZxc6QLgVWfnZlf7",
"description": "",
"sort": 0,
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994081362,
"modifiedTime": 1759994081362,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!folders!pZxc6QLgVWfnZlf7"
}

View file

@ -0,0 +1,41 @@
{
"folder": "BsNUpCnwmlhOWBhZ",
"name": "Breastplate",
"type": "armour",
"_id": "KQ6uyTPUOHuMTxDF",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 90,
"copper": null
},
"protection": 3,
"location": [
"body"
],
"equipped": false,
"weight": "heavy",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994459142,
"modifiedTime": 1759994468351,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!KQ6uyTPUOHuMTxDF"
}

View file

@ -0,0 +1,21 @@
{
"type": "Item",
"folder": "pZxc6QLgVWfnZlf7",
"name": "Heavy",
"color": "#06393f",
"sorting": "a",
"_id": "BsNUpCnwmlhOWBhZ",
"description": "",
"sort": 0,
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"lastModifiedBy": null
},
"_key": "!folders!BsNUpCnwmlhOWBhZ"
}

View file

@ -0,0 +1,44 @@
{
"folder": "RXPJBkzVxFnoT3Tm",
"name": "Heavy Shields",
"type": "shield",
"_id": "uUrCwjxV6Ihisb6V",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 50,
"copper": null
},
"protection": 1,
"location": [
"head",
"body",
"arms",
"legs"
],
"equipped": false,
"weight": "heavy",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994801184,
"modifiedTime": 1759994810086,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!uUrCwjxV6Ihisb6V"
}

View file

@ -0,0 +1,41 @@
{
"folder": "HRwiz1c1ZcQyPu4z",
"name": "Leather Cap",
"type": "armour",
"_id": "JMkV8kMnCXhW5KDh",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 10,
"copper": null
},
"protection": 1,
"location": [
"head"
],
"equipped": false,
"weight": "light",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994118194,
"modifiedTime": 1759994130845,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!JMkV8kMnCXhW5KDh"
}

View file

@ -0,0 +1,41 @@
{
"folder": "HRwiz1c1ZcQyPu4z",
"name": "Leather, Hide Bracers",
"type": "armour",
"_id": "nz4DXXR4iU9CeMRA",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 10,
"copper": null
},
"protection": 1,
"location": [
"arms"
],
"equipped": false,
"weight": "light",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994170968,
"modifiedTime": 1759994180395,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!nz4DXXR4iU9CeMRA"
}

View file

@ -0,0 +1,41 @@
{
"folder": "HRwiz1c1ZcQyPu4z",
"name": "Leather, Hide Jacket",
"type": "armour",
"_id": "zMyxSJ6VpaH3ddOO",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 20,
"copper": null
},
"protection": 1,
"location": [
"body"
],
"equipped": false,
"weight": "light",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994151324,
"modifiedTime": 1759994160761,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!zMyxSJ6VpaH3ddOO"
}

View file

@ -0,0 +1,41 @@
{
"folder": "HRwiz1c1ZcQyPu4z",
"name": "Leather, Hide Leggings",
"type": "armour",
"_id": "14Omu9q2sMxW8GWB",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 20,
"copper": null
},
"protection": 1,
"location": [
"legs"
],
"equipped": false,
"weight": "light",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994190989,
"modifiedTime": 1759994198011,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!14Omu9q2sMxW8GWB"
}

View file

@ -0,0 +1,21 @@
{
"type": "Item",
"folder": "pZxc6QLgVWfnZlf7",
"name": "Light",
"color": "#06393f",
"sorting": "a",
"_id": "HRwiz1c1ZcQyPu4z",
"description": "",
"sort": 0,
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"lastModifiedBy": null
},
"_key": "!folders!HRwiz1c1ZcQyPu4z"
}

View file

@ -0,0 +1,42 @@
{
"folder": "RXPJBkzVxFnoT3Tm",
"name": "Light Shields",
"type": "shield",
"_id": "a6vPAa25z8L9t79K",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 20,
"copper": null
},
"protection": 1,
"location": [
"head",
"arms"
],
"equipped": false,
"weight": "light",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994549164,
"modifiedTime": 1759994761998,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!a6vPAa25z8L9t79K"
}

View file

@ -0,0 +1,42 @@
{
"folder": "cKN149ZGLqfyt0oi",
"name": "Mail, Link, Scale Coat",
"type": "armour",
"_id": "Sr40RFsPr2M0bTKK",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 180,
"copper": null
},
"protection": 2,
"location": [
"body",
"arms"
],
"equipped": false,
"weight": "modest",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994257751,
"modifiedTime": 1759994294312,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!Sr40RFsPr2M0bTKK"
}

View file

@ -0,0 +1,41 @@
{
"folder": "cKN149ZGLqfyt0oi",
"name": "Mail, Link, Scale Coif",
"type": "armour",
"_id": "HfG5Doxf7576Jgbt",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 180,
"copper": null
},
"protection": 2,
"location": [
"head"
],
"equipped": false,
"weight": "modest",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994210701,
"modifiedTime": 1759994221462,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!HfG5Doxf7576Jgbt"
}

View file

@ -0,0 +1,41 @@
{
"folder": "cKN149ZGLqfyt0oi",
"name": "Mail, Link, Scale Leggings",
"type": "armour",
"_id": "YBpElIVQ534pm3Mf",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 200,
"copper": null
},
"protection": 2,
"location": [
"legs"
],
"equipped": false,
"weight": null,
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994280754,
"modifiedTime": 1759994424980,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!YBpElIVQ534pm3Mf"
}

View file

@ -0,0 +1,41 @@
{
"folder": "cKN149ZGLqfyt0oi",
"name": "Mail, Link, Scale Shirt",
"type": "armour",
"_id": "wab6Bo8ngar4mBCN",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 90,
"copper": null
},
"protection": 2,
"location": [
"body"
],
"equipped": false,
"weight": "modest",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994235204,
"modifiedTime": 1759994246578,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!wab6Bo8ngar4mBCN"
}

View file

@ -0,0 +1,43 @@
{
"folder": "RXPJBkzVxFnoT3Tm",
"name": "Modest Shields",
"type": "shield",
"_id": "fyL8LZ8jpEQbjpM2",
"img": "icons/svg/item-bag.svg",
"system": {
"quantity": 1,
"cost": {
"gold": null,
"silver": 30,
"copper": null
},
"protection": 1,
"location": [
"head",
"body",
"arms"
],
"equipped": false,
"weight": "modest",
"access": ""
},
"effects": [],
"sort": 0,
"ownership": {
"default": 0,
"9x9FgB0YTeCJJUDK": 3
},
"flags": {},
"_stats": {
"compendiumSource": null,
"duplicateSource": null,
"exportSource": null,
"coreVersion": "13.350",
"systemId": "ripcrypt",
"systemVersion": "0.2.0",
"createdTime": 1759994777609,
"modifiedTime": 1759994784898,
"lastModifiedBy": "9x9FgB0YTeCJJUDK"
},
"_key": "!items!fyL8LZ8jpEQbjpM2"
}

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