Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bf76f5b9 | ||
|
|
35642c43ba |
@@ -1,4 +1,4 @@
|
||||
name: Build and Attach Foundry World Zip
|
||||
name: Build and Attach Foundry Package Zips
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -6,19 +6,24 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-release-zip:
|
||||
build-release-zips:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build zip
|
||||
- name: Build zips
|
||||
run: |
|
||||
mkdir -p dist
|
||||
zip -r dist/sla-brp.zip sla-brp -x "*.DS_Store"
|
||||
zip -r dist/sla-industries-brp.zip sla-industries-brp -x "*.DS_Store"
|
||||
zip -r dist/sla-industries-compendium.zip sla-industries-compendium -x "*.DS_Store"
|
||||
|
||||
- name: Upload to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/sla-brp.zip
|
||||
files: |
|
||||
dist/sla-brp.zip
|
||||
dist/sla-industries-brp.zip
|
||||
dist/sla-industries-compendium.zip
|
||||
|
||||
@@ -1,51 +1,63 @@
|
||||
# SLA BRP Foundry World
|
||||
# SLA BRP Foundry Packages
|
||||
|
||||
Installable Foundry VTT world package for `sla-brp`.
|
||||
Installable Foundry VTT package repo for:
|
||||
|
||||
- System: `sla-industries-brp`
|
||||
- Module: `sla-industries-compendium` (companion content and artwork)
|
||||
- World: `sla-brp`
|
||||
|
||||
## Install URLs
|
||||
|
||||
- System manifest:
|
||||
`https://raw.githubusercontent.com/slaguru666/sla_industries_brp/main/sla-industries-brp/system.json`
|
||||
- Module manifest:
|
||||
`https://raw.githubusercontent.com/slaguru666/sla_industries_brp/main/sla-industries-compendium/module.json`
|
||||
- World manifest:
|
||||
`https://raw.githubusercontent.com/slaguru666/sla_industries_brp/main/sla-brp/world.json`
|
||||
|
||||
## Important Install Order
|
||||
|
||||
1. Install **System** `sla-industries-brp` first.
|
||||
2. Install **Module** `sla-industries-compendium` second.
|
||||
3. Install **World** `sla-brp` optionally (or create a new world with the system).
|
||||
|
||||
If the system is not installed first, Foundry marks the world unavailable.
|
||||
|
||||
## Populate A Fresh World
|
||||
|
||||
In a new `sla-industries-brp` world with the companion module enabled, run this as a GM script macro:
|
||||
|
||||
```js
|
||||
const { SLASeedImporter } = await import("/systems/sla-industries-brp/module/apps/sla-seed-importer.mjs");
|
||||
await SLASeedImporter.buildDraft2({ overwrite: false, syncCompendia: true });
|
||||
```
|
||||
|
||||
## Repo Layout
|
||||
|
||||
- `sla-brp/` - The world content directory (must contain `world.json`).
|
||||
- `scripts/build-release.sh` - Builds `dist/sla-brp.zip` for Foundry install.
|
||||
|
||||
## One-time Setup
|
||||
|
||||
1. Create a GitHub repo and push this folder.
|
||||
2. Update these fields in `sla-brp/world.json`:
|
||||
- `manifest`
|
||||
- `download`
|
||||
- `url`
|
||||
|
||||
Use:
|
||||
|
||||
- `manifest`: `https://raw.githubusercontent.com/slaguru666/sla_industries_brp/main/sla-brp/world.json`
|
||||
- `download`: `https://github.com/slaguru666/sla_industries_brp/releases/latest/download/sla-brp.zip`
|
||||
- `url`: `https://github.com/slaguru666/sla_industries_brp`
|
||||
- `sla-industries-brp/` - system content (`system.json`)
|
||||
- `sla-industries-compendium/` - companion module content (`module.json`, `sla-data`, compendium packs, artwork)
|
||||
- `sla-brp/` - world content (`world.json`)
|
||||
- `scripts/build-release.sh` - builds all release zips into `dist/`
|
||||
|
||||
## Release Steps
|
||||
|
||||
1. Update `sla-brp/world.json` `version`.
|
||||
2. Build zip locally:
|
||||
1. Update versions in:
|
||||
- `sla-industries-brp/system.json`
|
||||
- `sla-industries-compendium/module.json`
|
||||
- `sla-brp/world.json`
|
||||
2. Build zips:
|
||||
|
||||
```bash
|
||||
./scripts/build-release.sh
|
||||
```
|
||||
|
||||
3. Create a GitHub release and upload `dist/sla-brp.zip` as an asset.
|
||||
- If you publish a GitHub release event, the included workflow can auto-attach `dist/sla-brp.zip`.
|
||||
|
||||
## Install in Foundry
|
||||
|
||||
In Foundry setup screen:
|
||||
|
||||
1. Open **Game Worlds**.
|
||||
2. Click **Install World**.
|
||||
3. Paste manifest URL:
|
||||
|
||||
`https://raw.githubusercontent.com/slaguru666/sla_industries_brp/main/sla-brp/world.json`
|
||||
|
||||
4. Install.
|
||||
3. Create a GitHub release and attach:
|
||||
- `dist/sla-industries-brp.zip`
|
||||
- `dist/sla-industries-compendium.zip`
|
||||
- `dist/sla-brp.zip`
|
||||
|
||||
## Notes
|
||||
|
||||
- This world requires system ID: `sla-industries-brp`.
|
||||
- Keep world assets and data inside `sla-brp/` so the zip stays installable.
|
||||
- World `sla-brp` depends on system ID `sla-industries-brp`.
|
||||
- Companion module contains `sla-data/*.json` plus artwork under `assets/SLA_Assets`.
|
||||
- Download URLs use GitHub `releases/latest/download/...`, so the latest release must include all three zip assets.
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WORLD_ID="sla-brp"
|
||||
OUT_DIR="$ROOT_DIR/dist"
|
||||
ZIP_PATH="$OUT_DIR/${WORLD_ID}.zip"
|
||||
|
||||
mkdir -p "$OUT_DIR"
|
||||
rm -f "$ZIP_PATH"
|
||||
rm -f "$OUT_DIR/sla-brp.zip" "$OUT_DIR/sla-industries-brp.zip" "$OUT_DIR/sla-industries-compendium.zip"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
zip -r "$ZIP_PATH" "$WORLD_ID" -x "*.DS_Store"
|
||||
zip -r "$OUT_DIR/sla-brp.zip" sla-brp -x "*.DS_Store"
|
||||
zip -r "$OUT_DIR/sla-industries-brp.zip" sla-industries-brp -x "*.DS_Store"
|
||||
zip -r "$OUT_DIR/sla-industries-compendium.zip" sla-industries-compendium -x "*.DS_Store"
|
||||
|
||||
echo "Created $ZIP_PATH"
|
||||
echo "Created:"
|
||||
echo " $OUT_DIR/sla-brp.zip"
|
||||
echo " $OUT_DIR/sla-industries-brp.zip"
|
||||
echo " $OUT_DIR/sla-industries-compendium.zip"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "sla-brp",
|
||||
"title": "SLA BRP",
|
||||
"description": "A prebuilt SLA BRP world for Foundry Virtual Tabletop.",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"authors": [
|
||||
{
|
||||
"name": "timevans"
|
||||
@@ -15,7 +15,7 @@
|
||||
"maximum": "13"
|
||||
},
|
||||
"system": "sla-industries-brp",
|
||||
"systemVersion": "13.1.52",
|
||||
"systemVersion": "13.1.53",
|
||||
"manifest": "https://raw.githubusercontent.com/slaguru666/sla_industries_brp/main/sla-brp/world.json",
|
||||
"download": "https://github.com/slaguru666/sla_industries_brp/releases/latest/download/sla-brp.zip",
|
||||
"url": "https://github.com/slaguru666/sla_industries_brp"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{hbs,html,svg}]
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1 @@
|
||||
packs/** binary
|
||||
@@ -0,0 +1,12 @@
|
||||
# IDE
|
||||
.idea/
|
||||
.vs/
|
||||
|
||||
# Node Modules
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
|
||||
# Foundry
|
||||
*.lock
|
||||
jsconfig.json
|
||||
foundry
|
||||
@@ -0,0 +1,343 @@
|
||||
# CHANGE LOG
|
||||
|
||||
## 13.1.52
|
||||
- Changes to CCI: Open Document (James B)
|
||||
- Wound sheet no longer auto closes when making edits
|
||||
|
||||
## 13.1.51
|
||||
- Missing BRPIDs on actors in world shouldn't stop NPCs being imported
|
||||
- Active Effects added to wounds
|
||||
- Minor change to tlayout of skills tab on character sheet when unlocked - Cat Bonus is now included in total
|
||||
|
||||
## 13.1.50
|
||||
- Additional CCI option, Ambient Light toggle added
|
||||
- All CCI show/hide options have become show/hide/toggle
|
||||
|
||||
## 13.1.49
|
||||
- Additional Chaosium Canvas Interface options added
|
||||
|
||||
## 13.1.48
|
||||
- French language pack added thanks to Vonv
|
||||
- A default scene will be created for new worlds (to help with using GM tools) - thanks to James B
|
||||
- A Dark/Light mode macro has been added (to help with testing) - thanks to James B
|
||||
|
||||
## 13.1.47
|
||||
- Chaosium Canvas Interface Added thanks to James B
|
||||
|
||||
## 13.1.46
|
||||
- Fixed character sheet so the box doesn't cut through CHA when not using EDU
|
||||
- Added a game option under "Character Options" to opt to use wealth ratings (default) or use a cash value along with a label for it
|
||||
- There is now a "+" icon on the Character Sheet Sorcery and Magic tabs that will let you add a spell directly to the character sheet
|
||||
|
||||
## 13.1.45
|
||||
- Spanish Language pack added courtesy of Adrian_Martin(bakali77)
|
||||
|
||||
## 13.1.44
|
||||
- (Hopefully) this is updated for Foundry V13.
|
||||
- Please note this version does not support Foundry V12 - please stay on BRP v12.1.43 if you haven't moved to Foundry v13
|
||||
- And please do back up your world before migtation in case you need to roll back
|
||||
- The system is stil on Application v1 - the move to v2 will happen in due course
|
||||
- Please report any issues at https://github.com/Genii-Locorum/brp/issues
|
||||
|
||||
## 12.1.43
|
||||
- Active Effects have been added to Weapons, Armour and Gear. You can read more at https://github.com/Genii-Locorum/brp/wiki/Active-Effects
|
||||
- Active Effects do work for Unlinked Actors - when Foundry fix the underlying issue we may see duplicate effects until we remove the fix.
|
||||
- There is now an Active Effects tab on these item sheets - GMs can edit effects, Players can only view the current effects.
|
||||
- Weapons and Gear must be carried for the effects to apply, Armour must be worn
|
||||
- There is a limited list of active effects - these can be added to - please let me have your feedback if there are more to be added
|
||||
- You can add bonuses to skills, magic and psychic abilities by manually adding a customised data path - see the Wiki for more info on this.
|
||||
- There is now an "effects" tab on the character sheet which lists the effects and the item they are attached to. Click on the Source Item name to open the item sheet.
|
||||
- This is all the hard work of JamesB. Thank you so much for this.
|
||||
|
||||
## 12.1.42
|
||||
- Error witn Item "HP and PP" labels showing as undefined fixed.
|
||||
- Damage to the chest now triggers the correct statuses
|
||||
- There are icons to the left and right of Hit Points, the sword adds a wound, the heart heals a wound.
|
||||
- For powers (Magic, Sorcery, Superpower etc) the context menu now has the option to send the item description to the chat window.
|
||||
- When power roles (Magic, Psychic) are made then the description is also shown in the "hidden" section of the roll. This only applies to Normal rolls at the moment.
|
||||
- There's a new Combat game setting that let's you have a "Quick Combat" roll. This is a non-oppossed combat roll that also shows the damage that would be caused for each success level
|
||||
- There is now a Custom Resource in the "Optional Rules" menu - you can turn it on/off and give it a label. It will then appear on the actor sheet. There is no automation added.
|
||||
- There is now a Fatigue Point modifier on actors.
|
||||
|
||||
## 12.1.41
|
||||
- Slight tweak to roll cards to centre the first line next to the actor image
|
||||
- A lot more options to adjust the character sheet - https://github.com/Genii-Locorum/brp/wiki/Game-Settings:-Display-Options
|
||||
- The character sheet logo setting has moved to the Display Options
|
||||
- The character sheet visual adjustments are a work in progress - please feedback any other changes needed or areas where the expected results aren't working.
|
||||
- Instead of selecting to use Magic Points rather than Power Points you can now add a name and abbreviation instead of Power Points/PP - see the Optional Game Rules settings
|
||||
- You can also change the name and abbreviations for Hit Points and Fatigue Points.
|
||||
- BRP IDs can be automatically added for Items now - see the BRPID game settings (Actor BRPID has moved here from Char Settings)
|
||||
|
||||
## 12.1.40
|
||||
- Roll chat cards updated to increase size of the actor image and the space for the skill, weapon etc name and there is some additional info in the "expandable" section of the card
|
||||
- There is now a game setting to choose if you want to show XP & POW Improvement Dice Rolls where you have Dice So Nice module activated. You could see a lot of dice rolled.
|
||||
- POW improvement rolls no longer increase POW when the roll is failed (oops).
|
||||
- When dropping professions etc you should no longer get console errors if actor items don't have BRPIDs.
|
||||
- There's a new game setting, which if activated, autogenerates the BRP ID for new actors based on the name. Initially the BRPID fingerprint icon will be orange to warn you.
|
||||
- Special Weapon Damages are now being applied properly
|
||||
- Game Settings have been reorganised (the list was getting too long) in to several sub-forms - click on the relevant button to open the subform
|
||||
- There is a new advances skill category calculation - Negative Secondary - like Secondary but for stat values less than 10
|
||||
- Where you've added a different label to a Power in game settings this also applies to the list on the relevant character sheet tab and the item sheet.
|
||||
- Under BRP Game Settings > Display Options there is now two Colour settings to change the main background and secondary background colours on the actor sheet. Enter a colour reference such as rgba(0,141,142) or #008080.
|
||||
These are sort of a test to see if it's what people want. I will expand the list if it is.
|
||||
|
||||
## 12.1.39
|
||||
- Personal Skill Points are now totalled on the Skills tab, in unlocked mode, under the correct column
|
||||
- MOVE score added to culture item and added to character sheet along with the culture
|
||||
- On the character sheet there is now an option under the Skills tab context menu to toggle how the skills are displayed
|
||||
- When adding a profession to the character you now choose the wealth level. This can be edited when the character sheet is unlocked.
|
||||
- On the "CHAR" tab the Redistribute values are no longer manually entered. When in "Development Mode" and with the character sheet unlocked use the arrows by the
|
||||
stats to redistribute stats. You can only redistribute 3 points and there are min/maxes in place. Unless the redistributed points net to nil the column is red
|
||||
- Age has been added to the character sheet neer the top left of the Personal Section
|
||||
- Clicking on the "Impact" of a Magic Spell or a Psychic Ability on the actor sheet causes an Impact (Damage or Healing) dice roll
|
||||
- When adding a skill, profession, personality etc the system will check if you have the Skill Category on the character and if not it will add it automatically
|
||||
- There are two game settings for the initiative formula - select a stat (or none) and enter a modifier. If your formula is invalid you will get an error message when your world starts and the default formula is used
|
||||
|
||||
|
||||
## 12.1.38
|
||||
- Context menu for Psychic Abilities now shows XP improvement options when there is an improvement tick and development mode is on
|
||||
- All improvement checks exclude the item.system.effects score
|
||||
- Psychic Ability item sheet, when owned, now shows the Improvement button which can be toggled on and off
|
||||
- Personality Traits now have a "Starter Trait" toggle.
|
||||
- There is a new game setting "Starter Traits". If turned on then any new character will, if Personality Traits are used, automatically start with
|
||||
any trait which has the "Starter Trait" toggle activated.
|
||||
|
||||
## 12.1.37
|
||||
- Fixed issue with "easy" rolls incorrectly getting XP checks
|
||||
- Only characters can get XP checks (not NPCs) - this was throwing an error in the console for weapons
|
||||
- Chat card for skills rolls etc now show the correct actor image where wildcard tokens are used.
|
||||
|
||||
|
||||
## 12.1.36
|
||||
- The autoXP game setting has changed from a tick box to a drop down selection - None (not auto XP), Any Success (tick XP for success, special success of critical) or On Fail/Fumble.
|
||||
Please re-check your setting following world migration, as they are likely to have reset to "none".
|
||||
|
||||
## 12.1.35
|
||||
- Culture now has the correct title on the item sheet
|
||||
- Failings on the character sheet now display the failing name if the short description is blank
|
||||
- Where the system can't find a skill category in dice rolls then the Cat Bonus now correctly sets to nil
|
||||
- There are now game settings to allow you to change the titles of the "power" tabs on the character sheet. Leave them blank to use the defaults
|
||||
- You can now click on the skill category in the character sheet skill tab to open up the Skill Category item
|
||||
- In the skill category, when on a character, you can enter a "manual" modifier that is added to the calculated bonus
|
||||
|
||||
## 12.1.34
|
||||
- Layout of Items on characters now fixed to a consistent grid size
|
||||
- Culture has been added as an Item. You can set the characteristic dice rolls and culture bonuses, plus skills with skill modifiers.
|
||||
- Adding a culture to a character adds the characterstic formulae, bonuses, skills with modifiers.
|
||||
- When in development mode and with the character sheet unlocked there is a dice icon on the "CHAR" tab that lets you roll the dice - with a chat message showing the results.
|
||||
- When you have added a culture the name is shown on the character sheet and there's a context menu to view or delete the culture.
|
||||
- If you don't drop a culture item on the character you can manually enter a culture name
|
||||
- You can also manually enter/edit the dice rolls for characteristics if development mode is on and the character sheet is unlocked.
|
||||
- You can now edit (and keep the changes) to weapon ammo and hitpoints on the NPC sheet
|
||||
- Specialised skills now show under a common "main skill" heading on the actor sheet (so all Languages appear under "Language")
|
||||
- A macro has been included in BRP ID Macros compendium to rename all actor and game world specialised skills
|
||||
|
||||
## 12.1.33
|
||||
- When dropping a Specialism skill on to the character sheet where the specialism hasn't been chosen you will get asked for the specialism name
|
||||
- When the character sheet is unlocked the Skills Tab context menu now includes a "Recalculate skill base scores" which does exactly that.
|
||||
- When dropping Personality on to a character - the skills included in the personality are added to the character - with relevant choices presented to the player and base scores calculated.
|
||||
- The same applies for a Profession
|
||||
- Skills added via the Personality gain the +20 bonus and are highlighted in the Skill Dev tab (Skill tab when character sheet unlocked), though you can edit the Personality Points on any skill
|
||||
- Skills added via the Profession are highlighted in the Skill Dev tab, though the system doesn't restrict you to spending Professional Skill Points on just these skill
|
||||
- NPCs now have a Power Modifier option on the Base Stats tab
|
||||
- NPCs now have a 'Move Notes" entry on the base stats tab for a longer description of their movement. This appears as a tooltip on the main NPC sheet
|
||||
- GMs should now be able to drag all items from the Characters and NPCs to the items menu or compendia
|
||||
- The skill category on the Skill Item Sheet should now display properly for players (it was fine for GMs)
|
||||
|
||||
## 12.1.32
|
||||
- Fixed issue with the Sorcery Spell sheet nullifying levels when opened by a player
|
||||
- Fixed issue with Psychic abilities not being able to access Description/GM Notes
|
||||
- Added 'Crush and Knockabck' as a special damage type. This and 'Impale and Knockback' now do the relevant special damage.
|
||||
- Most Item names can now be edited by players since the name is less critical with the intro of BRPIDs. This doesnt include Hit Location, Skill Category and Wound
|
||||
- When creating a new character only adds new skills and skill categories if not already on the sheet (was a problem with duplicating characters)
|
||||
- You can now drop NPCs from the compendium to the canvas. If the NPC already exists in the game actors (where BRPID and Priority match) then you won't get an additional copy created in game
|
||||
- For characteristic rolls there are now two new difficulty levels - Tricky and Awkward for STAT * 4 and STAT * 3 respectively. Difficulty levels now show the multiplier.
|
||||
|
||||
|
||||
## 12.1.31
|
||||
- BRP IDs have been added to the game - see the Github Wiki for more information - https://github.com/Genii-Locorum/brp/wiki/BRP-IDs. This is a major change so please BACKUP YOUR WORLDS before implementing this.
|
||||
- A number of macros have been included to help with migration - more info at https://github.com/Genii-Locorum/brp/wiki/Migrating-to-BRP-IDs
|
||||
- You no longer need to bring comepndium items in to the game items and can drag items etc straight from the compendia
|
||||
- The list of weapon types (and some other checks) are no longer generated from just in world game items, but pull a list from game items and compendia based on BRPIDs. You will still need some combat skills etc before you create weapons but they can be in the compendia rather than in world
|
||||
- Under skills there is a new toggle option "Combat Skill" - if toggled on then the skill is included in the list of weapon types. You don't need to toggle this on if the Category is set to Combat as these are automatically included. This removes some hard coding to include Dodge, Throw and Demolitions. It means you can also include other skills you create or rename those skills.
|
||||
- Most compendia have been removed from this system but have been placed in to a separate module - the aim is to make customising your world easier (you can use the new module, import the content, remove the module, edit the content and save your own compendia)
|
||||
- Instructions have been removed from within the game but have migrated to the Github Wiki - https://github.com/Genii-Locorum/brp/wiki
|
||||
- For Personality, you can now manually enter the personality name when the character sheet is unlocked if you don't have a Personality Item on the character sheet - if you later add a Personality item it supercedes (but doesnt erase) the manual entry. If you right click the "personality" title and there is no context menu this shows you don't have a relevant item. The same applies to Profession.
|
||||
- When skills or powers are shown on Personality, Profession etc either in the main list or optional groups you can now click on the name to open the relevant Item sheet. These will be the best match based on the BRPID with the highest priority as the actual skill etc is not stored in the Personality, Profession etc. This means you can ammend a skill etc without having to relink it to the Personality etc
|
||||
-Hit Locations (the item) now have a Display Name (e.g. Head) and a Creature Type (e.g. Humanoid). The item name will be a combination of the two with the Creature Type, if populated, appearing in brackets. The Display Name is what's shown on the Character & NPC sheets. This is to make finding the right hit location for a creature easier in a big list of hit locations
|
||||
-Skill Categories have been added as a new Item. You can change the name and configure how the category modifiers are calculated. The BRPID for each modifier in world or the compendium is used to generate the list of possible skill categories. Skill categories are automatically added to new characters. It is imperative that you either have a skill category with the BRPID 'i.skillcat.combat' or you flag all the weapon skills as combat skills. If you don't you won't see a list of weapon skills.
|
||||
- Because of the above the game settings for Social and Supernatural have been removed (include or don't include the relevant game items as you see fit.)
|
||||
- For NPCs you can now enter Max HP formula (as two stats, a multiple and a modifier) on the Base Stats Tab and the calculation formula is then displayed. It defaults to CON, SIZ, 0.5 & 0
|
||||
- When dropping unlinked NPC actors to a scene you can get the Random or Average stats to be autorolled for you - you may get asked depending on the game settings - there's a new one to choose Ask, Do nothing, use Random stats or Average Stats.
|
||||
- In game settings you can also choose whether to show NPC names on tokens as defaults. You can also do the same for Resource Bars
|
||||
|
||||
## 12.1.29
|
||||
- Added a Description section to NPCs for extended notes.
|
||||
- You now access NPC stats and base stats (dice to roll) via separate icons rather than toggling one
|
||||
- There is a new GM tool - Bestiary Mode. When toggled on you will see more HPL and Armour & SAN Loss sections on the NPC sheet even if your game doesn't use them. This mode is aimed at creating a Bestiary for distribution to other users who may use game settings that you don't.
|
||||
- SAN Loss has been added to NPC sheets
|
||||
- Fixed a bug with NPC hit points on HPL not recording properly.
|
||||
|
||||
## 12.1.28
|
||||
- Fixed Combat rolls not correctly reducing success level.
|
||||
|
||||
## 12.1.27
|
||||
- HP on weapons on NPC sheet are now saved
|
||||
- NPC Fatigue Points Max are autocalculated
|
||||
- When rolling or averaging stats for an NPC the Current HP, PP, FP and SP are set to maximum values
|
||||
|
||||
## 12.1.26
|
||||
- A Special Success Damage Roll for a Crushing Weapon now adds +1D4 Damage Bonus if the base Damage Bonus = 0
|
||||
- Macros can now be dragged between hotbar slots and from the Macro Directory
|
||||
- When rolling damage the rolled dice are now visible if you expand the chat message
|
||||
- Combat cards can now be resolved with only 1 roll in them
|
||||
- Combat rolls now tick relevant XP skills when successfully made and relevant game setting is on
|
||||
- Hit Locations now check they are not already on the character sheet before they can be dropped
|
||||
- Armour - previously if the Ballistic Armour values were left blank, then the non-ballistics armour value was assumed to be used. This no longer happens - you will need to update the Ballistic Armour values in each piece of armour
|
||||
- If using Hits Per Location, Armour on the Items tab is grouped by Hit Location with a summary of the location. Clicking on the expand icon (on the right) hides the specific hit location but shows the relevant armour items. Clicking on the collapse icon hides the armour items and reveals the hit location. Clicking on the title "ARMOUR" expands all hit locations whilst SHIFT + CLICK collapses them all.
|
||||
|
||||
## 12.1.25
|
||||
- Added a game setting for "Reputation" - choose from None (reputation not used), single (single reputation item allowed) or multiple (multiple reputation items allowed). NPCs will always use the mutliple option.
|
||||
- Reputation item can be dragged to macro bar
|
||||
- New tab on character sheet "SOCIAL" that combines Reputation and Allegiance Items. Visible if either is allowed in game
|
||||
- New tab on character sheet "PERS" (Personality) that combines Passion and Personality Traits. Visible if either is allowed in game
|
||||
- There are now "+" icons on the Armour and Weapons grids on the character sheet to directly add Armour and Weapons to the character sheet.
|
||||
- Please note that Instructions for new items are being added to the Github wiki (https://github.com/Genii-Locorum/brp/wiki) - overtime more instructions will be added to, and ingame instructions migrated to, the wiki.
|
||||
|
||||
## 12.1.24
|
||||
- Removed the superfluous true/false flag on the Character Sheet Allegiance tab (this was for potenital ally status)
|
||||
- Fixed the inline edit for NPC Allegiance points
|
||||
- Total Professional Skill Points, Personal Skill Points and XP now shown on the "CHAR" tab (this adds up totals from Skill, Magic and Psychic items)
|
||||
- Fixed error with Critical Damage not rolling
|
||||
|
||||
## 12.1.23
|
||||
- Skill categories on the actor sheet get to use the full width of the skill list column to prevent wrap around issues
|
||||
- Add Supernatural as a skill category and a game setting. If unticked then any skills with Supernatural won't be shown on the character sheet
|
||||
- Add Social as a skill category and a game setting. If unticked then any skills with Social won't be shown on the character sheet
|
||||
- Personality Traits have been added. You can roll on the Personality Trait or the Opposed Trait and do XP improvements as well.
|
||||
- You can drag Personality Traits to the hotbar for a macro. Using the ALT key when clicking on it triggers the Opposed roll.
|
||||
- Added macro roll functionality for Passions (previously only displayed the item sheet).
|
||||
- All item Descriptions and GM Descriptions are now rich text.
|
||||
- Background, backstory and biography are now rich text.
|
||||
- NPC sheets now have Passions, Allegiance and Personality Traits. Unless you have an item on the NPC or the sheet is unlocked you won't see the sections (to save space) - you can drag and drop an item on and it will appear. This has been added for Powers as well.
|
||||
|
||||
## 12.1.22
|
||||
- Change to manifest URL to correct it
|
||||
|
||||
## 12.1.21
|
||||
- Fix to NPC sheet to allow in line edit of #ATT on weapons ((it was failing to make the update after a new value was entered))
|
||||
- Tooltip added on NPC sheet explaining BAP
|
||||
|
||||
## 12.1.20
|
||||
- Added option to adjust maximum magic points to character (on the CHAR tab)
|
||||
- Gear, Weapons and Armour can now have stored magic/power points. Enter them on the item sheet (default is zero). The current value can be edited directly on the character sheet.
|
||||
- If you hover over Power/Magic points then a tool tip shows current and maximum stored power points. If an item is "stored" then it is not included
|
||||
- A few tweaks to the character sheet Combat/Item tab with font size to accomodate the Stored PP/MP. These are only shown if the Max is set to 1 or more.
|
||||
|
||||
## 12.1.19
|
||||
- Added Dodge to list of weapon skills available when creating weapon. Added Dodge as a weapon to the compendium. Use this to "Dodge" in combat (this is a a workaround pending a better option)
|
||||
|
||||
## 12.1.18
|
||||
- Fixed issue with Combined/Oppossed/Cooperative rolls in V11 (Issue#16)
|
||||
- Fixed issue with AudioHelper error on XP rolls.
|
||||
|
||||
## 12.1.17
|
||||
- Fixed issue with Wounds not being created if not using HPL on actors (worked for tokens)
|
||||
|
||||
## 12.1.16
|
||||
- Updated for V12 release 324
|
||||
|
||||
## 11.1.15
|
||||
- Added NPC actor type
|
||||
- Added a game settings for Allegiances
|
||||
- Added Allegiance item, and an allegiance roll (normal roll only)
|
||||
- Allegiance Improvement has an XP check like skills and magic
|
||||
- Added a game settings for Passions
|
||||
- Added Passion item, and a passion roll (normal and opposed)
|
||||
- Passion Improvement has an XP check like skills and magic
|
||||
- Fixed issue with not being able to add Wounds to an unlinked token (and couldn't roll XP checks either)
|
||||
- Can open mutation, psychic, sorcery and superpower item sheets on Character sheet by clicking on the names
|
||||
|
||||
## 11.1.14
|
||||
- Added combat rolls from weapons on the hotbar
|
||||
- Tidied up some of the chat message outputs
|
||||
- Can cast Magic spells - click on the spell total score
|
||||
|
||||
## 11.1.13
|
||||
- Tweaked context menus so they go up if the start point is in the bottom half of the screen
|
||||
- Roll cards fixed so the correct roll is deleted rather than always the first roll
|
||||
- Dice rolls now visible on opposed, cooperative and combined rolls when using Dice so Nice when roll is resolved
|
||||
- Updated roll chat messages to add extra info when expanding the roll (GM or owner can click on the indidividual result lines)
|
||||
|
||||
## 11.1.12
|
||||
- Hotfix to reinsert code to calculate stats total
|
||||
|
||||
## 11.1.11
|
||||
- Context menus should now render upwards if they would drop off the bottom of the screen
|
||||
- Skills will roll from the macro bar (you can roll weapon skills from the Skills tab, but not weapons from the items tab - yet)
|
||||
- Skill tab percentages now include the category bonus
|
||||
- ENC now calculated (shown on Characteristics tab). FP adjusted an
|
||||
- "Carry status" is now shown as icon rather than words (the words are available as a tooltip)
|
||||
- Auto calc of Encumberance (Items, Armour Weapons) which in turn updates the Max Fatigue Points
|
||||
- Changed the combat tab. Rate of Fire is now visible by hovering over "Attack". Added ENC (hover to see quantity).
|
||||
- Fatigue and Power point spend and recovery added
|
||||
- Added error check on dropping Weapon on character sheet to make sure a Skill has been added to the weapon if first skill slot
|
||||
- Added an game setting to change the logo at the top centre of the character sheet. 58px height and 700px wide max.
|
||||
- Added a setting in Skills for "Starter Skills". Any skill flagged as a starter skill we be automatically added to a new character sheet
|
||||
if the relevant game setting is on (this may get added in time)
|
||||
|
||||
## 11.1.10
|
||||
- Removed a testing element left in (oops) that forced max dice roll values
|
||||
- Added context menu options for weapon skill and damage rolls
|
||||
|
||||
## 11.1.9
|
||||
- Added GM Tools to the scene tools and a Development Phase
|
||||
- Added XP improvement checks, only available when Development Phase is turned on by the GM. Applies to Normal Skill, Combined, Oppossed and POW v POW rolls.
|
||||
- Added POW improvement roll, only available when Dev Phase is turned on
|
||||
- Option to turn on "auto XP" checks when succesfully using a skill
|
||||
- Cooperative skill rolls added
|
||||
- Can roll weapon damage from items in the Combat tab (unless damage is "Special")
|
||||
- Can roll weapon attacks from skill% in items in the Combat tab
|
||||
- Updated partial success rolls for Combined rolls
|
||||
|
||||
## 11.1.8
|
||||
- Added Socket functionality (pre-cursor to combined/opposed rolls)
|
||||
- Combined and Opposed skill rolls added
|
||||
- Skills now roll off the values not the name (context menus are off the name). Clicking on the name opens the skill item sheet
|
||||
- Some changes to skills and weapons (additional skills added - Lasso, Net, Thrown Axe) and which weapons use which skills changed
|
||||
based on discussions with author. Rates of Fire also added to advanced missile weapons
|
||||
|
||||
## 11.1.7
|
||||
- Added wound/hit point statuses, minor and major wounds
|
||||
- Update hit locations for wound statuses
|
||||
- Characteristics now roll off the value not the name (context menus are off the name)
|
||||
|
||||
## 11.1.6
|
||||
- Minor change to natural healing
|
||||
- Added a "general" hit location when new character is created and the game is using HPL to hold poison damage etc
|
||||
- Moved Damage Bonus to show correctly in the derived stats area rather than the Combat Tab
|
||||
- Characteristic Rolls added along with instructions
|
||||
- Game settings added for Impossible rolls, Resistance rolls always having a 1% success/fail and detailed Resistance Roll results
|
||||
- Specialised skills, where "specialism chosen" is toggled on will now have the specialism name shown first so it is more visible in the skill list
|
||||
|
||||
## 11.1.5
|
||||
- Hits per location autocalculated
|
||||
- Added Wounds which autoupdate hits (total and HPL)
|
||||
- Added healing (treat wounds, natural healing, heal all wounds)
|
||||
- Add HP adjustment to hit locations
|
||||
|
||||
## 11.1.4
|
||||
- Updated item drop so base skill scores are automatically calculated
|
||||
- Autocalc for Max HP, PP & FP added
|
||||
- Added a Game Setting to allow characters to have enhanced HP
|
||||
- Added a flat modifier to characters on Characteristics tab to increase HP (driven by superpowers)
|
||||
|
||||
## 11.1.3
|
||||
- Corrected error in character.mjs pointing to wrong sheet in some operating systems.
|
||||
|
||||
## 11.1.2
|
||||
- Corrected error in actor-itemDrop for adding skills to character sheet when a weapon is dropped (refencing skill1 instead of skill2)
|
||||
- Added note in readme reminding users to "Keep Document ID" on importing from Compendia
|
||||
|
||||
## 11.1.1
|
||||
- Initial Beta Release
|
||||
@@ -0,0 +1,202 @@
|
||||
# SLA Industries BRP - Current System Status
|
||||
|
||||
Date: 2026-02-21
|
||||
|
||||
## Overview
|
||||
|
||||
This system is a Foundry VTT v13 draft fork of BRP for SLA Industries.
|
||||
|
||||
- System ID: `sla-industries-brp`
|
||||
- Version: `13.1.52`
|
||||
- Companion module: `sla-industries-compendium`
|
||||
- Core model: BRP base mechanics with SLA-first defaults and SLA-specific subsystems layered in.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
Main runtime entrypoints:
|
||||
|
||||
- `module/hooks/init.mjs`: class registration, settings, sheets, API exposure
|
||||
- `module/brp.mjs`: runtime hooks and startup behavior
|
||||
- `module/combat/combat.mjs`: combat document behavior
|
||||
- `module/combat/combat-tracker.mjs`: custom tracker template wrapper
|
||||
|
||||
Primary custom documents:
|
||||
|
||||
- `BRPActor` (`module/actor/actor.mjs`)
|
||||
- `BRPItem` (`module/item/item.mjs`)
|
||||
- `BRPCombat` (`module/combat/combat.mjs`)
|
||||
- `BRPCombatTracker` (`module/combat/combat-tracker.mjs`)
|
||||
|
||||
## Current SLA Implementations
|
||||
|
||||
### Data model
|
||||
|
||||
`template.json` includes SLA character data:
|
||||
|
||||
- `system.sla.scl`
|
||||
- `system.sla.bpnType`
|
||||
- `system.sla.bpnRef`
|
||||
- `system.sla.department`
|
||||
- `system.sla.sponsor`
|
||||
- `system.sla.mediaRating`
|
||||
- `system.sla.credits`
|
||||
- `system.sla.campaignNotes`
|
||||
|
||||
Skill pool creation scaffolding is present:
|
||||
|
||||
- `system.skillPools.creationCap`
|
||||
- `system.skillPools.professional.*`
|
||||
- `system.skillPools.general.*`
|
||||
|
||||
### Character sheet and UX
|
||||
|
||||
Implemented in `templates/actor/character-sheet.html` and `module/actor/sheets/character.mjs`:
|
||||
|
||||
- SLA branding and labels
|
||||
- Account Credits field
|
||||
- Drug status strip and drug actions
|
||||
- EBB actions
|
||||
- SAN and COOL actions
|
||||
- Skill pool display (professional/general/cap)
|
||||
- SLA-only item filtering on drops
|
||||
|
||||
### SLA subsystem APIs (exposed on `game.brp`)
|
||||
|
||||
- `SLASeedImporter`
|
||||
- `SLACharacterGenerator`
|
||||
- `SLASkillPoints`
|
||||
- `SLAAmmoCatalog`
|
||||
- `SLAAmmoTracker`
|
||||
- `SLADrugSystem`
|
||||
- `SLAEbbSystem`
|
||||
- `SLAMentalSystem`
|
||||
- `SLADamageResolver`
|
||||
|
||||
### Importing and content pipeline
|
||||
|
||||
The system importer reads canonical seed JSON from the companion module:
|
||||
|
||||
- `modules/sla-industries-compendium/sla-data/skills.json`
|
||||
- `modules/sla-industries-compendium/sla-data/species.json`
|
||||
- `modules/sla-industries-compendium/sla-data/training-packages.json`
|
||||
- `modules/sla-industries-compendium/sla-data/ebb-abilities.json`
|
||||
- `modules/sla-industries-compendium/sla-data/equipment.json`
|
||||
|
||||
Importer supports:
|
||||
|
||||
- world folder seeding
|
||||
- training package linking
|
||||
- compendium sync
|
||||
- SLA-only enforcement mode
|
||||
- audit output for missing links
|
||||
|
||||
### Current world defaults (SLA-oriented)
|
||||
|
||||
From settings registrations:
|
||||
|
||||
- HPL: enabled
|
||||
- SAN: enabled
|
||||
- Fatigue: enabled
|
||||
- Resource 5 (COOL): enabled
|
||||
- Power points relabeled to Flux (`Flux` / `FLX`)
|
||||
- Psychic enabled and relabeled to EBB
|
||||
- Magic/Mutation/Sorcery/Super disabled by default
|
||||
- Wealth label switched to Credits
|
||||
- Combat ammo tracking defaults enabled
|
||||
|
||||
## Known Gaps / Work In Progress
|
||||
|
||||
- Initiative currently uses BRP formula settings by default (`initStat` + `initMod`), not SLA Escalation Initiative.
|
||||
- Many sheets remain on legacy Application V1 patterns (existing TODO comments for V2 migration).
|
||||
- Some behavior is intentionally narrative rather than fully state-enforced (by design in current draft).
|
||||
|
||||
## Readiness
|
||||
|
||||
The system is ready for targeted feature work.
|
||||
|
||||
Current priority started in this pass:
|
||||
|
||||
- Add Escalation Initiative (normal/risk flow, Cool-triggered freeze outcomes, tie-break handling).
|
||||
|
||||
## Latest UI/Playflow Pass
|
||||
|
||||
Applied after baseline review:
|
||||
|
||||
- Character sheet quick initiative buttons beside portrait:
|
||||
- `DEX INIT`
|
||||
- `INT INIT`
|
||||
- `SHIFT` on either button performs a Risk roll
|
||||
- Top title line switched to `SLA INDUSTRIES` wordmark styling (removed "BRP Draft" text treatment).
|
||||
- Characteristics roll affordance increased (stronger clickable target styling).
|
||||
- Skill roll rows now include a dice icon indicator.
|
||||
- Personal details section typography increased and spacing tightened.
|
||||
- Portrait recentered and enlarged.
|
||||
- Fatigue removed from character and NPC front-sheet gameplay UI and hidden from optional rules form.
|
||||
|
||||
## Latest Rules Engine Pass
|
||||
|
||||
- Added SLA BRP roll-edge mechanic to standard checks:
|
||||
- `Advantage`: roll `1D100`, reroll only tens die, keep lowest
|
||||
- `Normal`: roll `1D100`
|
||||
- `Disadvantage`: roll `1D100`, reroll only tens die, keep highest
|
||||
- Difficulty selection now uses edge modes instead of multiplier labels in the standard check dialog.
|
||||
- Flat modifier remains in place and applies normally.
|
||||
- BRP success tiers (critical/special/success/fail/fumble) remain unchanged and are resolved from the kept die.
|
||||
- Legacy difficulty values map safely to new edge modes for backward compatibility with older cards/macros.
|
||||
- SAN and COOL prompts now include explicit edge selection (`Advantage`, `Normal`, `Disadvantage`) and apply the same kept-die behavior.
|
||||
- Added built-in `SLABPNToolkit`:
|
||||
- Journal sidebar `BPN Toolkit` button for GMs.
|
||||
- Journal sidebar `Equipment` button for direct access to the SLA general equipment catalogue.
|
||||
- Items sidebar `Seed SLA Equipment` button to create drag/drop gear items directly in-world.
|
||||
- One-click setup of `BPN FORMAT & COLOUR CODES`, `TEMPLATE – BPN`, `BPN RANDOM DATA – EXTENDED`, and `BPN TOOLKIT – COMPLETE` journals.
|
||||
- Added `SLA GENERAL EQUIPMENT CATALOGUE` journal section split into communications, surveillance, medical, exploration, tools, personal gear, specialist tech, lifestyle, maintenance, and non-lethal security.
|
||||
- Added starting loadout packages, availability-by-SCL guidance, and maintenance/condition rules to the equipment section.
|
||||
- 1st Edition complete colour code reference: Blue/Yellow/Green/White/Grey/Silver/Jade/Red/Black/Platinum plus optional Orange supplement entry.
|
||||
- Guided BPN creation dialog now uses expanded d20/d12 tables and includes reward flow support (payment type, CBS tier, SCL tier, media roll).
|
||||
- Extended random layers: colour mission hooks, contacts, civilian factions, opposition attitudes, atmosphere, local twists, extra objectives, and escalation events.
|
||||
- Built-in Table 10 reward calculator logic for generated BPNs (Per Operative/Per Squad CBS + SCL increase + media outcome).
|
||||
- Journal rendering now uses a dedicated SLA cyberpunk dossier theme (`.sla-bpn-dossier`) with neon accents, dark panels, and compact data grids.
|
||||
- Quick random BPN generation that creates player-facing briefing + GM-only debrief journals.
|
||||
- Added equipment item seeding pipeline for all catalogue entries:
|
||||
- Creates `gear` items under `SLA General Equipment` folders by category.
|
||||
- Adds stable BRP ID flags to avoid duplicate reseeds.
|
||||
- Supports update mode (`overwrite`) for refreshing existing seeded entries.
|
||||
- Added a searchable in-world rulebook journal for players and GMs:
|
||||
- Journal name: `SLA RULEBOOK – PLAYERS & GMS`.
|
||||
- Auto-created/synced by GM on world ready.
|
||||
- Split into multi-page sections for sidebar/page search.
|
||||
- Default permission set to `Observer` so all players can access it.
|
||||
- Added `Rulebook` button to Journal sidebar (visible to all users).
|
||||
- Added a searchable in-world Vehicles & Chases journal for players and GMs:
|
||||
- Journal name: `SLA VEHICLES & CHASES – PLAYERS & GMS`.
|
||||
- Source markdown: `SLA_VEHICLES_AND_CHASES.md`.
|
||||
- Auto-created/synced by GM on world ready.
|
||||
- Default permission set to `Observer` for full player access.
|
||||
- Added `Vehicles & Chases` button to Journal sidebar (visible to all users).
|
||||
- Added `Chase Helper` button to Journal sidebar (visible to all users):
|
||||
- Opens `SLA Chase Round Assistant` dialog.
|
||||
- Resolves opposed chase rolls using Edge (Advantage/Normal/Disadvantage).
|
||||
- Applies approach-based range-band movement with optional 2-step surge on Special/Critical wins.
|
||||
- Posts a structured chase-round summary to chat (targets, modifiers, kept dice, result tiers, and new range band).
|
||||
- Added Wounds/Death/Healing rules integration:
|
||||
- Rulebook foundation extended with a dedicated chapter for 0-HP CON saves, bleed-out, first aid, serious injuries, and recovery.
|
||||
- Journal sidebar `Wound Helper` button (visible to all users).
|
||||
- Added `SLA Wound Crisis Assistant`:
|
||||
- Resolves HP drop + immediate CON save at Edge.
|
||||
- Applies outcome states (Down but Alive, Dying, Dead).
|
||||
- Tracks bleed-out rounds from CON score.
|
||||
- Optional first aid resolution with kit profiles and healing rolls.
|
||||
- Optional serious injury d6 roll with long-term consequences.
|
||||
- Can write state to actor and post full trauma summary to chat.
|
||||
- Added full NPC/adversary system layer:
|
||||
- Source chapter file: `SLA_NPCS_AND_ADVERSARIES.md`.
|
||||
- Searchable player-facing NPC dossier journal:
|
||||
- Journal name: `SLA NPCS & ADVERSARIES – PLAYERS & GMS`.
|
||||
- Auto-created/synced by GM on world ready.
|
||||
- Default permission set to `Observer`.
|
||||
- Journal sidebar `NPC Guide` button (visible to all users).
|
||||
- Added seeded adversary actor pipeline:
|
||||
- Actor sidebar `Seed NPC Adversaries` button.
|
||||
- Creates tiered archetype actors under `SLA NPC Adversaries` folders.
|
||||
- Seeded set: Downtown Ganger, Basic Civilian, Shiver Patrol Officer, DarkNight Cell Operative, Cloak Division Agent, Thresher Powersuit Pilot, Carrien Hunter, Manchine.
|
||||
- Supports reseed update mode (`overwrite`) through toolkit API.
|
||||
@@ -0,0 +1,61 @@
|
||||
MIT License
|
||||
|
||||
Chaosium
|
||||
Permission is given for users to make limited derivative use of this publication
|
||||
for personal use on the Foundry VTT platform. No rights for any commercial use
|
||||
are granted. No part of this publication may be reproduced or distributed for use
|
||||
outside of the Foundry VTT platform. All rights are reserved with Chaosium Inc.
|
||||
Basic Role Playing Universal Game Engine © copyright 1981–2023 Chaosium Inc. All
|
||||
rights reserved.
|
||||
|
||||
Copyright (c) 2023 Genii-Locorum
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
This license does not apply to the compendium content listed in this software's
|
||||
"packs" directory. See below regarding Chaosium's license conditions
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
Built of the Boilerplate System
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Asacolips Projects / Foundry Mods
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
This license does not apply to the compendium content listed in this software's
|
||||
"packs" directory. See the README for licensing information for the compendium
|
||||
packs.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,63 @@
|
||||
# SLA Industries BRP (Draft)
|
||||
|
||||
This is a draft system fork for Foundry VTT based on the BRP system, configured for a SLA Industries conversion workflow.
|
||||
|
||||
## Notes
|
||||
- System ID: `sla-industries-brp`
|
||||
- Intended companion module: `sla-industries-compendium`
|
||||
- This draft keeps BRP mechanics as the base and applies SLA-first defaults (Flux/Ebb labels, SAN/Hit Locations/Fatigue/COOL enabled by default).
|
||||
|
||||
## Transfer Backup (2026-02-18)
|
||||
Backup archives created for migration/testing:
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-system-20260218-002446.tar.gz`
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-compendium-module-20260218-002446.tar.gz`
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-transfer-bundle-20260218-002446.tar.gz`
|
||||
|
||||
## Transfer Backup (2026-02-23)
|
||||
Latest transfer set (system + module + bundle):
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-system-20260223-090401.tar.gz`
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-compendium-module-20260223-090401.tar.gz`
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-transfer-bundle-20260223-090401.tar.gz`
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-transfer-bundle-20260223-090401.zip`
|
||||
- Manifest/checksums:
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-transfer-20260223-090401/BACKUP_MANIFEST.md`
|
||||
- `/Users/timevans/FoundryVTT/Backups/sla-industries-brp-transfer-20260223-090401/SHA256SUMS.txt`
|
||||
|
||||
Restore under Foundry Data:
|
||||
- `systems/sla-industries-brp`
|
||||
- `modules/sla-industries-compendium`
|
||||
|
||||
## Documentation
|
||||
- Technical installation/transfer:
|
||||
- `/Users/timevans/FoundryVTT/Data/systems/sla-industries-brp/SLA_TECHNICAL_INSTALL_TRANSFER_GUIDE.md`
|
||||
- Rules/play reference (expanded, with play + gear examples):
|
||||
- `/Users/timevans/FoundryVTT/Data/systems/sla-industries-brp/SLA_RULES_OF_PLAY_REFERENCE.md`
|
||||
- Full player+GM rulebook foundation (module-ready living document):
|
||||
- `/Users/timevans/FoundryVTT/Data/systems/sla-industries-brp/SLA_RULEBOOK_FOUNDATION.md`
|
||||
- Vehicles & chases chapter source:
|
||||
- `/Users/timevans/FoundryVTT/Data/systems/sla-industries-brp/SLA_VEHICLES_AND_CHASES.md`
|
||||
- NPCs and adversaries chapter source:
|
||||
- `/Users/timevans/FoundryVTT/Data/systems/sla-industries-brp/SLA_NPCS_AND_ADVERSARIES.md`
|
||||
- Searchable in-world Journal rulebook:
|
||||
- `SLA RULEBOOK – PLAYERS & GMS` (auto-synced from foundation markdown by GM on world load)
|
||||
- Searchable in-world Vehicles & Chases journal:
|
||||
- `SLA VEHICLES & CHASES – PLAYERS & GMS` (auto-synced from vehicles/chases markdown by GM on world load)
|
||||
- Searchable in-world NPC guide journal:
|
||||
- `SLA NPCS & ADVERSARIES – PLAYERS & GMS` (auto-synced from NPC/adversary markdown by GM on world load)
|
||||
- Chase round play aid:
|
||||
- Journal sidebar `Chase Helper` button (all users) opens an opposed roll assistant with chat output.
|
||||
- Wounds / death / healing integration:
|
||||
- Rulebook chapter included in `SLA_RULEBOOK_FOUNDATION.md` (0-HP CON save model).
|
||||
- Journal sidebar `Wound Helper` button (all users) opens the trauma resolver assistant.
|
||||
- NPC/adversary play aids:
|
||||
- Journal sidebar `NPC Guide` button (all users).
|
||||
- Actor sidebar `Seed NPC Adversaries` button (GM) seeds tiered SLA NPC archetypes.
|
||||
|
||||
## Visual Direction
|
||||
The actor sheet, dialogs, and chat cards are tuned for an SLA red-forward theme with higher-contrast readability:
|
||||
- Title font: `Cinzel`
|
||||
- Body font: `Cormorant Garamond`
|
||||
- UI font: `Barlow Condensed`
|
||||
|
||||
## ORC Notice
|
||||
BRP source material remains subject to Chaosium's ORC licensing terms where applicable.
|
||||
@@ -0,0 +1,111 @@
|
||||
# SLA Industries BRP Draft Notes
|
||||
|
||||
## Source Material Reviewed
|
||||
- `/Users/timevans/Downloads/SLA_BRP.txt`
|
||||
- `/Users/timevans/Downloads/880025437-Basic-Roleplaying-Universal-Game-Engine-Quickstart.pdf` (50 pages, text extracted)
|
||||
|
||||
## Foundry Research Summary
|
||||
- Base system analyzed: `/Users/timevans/FoundryVTT/data/systems/brp`
|
||||
- UGE module analyzed: `/Users/timevans/FoundryVTT/data/modules/basic-role-playing-uge-compendium`
|
||||
- UGE packs are Foundry LevelDB pack folders (not plain JSON files), so direct text editing of entries is not the right workflow.
|
||||
|
||||
## Draft 1 Implemented
|
||||
- New cloned system created:
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp`
|
||||
- New cloned compendium module created:
|
||||
- `/Users/timevans/FoundryVTT/data/modules/sla-industries-compendium`
|
||||
- System manifest updated:
|
||||
- `id` set to `sla-industries-brp`
|
||||
- title set to `SLA Industries BRP (Draft)`
|
||||
- internal system references point to `sla-industries-brp`
|
||||
- Compendium module manifest updated:
|
||||
- `id` set to `sla-industries-compendium`
|
||||
- title set to `SLA Industries Compendium (Draft)`
|
||||
- all pack `system` values set to `sla-industries-brp`
|
||||
- `relationships.systems[0].id` set to `sla-industries-brp`
|
||||
- Internal hardcoded paths updated:
|
||||
- all `systems/brp/...` template/asset paths changed to `systems/sla-industries-brp/...`
|
||||
- Settings namespace isolated:
|
||||
- all `game.settings.*('brp', ...)` changed to `game.settings.*('sla-industries-brp', ...)`
|
||||
- Sheet registration fixed for cloned system:
|
||||
- all `registerSheet('brp', ...)` updated to `registerSheet('sla-industries-brp', ...)`
|
||||
|
||||
## SLA Default World Settings Applied (Draft)
|
||||
Based on your SLA conversion document, default values were set to:
|
||||
- Optional rules:
|
||||
- `useHPL = true`
|
||||
- `useSAN = true`
|
||||
- `useFP = true`
|
||||
- `useRes5 = true`
|
||||
- `skillBonus = 1`
|
||||
- Resource labels:
|
||||
- `ppLabelLong = Flux`
|
||||
- `ppLabelShort = FLX`
|
||||
- `fpLabelLong = Fatigue`
|
||||
- `fpLabelShort = FP`
|
||||
- `res5LabelLong = COOL Rating`
|
||||
- `res5LabelShort = COOL`
|
||||
- Power categories:
|
||||
- `magic = false`
|
||||
- `mutation = false`
|
||||
- `psychic = true`
|
||||
- `psychicLabel = Ebb`
|
||||
- `sorcery = false`
|
||||
- `super = false`
|
||||
- Character sheet labels:
|
||||
- `background1 = Dossier`
|
||||
- `background2 = BPN Log`
|
||||
- `background3 = Notes`
|
||||
- `wealthLabel = Credits`
|
||||
|
||||
## Branding Swap Applied
|
||||
- Setup media, default scene image, and character sheet logo now use SLA-branded assets:
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/assets/sla-logo-powered_by-solid.svg`
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/assets/sla-logo-powered_by-02.svg`
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/assets/sla-char-sheet-logo.svg`
|
||||
- BRP-branded readmes replaced with draft SLA readmes in:
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/Readme.md`
|
||||
- `/Users/timevans/FoundryVTT/data/modules/sla-industries-compendium/README.md`
|
||||
|
||||
## Draft 2 (Current)
|
||||
- Added SLA operation metadata fields to actor data model:
|
||||
- `system.sla.scl`
|
||||
- `system.sla.bpnType`
|
||||
- `system.sla.bpnRef`
|
||||
- `system.sla.department`
|
||||
- `system.sla.sponsor`
|
||||
- `system.sla.mediaRating`
|
||||
- `system.sla.campaignNotes`
|
||||
- Added SLA Operations UI block to character background tab:
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/templates/actor/parts/actor-background.html`
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/css/brp.css`
|
||||
- Added compendium seed data files for upcoming automated import:
|
||||
- `/Users/timevans/FoundryVTT/data/modules/sla-industries-compendium/sla-data/species.json`
|
||||
- `/Users/timevans/FoundryVTT/data/modules/sla-industries-compendium/sla-data/training-packages.json`
|
||||
- `/Users/timevans/FoundryVTT/data/modules/sla-industries-compendium/sla-data/README.md`
|
||||
- Added importer utility exposed on `game.brp`:
|
||||
- `/Users/timevans/FoundryVTT/data/systems/sla-industries-brp/module/apps/sla-seed-importer.mjs`
|
||||
- usage: `await game.brp.SLASeedImporter.importAll({ overwrite: false })`
|
||||
|
||||
## Immediate Next Steps (Phase 2)
|
||||
1. Decide item data strategy for SLA conversions:
|
||||
- Import new compendium module for SLA
|
||||
- Or keep BRP UGE module and layer SLA content as separate packs
|
||||
2. Build SLA starter packs:
|
||||
- species/racial templates
|
||||
- professions (training packages)
|
||||
- skills and categories
|
||||
- Ebb psychic abilities
|
||||
- weapon and armor baseline entries
|
||||
3. Configure character defaults:
|
||||
- default SCL field handling
|
||||
- COOL roll macro and usage points
|
||||
- starting credits and package templates
|
||||
|
||||
## Confirmed Decisions
|
||||
1. Clone UGE module now: yes.
|
||||
2. Module ID/name: `sla-industries-compendium`.
|
||||
3. Mutation default: disabled (still selectable).
|
||||
4. Skill bonus default: simple.
|
||||
5. Flux short label: `FLX`.
|
||||
6. Branding swap: now.
|
||||
@@ -0,0 +1,189 @@
|
||||
# SLA BRP - Escalation Initiative (Advantage Die System)
|
||||
|
||||
Version: 1.0
|
||||
|
||||
## Core Concept
|
||||
|
||||
Initiative is a single roll each round.
|
||||
|
||||
Operatives may gamble for speed using an Advantage Die.
|
||||
If they push too hard and lose composure, they freeze.
|
||||
|
||||
This system is designed to:
|
||||
|
||||
- Use a single roll per round
|
||||
- Require no bookkeeping between rounds
|
||||
- Work cleanly at table or in Foundry
|
||||
- Integrate COOL as a psychological pressure mechanic
|
||||
|
||||
## Round Procedure
|
||||
|
||||
Each round:
|
||||
|
||||
1. Choose your approach:
|
||||
- DEX (Reflex)
|
||||
- INT (Read the Fight)
|
||||
2. Choose whether to take the Risk.
|
||||
3. Roll initiative.
|
||||
|
||||
Highest total acts first.
|
||||
|
||||
## Normal Roll
|
||||
|
||||
Roll:
|
||||
|
||||
`1D10 + chosen stat (DEX or INT)`
|
||||
|
||||
Highest total acts first.
|
||||
|
||||
## Risk Roll (Advantage Die)
|
||||
|
||||
Instead of a normal roll, you may roll:
|
||||
|
||||
`2D10 + chosen stat`
|
||||
|
||||
Keep the highest die.
|
||||
|
||||
However:
|
||||
|
||||
If either die shows a 1, you must immediately roll COOL.
|
||||
|
||||
## COOL Test (When a 1 Is Rolled)
|
||||
|
||||
If no die shows 1:
|
||||
|
||||
- Act normally at your rolled initiative.
|
||||
|
||||
If a die shows 1:
|
||||
|
||||
- Immediately roll COOL.
|
||||
|
||||
COOL resolution:
|
||||
|
||||
Success:
|
||||
|
||||
- You act normally.
|
||||
|
||||
Failure:
|
||||
|
||||
- You freeze.
|
||||
- You take no action this round.
|
||||
- You may still Dodge or Parry.
|
||||
|
||||
Fumble:
|
||||
|
||||
- You catastrophically overcommit.
|
||||
- You take no action this round.
|
||||
- You cannot Dodge or Parry until after your initiative passes.
|
||||
- GM may narrate a visible tactical error (stumble, twitch, hesitation, weapon slip).
|
||||
|
||||
## Tie Breakers
|
||||
|
||||
If two characters have the same initiative total:
|
||||
|
||||
1. Higher chosen stat wins.
|
||||
2. If still tied:
|
||||
- The character who chose INT wins ties against DEX.
|
||||
3. If still tied:
|
||||
- Actions are simultaneous.
|
||||
|
||||
## Design Notes
|
||||
|
||||
- Rolling `2D10` gives roughly a `19%` chance of triggering a COOL test.
|
||||
- This creates tension without heavy bookkeeping.
|
||||
- Low COOL characters become increasingly vulnerable to freezing.
|
||||
- This interacts naturally with Sanity erosion if COOL is penalized by low SAN.
|
||||
- No tokens, no tracking, no recalculation mid-round.
|
||||
- Every consequence resolves immediately.
|
||||
|
||||
## Optional Differentiation (Optional Rule)
|
||||
|
||||
If the COOL roll succeeds after a Risk roll:
|
||||
|
||||
If you chose INT:
|
||||
|
||||
- `+10%` to your first attack this round.
|
||||
|
||||
If you chose DEX:
|
||||
|
||||
- Gain `1m` free movement before your first action.
|
||||
|
||||
This adds flavor without adding tracking.
|
||||
|
||||
## Foundry Implementation Notes
|
||||
|
||||
This system is simple to implement in Foundry because:
|
||||
|
||||
1. It requires only one roll per round.
|
||||
2. No persistent state is required.
|
||||
3. No tracking of damage, tokens, or flags is necessary.
|
||||
|
||||
Suggested implementation approach:
|
||||
|
||||
- Create two initiative macros:
|
||||
- A) Normal Roll
|
||||
- B) Risk Roll
|
||||
|
||||
Normal Roll formula:
|
||||
|
||||
`1d10 + @dex`
|
||||
|
||||
or
|
||||
|
||||
`1d10 + @int`
|
||||
|
||||
Risk Roll formula:
|
||||
|
||||
`2d10kh + @dex`
|
||||
|
||||
or
|
||||
|
||||
`2d10kh + @int`
|
||||
|
||||
(`kh` = keep highest)
|
||||
|
||||
After the roll:
|
||||
|
||||
- If any die result = 1, prompt a COOL roll manually or via macro.
|
||||
- Apply freeze effect narratively (no system state required unless desired).
|
||||
|
||||
Optional automation:
|
||||
|
||||
- Add a dialog checkbox: Risk?
|
||||
- If checked, roll `2d10kh`.
|
||||
- If either die result is `1`, auto-prompt COOL roll.
|
||||
|
||||
No initiative reordering logic is required beyond setting initiative value.
|
||||
|
||||
### Current system API hooks (first implementation pass)
|
||||
|
||||
In this system build, Escalation Initiative is available via:
|
||||
|
||||
- `game.brp.SLAEscalationInitiative.rollControlled({ approach: "dex", risk: false })`
|
||||
- `game.brp.SLAEscalationInitiative.rollControlled({ approach: "int", risk: true })`
|
||||
|
||||
Combat tracker single-combatant initiative rolls now prompt for:
|
||||
|
||||
- DEX or INT
|
||||
- Risk on/off
|
||||
|
||||
Risk roll handling includes automatic COOL checks when any risk die shows `1`.
|
||||
|
||||
## Why This Works for 6 Players
|
||||
|
||||
- Everyone rolls once.
|
||||
- Only risk-takers occasionally roll COOL.
|
||||
- Freeze is immediate and resolved instantly.
|
||||
- No end-of-round recalculation.
|
||||
- No momentum tokens.
|
||||
- No damage comparison.
|
||||
|
||||
It keeps combat moving.
|
||||
|
||||
It adds psychological risk.
|
||||
|
||||
It feels like SLA.
|
||||
|
||||
## End of Initiative Rules
|
||||
|
||||
Reserved for table-specific additions.
|
||||
@@ -0,0 +1,219 @@
|
||||
# NPCs & Adversaries (BRP – SLA Industries)
|
||||
|
||||
Document Status: First Draft – Playable
|
||||
Scope: Fast-building enemies and contacts for SLA BRP, keyed to BPN colour and Mort threats.
|
||||
|
||||
---
|
||||
|
||||
## 1. NPC Tiers – Mooks to Monsters
|
||||
|
||||
Use tiers so you do not overbuild every stat block.
|
||||
|
||||
- Tier 1 – Extras
|
||||
- Downtown gangers, civilians, basic security, background Shivers.
|
||||
- One or two useful skills, low HP, go down fast.
|
||||
- Tier 2 – Standard Opposition
|
||||
- DarkNight cells, competent gangers, serial killers, proper Shiver squads.
|
||||
- Wider skill set, decent gear, dangerous in groups.
|
||||
- Tier 3 – Elite Operatives
|
||||
- DarkNight specialists, Thresher powersuits, contract killers, Internal/Cloak agents.
|
||||
- High skills, strong armour, special action economy pressure.
|
||||
- Tier 4 – Horrors and Set-Piece Threats
|
||||
- Carrien packs, Manchines, Necanthropic entities.
|
||||
- Break normal assumptions: fear pressure, extra attacks, regeneration, brutal armour.
|
||||
|
||||
---
|
||||
|
||||
## 2. Standard NPC Stat Block
|
||||
|
||||
For all NPCs, use:
|
||||
|
||||
- Name and Role
|
||||
- Tier
|
||||
- Attributes: STR, CON, DEX, INT, POW, CHA, SIZ
|
||||
- HP
|
||||
- Key Skills (only list skills you will roll)
|
||||
- Attacks (skill %, damage, notes)
|
||||
- Armour
|
||||
- Traits
|
||||
- Gear
|
||||
- BPN Hooks (where this opposition appears)
|
||||
|
||||
Target 6-10 practical lines for table speed.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quick-Build Templates by Tier
|
||||
|
||||
### 3.1 Tier 1 – Extras
|
||||
|
||||
#### Downtown Ganger
|
||||
|
||||
- Tier: 1
|
||||
- STR 10, CON 10, DEX 11, INT 9, POW 9, CHA 9, SIZ 10
|
||||
- HP: 10
|
||||
- Skills: Streetwise 35, Stealth 40, Melee 40, Pistol 35
|
||||
- Attacks: Knife 40 (1D6), 10mm Pistol 35 (1D8)
|
||||
- Armour: Cheap jacket AR 1
|
||||
- Trait: Cowardly (COOL test or break when allies drop)
|
||||
- Hooks: Blue, White, Red
|
||||
|
||||
#### Basic Civilian
|
||||
|
||||
- Tier: 1
|
||||
- STR 8, CON 9, DEX 9, INT 10, POW 9, CHA 10, SIZ 9
|
||||
- HP: 9
|
||||
- Skills: Athletics 30, Hide 25, Persuade 30
|
||||
- Attacks: Improvised weapon 25 (1D4)
|
||||
- Armour: None
|
||||
- Trait: Panicky (Disadvantage on SAN/COOL under gunfire)
|
||||
- Hooks: Blue, White, Silver
|
||||
|
||||
### 3.2 Tier 2 – Standard Opposition
|
||||
|
||||
#### Shiver Patrol Officer
|
||||
|
||||
- Tier: 2
|
||||
- STR 12, CON 12, DEX 11, INT 10, POW 11, CHA 10, SIZ 12
|
||||
- HP: 12
|
||||
- Skills: Rifle 55, Baton 50, Athletics 45, Perception 50, Intimidate 45
|
||||
- Attacks: Gauss Rifle 55 (2D8), Baton 50 (1D6+1)
|
||||
- Armour: Blocker armour AR 4
|
||||
- Traits: Backup, Procedure-Driven
|
||||
- Hooks: Blue, Red, Grey, Silver
|
||||
|
||||
#### DarkNight Cell Operative
|
||||
|
||||
- Tier: 2
|
||||
- STR 12, CON 12, DEX 13, INT 12, POW 11, CHA 10, SIZ 12
|
||||
- HP: 12
|
||||
- Skills: Rifle 60, Stealth 60, Electronics 50, Tactics 45, Knife 45
|
||||
- Attacks: Assault Rifle 60 (2D8), Combat Knife 45 (1D6)
|
||||
- Armour: Medium AR 4-5
|
||||
- Traits: Coordinated, Fanatic
|
||||
- Hooks: Red, Grey, Black
|
||||
|
||||
### 3.3 Tier 3 – Elite Operatives
|
||||
|
||||
#### Cloak Division Agent
|
||||
|
||||
- Tier: 3
|
||||
- STR 14, CON 14, DEX 14, INT 13, POW 14, CHA 12, SIZ 14
|
||||
- HP: 14
|
||||
- Skills: Pistol 75, Unarmed 70, Stealth 70, Insight 75, Intimidate 80, Investigation 70
|
||||
- Attacks: Concealed Pistol 75 (1D10+2), Unarmed 70 (1D6+2)
|
||||
- Armour: Discrete AR 5-6
|
||||
- Traits: Nuke Tendon burst action (1/scene), Internal Authority
|
||||
- Hooks: Grey, Platinum
|
||||
|
||||
#### Thresher Powersuit Pilot
|
||||
|
||||
- Tier: 3
|
||||
- STR 16, CON 15, DEX 13, INT 12, POW 11, CHA 9, SIZ 15
|
||||
- HP: 15 (pilot baseline)
|
||||
- Skills: Heavy Weapons 70, Tactics 60, Electronics 50
|
||||
- Attacks: Heavy Gauss Cannon 70 (3D10), Power Fist 65 (2D8)
|
||||
- Armour: Suit AR 8+
|
||||
- Traits: Bulky, Shock and Awe entry pressure
|
||||
- Hooks: Red, Black, Platinum
|
||||
|
||||
### 3.4 Tier 4 – Mort Horrors
|
||||
|
||||
#### Carrien Hunter
|
||||
|
||||
- Tier: 4
|
||||
- STR 15, CON 13, DEX 13, INT 6, POW 8, CHA 0, SIZ 15
|
||||
- HP: 14
|
||||
- Skills: Claw 65, Athletics 60, Stealth 60
|
||||
- Attacks: Claws 65 (1D8+2), Bite 55 (1D8)
|
||||
- Armour: Hide AR 2-3
|
||||
- Traits: Night Hunter, Pack Tactics, Horror trigger
|
||||
- Hooks: Blue, Green, Black
|
||||
|
||||
#### Manchine
|
||||
|
||||
- Tier: 4
|
||||
- STR 18, CON 18, DEX 14, INT 7, POW 0, CHA 0, SIZ 22
|
||||
- HP: 20
|
||||
- Skills: Melee 75, Athletics 60
|
||||
- Attacks: Metal Claws 75 (2D8+3)
|
||||
- Armour: Chassis AR 6
|
||||
- Traits: Relentless, Terror of Mort, extra melee pressure
|
||||
- Hooks: Green, Black, Platinum
|
||||
|
||||
---
|
||||
|
||||
## 4. SLA-Flavoured Trait Library
|
||||
|
||||
Apply one or two to instantly SLA-ify an adversary:
|
||||
|
||||
- Media Darling: social leverage while cameras are live.
|
||||
- Corporate Asset: death/escalation creates Grey BPN fallout.
|
||||
- Addict: temporary edge with crash risk.
|
||||
- Obsessive: cannot disengage without POW pressure.
|
||||
- Internal Watchlisted: actions around this NPC affect Internal heat.
|
||||
|
||||
---
|
||||
|
||||
## 5. BPN Colour to Opposition Mapping
|
||||
|
||||
| BPN Colour | Typical Adversaries |
|
||||
|---|---|
|
||||
| Blue | Gangers, minor cults, low-end Shivers, Carrien sightings |
|
||||
| White | Serial killers, corrupt office staff, hidden handlers |
|
||||
| Yellow | Thieves, smugglers, rival retrieval teams |
|
||||
| Green | Carrien packs, Manchines, sector horrors |
|
||||
| Red | DarkNight cells, riot vectors, heavy response targets |
|
||||
| Grey | Rogue operatives, corporate enforcement, internals conflicts |
|
||||
| Jade | Biohazard outcomes, Ebb spillover entities |
|
||||
| Black | High-tier rivals, deniable horrors, clandestine kill assets |
|
||||
| Silver | Media rivals, PR saboteurs, camera-focused threats |
|
||||
| Platinum | Unique, custom, campaign-defining threats |
|
||||
|
||||
---
|
||||
|
||||
## 6. Foundry Implementation Notes
|
||||
|
||||
### World-facing journals
|
||||
|
||||
- Journal: SLA NPCS & ADVERSARIES – PLAYERS & GMS
|
||||
- Auto-synced from this markdown by GM on world ready.
|
||||
- Observer ownership for player access.
|
||||
|
||||
### Sidebar access
|
||||
|
||||
- Journal sidebar button: NPC Guide
|
||||
- Actor sidebar button: Seed NPC Adversaries
|
||||
|
||||
### Seeded adversary actors
|
||||
|
||||
The system provides tiered seeded templates for:
|
||||
|
||||
- Downtown Ganger
|
||||
- Basic Civilian
|
||||
- Shiver Patrol Officer
|
||||
- DarkNight Cell Operative
|
||||
- Cloak Division Agent
|
||||
- Thresher Powersuit Pilot
|
||||
- Carrien Hunter
|
||||
- Manchine
|
||||
|
||||
Each actor includes:
|
||||
|
||||
- attributes and HP baseline
|
||||
- key skill items
|
||||
- attack weapon entries using NPC roll values
|
||||
- tier/role metadata flags
|
||||
- BPN-ready narrative notes
|
||||
|
||||
---
|
||||
|
||||
## 7. Table-Speed Guidance
|
||||
|
||||
- Use Tier 1 in groups and let them fold quickly.
|
||||
- Use Tier 2 as core pressure pieces.
|
||||
- Use Tier 3 sparingly for command-level stakes.
|
||||
- Use Tier 4 for scene-defining terror, not routine filler.
|
||||
|
||||
If a fight drags, increase consequences not hit points.
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
# SLA Operative Group Non-Combat Issue Generator
|
||||
|
||||
This document contains 300 baseline non-combat operational problems for
|
||||
use in random generation.
|
||||
|
||||
1. Communications blackout in assigned sector.
|
||||
2. Conflicting mission directives from two supervisors.
|
||||
3. Delayed corporate clearance paperwork.
|
||||
4. Identity verification flagged as incomplete.
|
||||
5. Civilian protest blocking primary access route.
|
||||
6. Media crew interference at operational site.
|
||||
7. Malfunctioning biometric scanner at checkpoint.
|
||||
8. Data corruption in mission brief files.
|
||||
9. Missing equipment requisition approval.
|
||||
10. Power outage in staging facility.
|
||||
11. Fuel shortage for assigned vehicles.
|
||||
12. Corporate audit team arrival mid-operation.
|
||||
13. Hostile but non-violent civilian crowd.
|
||||
14. Surveillance drones offline unexpectedly.
|
||||
15. Faulty environmental controls in building.
|
||||
16. Evacuation alarm triggered accidentally.
|
||||
17. Internal team dispute over protocol.
|
||||
18. Psychological stress episode within squad.
|
||||
19. Lost access credentials.
|
||||
20. Conflicting jurisdiction with local authorities.
|
||||
21. Civilian curfew enforcement confusion.
|
||||
22. Transport vehicle mechanical failure.
|
||||
23. Misrouted supply delivery.
|
||||
24. Hacked public announcement system broadcasting false info.
|
||||
25. Compromised data terminal.
|
||||
26. Incomplete map of operational zone.
|
||||
27. Sudden weather hazard (acid rain, dust storm).
|
||||
28. Interference from corporate rival observers.
|
||||
29. Escalating rumor campaign damaging reputation.
|
||||
30. Building structural instability.
|
||||
31. Water contamination warning.
|
||||
32. AI assistant glitching with incorrect data.
|
||||
33. Faulty navigation system.
|
||||
34. Missing digital evidence files.
|
||||
35. Unexpected inspection from SLA oversight.
|
||||
36. Civilian evacuation refusal.
|
||||
37. Confiscated equipment due to permit error.
|
||||
38. Broken elevator in high-rise target building.
|
||||
39. Air filtration failure indoors.
|
||||
40. Local blackout impacting tech systems.
|
||||
41. Language barrier with witnesses.
|
||||
42. Lost encrypted comm device.
|
||||
43. Unauthorized drone surveillance detected.
|
||||
44. Conflicting witness statements.
|
||||
45. Asset refusing cooperation.
|
||||
46. Administrative reassignment mid-task.
|
||||
47. Malfunctioning recording equipment.
|
||||
48. Team member sleep deprivation.
|
||||
49. Expired access codes.
|
||||
50. Corrupt official demanding documentation.
|
||||
51. Logistical delay affecting transport schedules.
|
||||
52. Equipment calibration error in digital archives.
|
||||
53. Data inconsistency detected in facility access.
|
||||
54. Regulatory violation warning concerning corporate permits.
|
||||
55. Unexpected civilian involvement related to surveillance logs.
|
||||
56. Administrative confusion regarding supply chains.
|
||||
57. Technical malfunction impacting security clearances.
|
||||
58. Public relations complication involving energy grids.
|
||||
59. Environmental hazard detected near drone networks.
|
||||
60. Internal procedural conflict over field reports.
|
||||
61. Logistical delay affecting financial accounts.
|
||||
62. Equipment calibration error in communication relays.
|
||||
63. Data inconsistency detected in AI systems.
|
||||
64. Regulatory violation warning concerning environmental scanners.
|
||||
65. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
66. Administrative confusion regarding public terminals.
|
||||
67. Technical malfunction impacting team rosters.
|
||||
68. Public relations complication involving asset tracking.
|
||||
69. Environmental hazard detected near legal documentation.
|
||||
70. Internal procedural conflict over maintenance logs.
|
||||
71. Logistical delay affecting transport schedules.
|
||||
72. Equipment calibration error in digital archives.
|
||||
73. Data inconsistency detected in facility access.
|
||||
74. Regulatory violation warning concerning corporate permits.
|
||||
75. Unexpected civilian involvement related to surveillance logs.
|
||||
76. Administrative confusion regarding supply chains.
|
||||
77. Technical malfunction impacting security clearances.
|
||||
78. Public relations complication involving energy grids.
|
||||
79. Environmental hazard detected near drone networks.
|
||||
80. Internal procedural conflict over field reports.
|
||||
81. Logistical delay affecting financial accounts.
|
||||
82. Equipment calibration error in communication relays.
|
||||
83. Data inconsistency detected in AI systems.
|
||||
84. Regulatory violation warning concerning environmental scanners.
|
||||
85. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
86. Administrative confusion regarding public terminals.
|
||||
87. Technical malfunction impacting team rosters.
|
||||
88. Public relations complication involving asset tracking.
|
||||
89. Environmental hazard detected near legal documentation.
|
||||
90. Internal procedural conflict over maintenance logs.
|
||||
91. Logistical delay affecting transport schedules.
|
||||
92. Equipment calibration error in digital archives.
|
||||
93. Data inconsistency detected in facility access.
|
||||
94. Regulatory violation warning concerning corporate permits.
|
||||
95. Unexpected civilian involvement related to surveillance logs.
|
||||
96. Administrative confusion regarding supply chains.
|
||||
97. Technical malfunction impacting security clearances.
|
||||
98. Public relations complication involving energy grids.
|
||||
99. Environmental hazard detected near drone networks.
|
||||
100. Internal procedural conflict over field reports.
|
||||
101. Logistical delay affecting financial accounts.
|
||||
102. Equipment calibration error in communication relays.
|
||||
103. Data inconsistency detected in AI systems.
|
||||
104. Regulatory violation warning concerning environmental scanners.
|
||||
105. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
106. Administrative confusion regarding public terminals.
|
||||
107. Technical malfunction impacting team rosters.
|
||||
108. Public relations complication involving asset tracking.
|
||||
109. Environmental hazard detected near legal documentation.
|
||||
110. Internal procedural conflict over maintenance logs.
|
||||
111. Logistical delay affecting transport schedules.
|
||||
112. Equipment calibration error in digital archives.
|
||||
113. Data inconsistency detected in facility access.
|
||||
114. Regulatory violation warning concerning corporate permits.
|
||||
115. Unexpected civilian involvement related to surveillance logs.
|
||||
116. Administrative confusion regarding supply chains.
|
||||
117. Technical malfunction impacting security clearances.
|
||||
118. Public relations complication involving energy grids.
|
||||
119. Environmental hazard detected near drone networks.
|
||||
120. Internal procedural conflict over field reports.
|
||||
121. Logistical delay affecting financial accounts.
|
||||
122. Equipment calibration error in communication relays.
|
||||
123. Data inconsistency detected in AI systems.
|
||||
124. Regulatory violation warning concerning environmental scanners.
|
||||
125. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
126. Administrative confusion regarding public terminals.
|
||||
127. Technical malfunction impacting team rosters.
|
||||
128. Public relations complication involving asset tracking.
|
||||
129. Environmental hazard detected near legal documentation.
|
||||
130. Internal procedural conflict over maintenance logs.
|
||||
131. Logistical delay affecting transport schedules.
|
||||
132. Equipment calibration error in digital archives.
|
||||
133. Data inconsistency detected in facility access.
|
||||
134. Regulatory violation warning concerning corporate permits.
|
||||
135. Unexpected civilian involvement related to surveillance logs.
|
||||
136. Administrative confusion regarding supply chains.
|
||||
137. Technical malfunction impacting security clearances.
|
||||
138. Public relations complication involving energy grids.
|
||||
139. Environmental hazard detected near drone networks.
|
||||
140. Internal procedural conflict over field reports.
|
||||
141. Logistical delay affecting financial accounts.
|
||||
142. Equipment calibration error in communication relays.
|
||||
143. Data inconsistency detected in AI systems.
|
||||
144. Regulatory violation warning concerning environmental scanners.
|
||||
145. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
146. Administrative confusion regarding public terminals.
|
||||
147. Technical malfunction impacting team rosters.
|
||||
148. Public relations complication involving asset tracking.
|
||||
149. Environmental hazard detected near legal documentation.
|
||||
150. Internal procedural conflict over maintenance logs.
|
||||
151. Logistical delay affecting transport schedules.
|
||||
152. Equipment calibration error in digital archives.
|
||||
153. Data inconsistency detected in facility access.
|
||||
154. Regulatory violation warning concerning corporate permits.
|
||||
155. Unexpected civilian involvement related to surveillance logs.
|
||||
156. Administrative confusion regarding supply chains.
|
||||
157. Technical malfunction impacting security clearances.
|
||||
158. Public relations complication involving energy grids.
|
||||
159. Environmental hazard detected near drone networks.
|
||||
160. Internal procedural conflict over field reports.
|
||||
161. Logistical delay affecting financial accounts.
|
||||
162. Equipment calibration error in communication relays.
|
||||
163. Data inconsistency detected in AI systems.
|
||||
164. Regulatory violation warning concerning environmental scanners.
|
||||
165. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
166. Administrative confusion regarding public terminals.
|
||||
167. Technical malfunction impacting team rosters.
|
||||
168. Public relations complication involving asset tracking.
|
||||
169. Environmental hazard detected near legal documentation.
|
||||
170. Internal procedural conflict over maintenance logs.
|
||||
171. Logistical delay affecting transport schedules.
|
||||
172. Equipment calibration error in digital archives.
|
||||
173. Data inconsistency detected in facility access.
|
||||
174. Regulatory violation warning concerning corporate permits.
|
||||
175. Unexpected civilian involvement related to surveillance logs.
|
||||
176. Administrative confusion regarding supply chains.
|
||||
177. Technical malfunction impacting security clearances.
|
||||
178. Public relations complication involving energy grids.
|
||||
179. Environmental hazard detected near drone networks.
|
||||
180. Internal procedural conflict over field reports.
|
||||
181. Logistical delay affecting financial accounts.
|
||||
182. Equipment calibration error in communication relays.
|
||||
183. Data inconsistency detected in AI systems.
|
||||
184. Regulatory violation warning concerning environmental scanners.
|
||||
185. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
186. Administrative confusion regarding public terminals.
|
||||
187. Technical malfunction impacting team rosters.
|
||||
188. Public relations complication involving asset tracking.
|
||||
189. Environmental hazard detected near legal documentation.
|
||||
190. Internal procedural conflict over maintenance logs.
|
||||
191. Logistical delay affecting transport schedules.
|
||||
192. Equipment calibration error in digital archives.
|
||||
193. Data inconsistency detected in facility access.
|
||||
194. Regulatory violation warning concerning corporate permits.
|
||||
195. Unexpected civilian involvement related to surveillance logs.
|
||||
196. Administrative confusion regarding supply chains.
|
||||
197. Technical malfunction impacting security clearances.
|
||||
198. Public relations complication involving energy grids.
|
||||
199. Environmental hazard detected near drone networks.
|
||||
200. Internal procedural conflict over field reports.
|
||||
201. Logistical delay affecting financial accounts.
|
||||
202. Equipment calibration error in communication relays.
|
||||
203. Data inconsistency detected in AI systems.
|
||||
204. Regulatory violation warning concerning environmental scanners.
|
||||
205. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
206. Administrative confusion regarding public terminals.
|
||||
207. Technical malfunction impacting team rosters.
|
||||
208. Public relations complication involving asset tracking.
|
||||
209. Environmental hazard detected near legal documentation.
|
||||
210. Internal procedural conflict over maintenance logs.
|
||||
211. Logistical delay affecting transport schedules.
|
||||
212. Equipment calibration error in digital archives.
|
||||
213. Data inconsistency detected in facility access.
|
||||
214. Regulatory violation warning concerning corporate permits.
|
||||
215. Unexpected civilian involvement related to surveillance logs.
|
||||
216. Administrative confusion regarding supply chains.
|
||||
217. Technical malfunction impacting security clearances.
|
||||
218. Public relations complication involving energy grids.
|
||||
219. Environmental hazard detected near drone networks.
|
||||
220. Internal procedural conflict over field reports.
|
||||
221. Logistical delay affecting financial accounts.
|
||||
222. Equipment calibration error in communication relays.
|
||||
223. Data inconsistency detected in AI systems.
|
||||
224. Regulatory violation warning concerning environmental scanners.
|
||||
225. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
226. Administrative confusion regarding public terminals.
|
||||
227. Technical malfunction impacting team rosters.
|
||||
228. Public relations complication involving asset tracking.
|
||||
229. Environmental hazard detected near legal documentation.
|
||||
230. Internal procedural conflict over maintenance logs.
|
||||
231. Logistical delay affecting transport schedules.
|
||||
232. Equipment calibration error in digital archives.
|
||||
233. Data inconsistency detected in facility access.
|
||||
234. Regulatory violation warning concerning corporate permits.
|
||||
235. Unexpected civilian involvement related to surveillance logs.
|
||||
236. Administrative confusion regarding supply chains.
|
||||
237. Technical malfunction impacting security clearances.
|
||||
238. Public relations complication involving energy grids.
|
||||
239. Environmental hazard detected near drone networks.
|
||||
240. Internal procedural conflict over field reports.
|
||||
241. Logistical delay affecting financial accounts.
|
||||
242. Equipment calibration error in communication relays.
|
||||
243. Data inconsistency detected in AI systems.
|
||||
244. Regulatory violation warning concerning environmental scanners.
|
||||
245. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
246. Administrative confusion regarding public terminals.
|
||||
247. Technical malfunction impacting team rosters.
|
||||
248. Public relations complication involving asset tracking.
|
||||
249. Environmental hazard detected near legal documentation.
|
||||
250. Internal procedural conflict over maintenance logs.
|
||||
251. Logistical delay affecting transport schedules.
|
||||
252. Equipment calibration error in digital archives.
|
||||
253. Data inconsistency detected in facility access.
|
||||
254. Regulatory violation warning concerning corporate permits.
|
||||
255. Unexpected civilian involvement related to surveillance logs.
|
||||
256. Administrative confusion regarding supply chains.
|
||||
257. Technical malfunction impacting security clearances.
|
||||
258. Public relations complication involving energy grids.
|
||||
259. Environmental hazard detected near drone networks.
|
||||
260. Internal procedural conflict over field reports.
|
||||
261. Logistical delay affecting financial accounts.
|
||||
262. Equipment calibration error in communication relays.
|
||||
263. Data inconsistency detected in AI systems.
|
||||
264. Regulatory violation warning concerning environmental scanners.
|
||||
265. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
266. Administrative confusion regarding public terminals.
|
||||
267. Technical malfunction impacting team rosters.
|
||||
268. Public relations complication involving asset tracking.
|
||||
269. Environmental hazard detected near legal documentation.
|
||||
270. Internal procedural conflict over maintenance logs.
|
||||
271. Logistical delay affecting transport schedules.
|
||||
272. Equipment calibration error in digital archives.
|
||||
273. Data inconsistency detected in facility access.
|
||||
274. Regulatory violation warning concerning corporate permits.
|
||||
275. Unexpected civilian involvement related to surveillance logs.
|
||||
276. Administrative confusion regarding supply chains.
|
||||
277. Technical malfunction impacting security clearances.
|
||||
278. Public relations complication involving energy grids.
|
||||
279. Environmental hazard detected near drone networks.
|
||||
280. Internal procedural conflict over field reports.
|
||||
281. Logistical delay affecting financial accounts.
|
||||
282. Equipment calibration error in communication relays.
|
||||
283. Data inconsistency detected in AI systems.
|
||||
284. Regulatory violation warning concerning environmental scanners.
|
||||
285. Unexpected civilian involvement related to vehicle diagnostics.
|
||||
286. Administrative confusion regarding public terminals.
|
||||
287. Technical malfunction impacting team rosters.
|
||||
288. Public relations complication involving asset tracking.
|
||||
289. Environmental hazard detected near legal documentation.
|
||||
290. Internal procedural conflict over maintenance logs.
|
||||
291. Logistical delay affecting transport schedules.
|
||||
292. Equipment calibration error in digital archives.
|
||||
293. Data inconsistency detected in facility access.
|
||||
294. Regulatory violation warning concerning corporate permits.
|
||||
295. Unexpected civilian involvement related to surveillance logs.
|
||||
296. Administrative confusion regarding supply chains.
|
||||
297. Technical malfunction impacting security clearances.
|
||||
298. Public relations complication involving energy grids.
|
||||
299. Environmental hazard detected near drone networks.
|
||||
300. Internal procedural conflict over field reports.
|
||||
@@ -0,0 +1,826 @@
|
||||
# SLA Industries BRP
|
||||
## Core Rulebook Foundation (Players + GMs)
|
||||
|
||||
Document Status: Living Foundation Draft
|
||||
Date: 2026-02-23
|
||||
System: `sla-industries-brp` (Foundry v13)
|
||||
Purpose: This is the long-form baseline rulebook for play, teaching, and in-app reference pages.
|
||||
|
||||
---
|
||||
|
||||
## How To Use This Rulebook
|
||||
|
||||
This document is designed for two audiences:
|
||||
|
||||
- **Players**: Learn what to roll, when to roll, and how decisions affect outcomes.
|
||||
- **GMs**: Run missions, adjudicate edge cases, pace scenes, and keep SLA tone consistent.
|
||||
|
||||
It is intentionally structured for Foundry Journal conversion:
|
||||
|
||||
- Chapter-level headings can map to Journal pages.
|
||||
- Sub-headings can map to anchored sections.
|
||||
- Examples can be extracted into “quick cards” or “teaching popups.”
|
||||
|
||||
---
|
||||
|
||||
## Rulebook Conventions
|
||||
|
||||
### Terms
|
||||
- `Target`: the percentage you roll against.
|
||||
- `Kept Roll`: the final die value used after edge mode selection.
|
||||
- `Edge`: Advantage, Normal, or Disadvantage.
|
||||
|
||||
### Priority
|
||||
When there is a rules conflict at table:
|
||||
1. Safety/common sense.
|
||||
2. Mission clarity.
|
||||
3. Core resolution consistency.
|
||||
4. Narrative tone of SLA Industries.
|
||||
|
||||
### GM Transparency Standard
|
||||
Players should always be told:
|
||||
- what they are rolling,
|
||||
- what edge mode applies,
|
||||
- what major modifiers changed the target,
|
||||
- what consequence tier occurred.
|
||||
|
||||
---
|
||||
|
||||
# Part I: Player Rules
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Resolution
|
||||
|
||||
Most actions use BRP percentile roll-under checks:
|
||||
|
||||
1. Set a target percentage.
|
||||
2. Apply modifiers.
|
||||
3. Roll d100 (or edge roll).
|
||||
4. Compare kept roll to final target.
|
||||
|
||||
Lower is better.
|
||||
|
||||
### Success Tiers
|
||||
All kept results resolve as one of:
|
||||
- `Critical`
|
||||
- `Special`
|
||||
- `Success`
|
||||
- `Failure`
|
||||
- `Fumble`
|
||||
|
||||
The exact tier affects narrative quality, damage quality, or follow-up opportunities.
|
||||
|
||||
---
|
||||
|
||||
## 2. Edge (Advantage / Disadvantage)
|
||||
|
||||
This system replaces old multiplier difficulty with edge mode.
|
||||
|
||||
### Edge Modes
|
||||
- `Advantage`: roll `1D100`, reroll only the tens die, keep the lower result.
|
||||
- `Normal`: roll `1D100`.
|
||||
- `Disadvantage`: roll `1D100`, reroll only the tens die, keep the higher result.
|
||||
|
||||
### Why It Matters
|
||||
- Advantage pushes toward better tiers.
|
||||
- Disadvantage pushes toward failure/fumble.
|
||||
- Core BRP success tiers remain unchanged.
|
||||
|
||||
### Quick Example
|
||||
Skill target `60%`:
|
||||
- Advantage roll: `74` and `32` -> keep `32` -> Success.
|
||||
- Disadvantage roll: `41` and `89` -> keep `89` -> Failure.
|
||||
|
||||
---
|
||||
|
||||
## 3. Building Your Final Target
|
||||
|
||||
Your final target can include multiple layers:
|
||||
|
||||
- `Base`: starting skill/stat value.
|
||||
- `Manual`: tactical flat modifier (`+/- %`).
|
||||
- `SLA Mods`: system overlays (drug, EBB, mental).
|
||||
- `Final`: the value actually tested.
|
||||
|
||||
The chat card presents this breakdown directly so everyone can audit the roll.
|
||||
|
||||
### Example
|
||||
`Base 52% | Manual +10% | SLA Mods -20% | Final 42%`
|
||||
|
||||
---
|
||||
|
||||
## 4. Standard Skill and Characteristic Checks
|
||||
|
||||
When you click a skill or characteristic:
|
||||
|
||||
1. Select edge mode.
|
||||
2. Apply tactical modifier if needed.
|
||||
3. Confirm roll.
|
||||
4. Read kept result and tier.
|
||||
|
||||
### Player Best Practice
|
||||
Before confirming, ask:
|
||||
- “Do we have positional advantage?”
|
||||
- “Are conditions harming this test?”
|
||||
- “Did we use tools/gear that should matter?”
|
||||
|
||||
That keeps modifiers meaningful and consistent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Resistance and POW-v-POW
|
||||
|
||||
Some checks are direct opposed-force style checks.
|
||||
|
||||
Use when:
|
||||
- resisting force,
|
||||
- testing one will/power directly against another,
|
||||
- resolving specific contest prompts.
|
||||
|
||||
You provide the opposing value, and target recalculates accordingly.
|
||||
|
||||
### Example
|
||||
You resist a brute-force shove:
|
||||
- enter opponent equivalent strength value,
|
||||
- system recalculates target,
|
||||
- roll and resolve tier.
|
||||
|
||||
---
|
||||
|
||||
## 6. SAN and COOL
|
||||
|
||||
SAN and COOL are integrated into check flows.
|
||||
|
||||
### SAN
|
||||
Used for psychological and existential strain.
|
||||
|
||||
### COOL
|
||||
Used for stress composure, tactical nerve, and emotional control under pressure.
|
||||
|
||||
Both support edge modes in prompts.
|
||||
|
||||
### Example: COOL under pressure
|
||||
- Base COOL: `48%`
|
||||
- Mental penalty: `-15%`
|
||||
- Final COOL: `33%`
|
||||
- Roll: `29` -> Success.
|
||||
|
||||
### Example: SAN with disadvantage
|
||||
- SAN target: `55%`
|
||||
- Disadvantage roll: `22`, `81` -> keep `81` -> Failure.
|
||||
|
||||
---
|
||||
|
||||
## 7. Initiative (Escalation Intent)
|
||||
|
||||
Current sheet supports:
|
||||
- `DEX INIT`
|
||||
- `INT INIT`
|
||||
- `SHIFT + click` for risk behavior where configured.
|
||||
|
||||
Core table goal:
|
||||
- one fast initiative resolution each round,
|
||||
- minimal bookkeeping,
|
||||
- meaningful risk pressure.
|
||||
|
||||
### Simple Round Start Example
|
||||
1. Op A: DEX INIT -> 18
|
||||
2. Op B: INT INIT + risk -> 24
|
||||
3. Op C: DEX INIT -> 11
|
||||
4. Order: B, A, C
|
||||
|
||||
---
|
||||
|
||||
## 8. Combat at Player Level
|
||||
|
||||
Combat is a check + consequence loop:
|
||||
|
||||
1. Choose attack/action.
|
||||
2. Set edge from circumstances.
|
||||
3. Roll and resolve tier.
|
||||
4. Apply ammo/effect context.
|
||||
5. Continue action economy.
|
||||
|
||||
### Ammo Context
|
||||
Combat cards can include:
|
||||
- ammo tag/type,
|
||||
- ammo effect text,
|
||||
- ammo credit spend/surcharge where relevant.
|
||||
|
||||
### Example
|
||||
Firearm (Rifle) `70%`, long range in rain:
|
||||
- Edge: Disadvantage
|
||||
- Roll: `14`, `83` -> keep `83`
|
||||
- Result: Failure
|
||||
|
||||
---
|
||||
|
||||
## 9. Equipment in Play (Player Guide)
|
||||
|
||||
Seeded equipment items can be drag-dropped to your actor sheet.
|
||||
|
||||
Think of gear as tactical leverage, not decoration.
|
||||
|
||||
### Communication Gear
|
||||
- **Headset Communicator**: keeps squad synced across split lanes.
|
||||
- **Klippo Multi-Band**: broader comm control and channel flexibility.
|
||||
|
||||
### Detection/Investigation Gear
|
||||
- **Environment Scanner**: hidden threats and scan-based discovery.
|
||||
- **Forensic Kit**: scene-quality evidence handling.
|
||||
- **Tactical Binoculars**: long-distance visual dominance.
|
||||
|
||||
### Medical Gear
|
||||
- **BOOPA Medical Kit (Std/Adv)**: field treatment reliability.
|
||||
- **Trauma Stabiliser**: preserve life until extraction.
|
||||
|
||||
### Movement/Breach Gear
|
||||
- **Grapple Gun** + **Harness**: vertical route control.
|
||||
- **Breaching Charge**: forced entry solutions.
|
||||
- **Smoke Grenade**: reposition and extraction support.
|
||||
|
||||
### Specialist Gear
|
||||
- **Ebb Damper**: localized anti-EBB pressure.
|
||||
- **Magna-Clamp Tracker**: covert track-and-follow operations.
|
||||
|
||||
---
|
||||
|
||||
## 10. Example of Play: Street Contact to Firefight
|
||||
|
||||
### Scene Setup
|
||||
A WHITE BPN investigation team tracks a suspect through a transit platform.
|
||||
|
||||
### Step 1: Search Sweep
|
||||
- Investigator uses **Environment Scanner (Standard)**.
|
||||
- GM grants favorable conditions -> Advantage.
|
||||
- Search target `58%`.
|
||||
- Roll `66`, `21` -> keep `21` -> Special.
|
||||
- Outcome: suspect heat signature detected behind kiosk support wall.
|
||||
|
||||
### Step 2: Calm the Witness
|
||||
- Social check under panic crowd noise.
|
||||
- GM sets Disadvantage due chaos.
|
||||
- Skill target `47%`.
|
||||
- Roll `12`, `74` -> keep `74` -> Failure.
|
||||
- Outcome: witness bolts toward unsafe exit.
|
||||
|
||||
### Step 3: Cover Movement
|
||||
- Support op deploys **Smoke Grenade** between crowd and hostile lane.
|
||||
- Team crosses open line with reduced direct observation.
|
||||
|
||||
### Step 4: Engagement
|
||||
- Shooter attacks with pistol, target `53%` after all mods.
|
||||
- Normal roll `29` -> Success.
|
||||
- Hit resolves through combat card flow.
|
||||
|
||||
### Step 5: Post-Contact Evidence
|
||||
- Team uses **Forensic Kit** + **Camera** for chain evidence.
|
||||
- BPN debrief includes proof package and media-safe narrative.
|
||||
|
||||
---
|
||||
|
||||
## 11. Quick Player Reference
|
||||
|
||||
When you act, run this checklist:
|
||||
|
||||
1. What am I rolling?
|
||||
2. What is my edge mode?
|
||||
3. What tactical modifier applies?
|
||||
4. Did gear/tooling change this?
|
||||
5. What consequence happens on fail/fumble?
|
||||
|
||||
This keeps play fast and transparent.
|
||||
|
||||
---
|
||||
|
||||
# Part II: GM Rules and Procedures
|
||||
|
||||
---
|
||||
|
||||
## 12. GM Adjudication Framework
|
||||
|
||||
Use this order when deciding edge and modifiers:
|
||||
|
||||
1. Positioning and preparation.
|
||||
2. Environmental pressure.
|
||||
3. Opposition quality.
|
||||
4. Time pressure.
|
||||
5. Visibility/media constraints.
|
||||
|
||||
### Assigning Edge
|
||||
Use edge for scene quality:
|
||||
- clear advantage -> Advantage,
|
||||
- ordinary conditions -> Normal,
|
||||
- materially impaired -> Disadvantage.
|
||||
|
||||
### Assigning Flat Modifiers
|
||||
Use flat mods for specific measurable impacts:
|
||||
- `+10%` clear tactical help,
|
||||
- `-10%` moderate interference,
|
||||
- `-20%` severe but survivable pressure,
|
||||
- `-30%+` extreme operational degradation.
|
||||
|
||||
---
|
||||
|
||||
## 13. Running the Mission Loop (BPN-Centric)
|
||||
|
||||
Recommended session loop:
|
||||
|
||||
1. Briefing issued (BPN page).
|
||||
2. Infiltration/contact phase.
|
||||
3. Escalation and conflict phase.
|
||||
4. Resolution/debrief phase.
|
||||
5. Rewards/reputation consequences.
|
||||
|
||||
### BPN Toolkit Support
|
||||
Use toolkit for:
|
||||
- color coding,
|
||||
- mission tables,
|
||||
- objective layers,
|
||||
- escalation events,
|
||||
- GM debrief pages.
|
||||
|
||||
---
|
||||
|
||||
## 14. Consequences and Stakes
|
||||
|
||||
Set consequence expectations before high-risk rolls.
|
||||
|
||||
### Good consequence declarations
|
||||
- “If this fails, the convoy leaves and you lose pursuit window.”
|
||||
- “On fumble, the feed goes live with compromised footage.”
|
||||
|
||||
Players should know what they are risking.
|
||||
|
||||
---
|
||||
|
||||
## 15. Media and Corporate Pressure (Tone Layer)
|
||||
|
||||
SLA missions are often performative.
|
||||
|
||||
GM pressure levers:
|
||||
- witness optics,
|
||||
- collateral narratives,
|
||||
- sponsor-safe behavior,
|
||||
- command-facing reporting quality.
|
||||
|
||||
Keep this layer active without drowning tactical clarity.
|
||||
|
||||
---
|
||||
|
||||
## 16. Gear Adjudication by Scene Type
|
||||
|
||||
### Investigation scenes
|
||||
Prioritize:
|
||||
- scanner, forensics, camera, recorder, organizer.
|
||||
|
||||
### Tactical entry scenes
|
||||
Prioritize:
|
||||
- breaching, smoke/flash, motion tracking, comm discipline.
|
||||
|
||||
### Biohazard / occult scenes
|
||||
Prioritize:
|
||||
- environment scanners, medical stabilization, EBB dampening support.
|
||||
|
||||
### Escort / logistics scenes
|
||||
Prioritize:
|
||||
- tracking tools, convoy comms, route planning, rapid first response gear.
|
||||
|
||||
---
|
||||
|
||||
## 17. Full GM Example: RED BPN Crisis
|
||||
|
||||
### Brief
|
||||
Red BPN: hijacked monorail platform, live media saturation.
|
||||
|
||||
### GM Setup
|
||||
- Primary objective: stop hijackers before train departure.
|
||||
- Secondary objective: recover encrypted drive from handler.
|
||||
- Cleanup objective: avoid public footage of forbidden methods.
|
||||
|
||||
### Encounter 1: Crowd Control
|
||||
- Crowd density imposes Disadvantage on broad social command checks.
|
||||
- Successful use of loud comm routing and visible authority can restore Normal.
|
||||
|
||||
### Encounter 2: Access Control
|
||||
- Locked control room.
|
||||
- Team uses **Electronic Lockpick/Bypass**.
|
||||
- GM grants Advantage if data from organizer maps gives door architecture insights.
|
||||
|
||||
### Encounter 3: Live Fire Corridor
|
||||
- Smoke deployed to split hostile LOS.
|
||||
- One op uses tactical binoculars from elevated angle to call target movement.
|
||||
- Combat resolves with ammo and damage context.
|
||||
|
||||
### Aftermath
|
||||
- Debrief judges tactical success plus media narrative compliance.
|
||||
- Reward outcomes include CBS + SCL effect + reputation implications.
|
||||
|
||||
---
|
||||
|
||||
## 18. Teaching Mode for New Tables
|
||||
|
||||
For onboarding sessions:
|
||||
|
||||
1. First hour: use only Normal edge.
|
||||
2. Introduce Advantage/Disadvantage once players understand target flow.
|
||||
3. Add tactical modifiers only after two to three resolved checks.
|
||||
4. Introduce SAN/COOL pressure in scene 2 or 3.
|
||||
5. Add ammo/equipment depth when players are ready.
|
||||
|
||||
This staged rollout prevents overload.
|
||||
|
||||
---
|
||||
|
||||
# Part III: Foundry Journal / Module Structure
|
||||
|
||||
---
|
||||
|
||||
## 19. Suggested Journal Page Map
|
||||
|
||||
Recommended split for module teaching/reference pages:
|
||||
|
||||
1. Core Resolution
|
||||
2. Edge System
|
||||
3. Target Modifiers
|
||||
4. Skill and Characteristic Checks
|
||||
5. SAN and COOL
|
||||
6. Initiative
|
||||
7. Combat Flow
|
||||
8. Equipment Use
|
||||
9. Player Quickstart
|
||||
10. GM Adjudication
|
||||
11. BPN Mission Loop
|
||||
12. Advanced Examples
|
||||
13. FAQ and Clarifications
|
||||
14. Changelog
|
||||
15. Expansion Notes
|
||||
|
||||
---
|
||||
|
||||
## 20. Suggested Tags for Internal Linking
|
||||
|
||||
Use these tags in page headings or metadata:
|
||||
- `rules-core`
|
||||
- `rules-edge`
|
||||
- `rules-combat`
|
||||
- `rules-sanity-cool`
|
||||
- `rules-equipment`
|
||||
- `gm-procedure`
|
||||
- `gm-examples`
|
||||
- `player-quickref`
|
||||
|
||||
This helps searchable teaching UI and sidebar filtering.
|
||||
|
||||
---
|
||||
|
||||
## 21. Expansion Slots (Deliberate Future Sections)
|
||||
|
||||
Reserved for future passes:
|
||||
|
||||
- Character creation full walkthrough
|
||||
- Species and package deep rules
|
||||
- Downtime/economy cycle rules
|
||||
- Injury and recovery deep chapter
|
||||
- Advanced EBB chapter
|
||||
- Encounter templates by BPN color
|
||||
- Solo-play chapter expansion
|
||||
- Optional realism toggles
|
||||
|
||||
Each slot can become its own chapter without restructuring this foundation.
|
||||
|
||||
---
|
||||
|
||||
# Part IV: Reference Tables
|
||||
|
||||
---
|
||||
|
||||
## 22. Edge Quick Table
|
||||
|
||||
| Situation | Edge Recommendation |
|
||||
|---|---|
|
||||
| Prepared, superior angle, good intel | Advantage |
|
||||
| Standard operating conditions | Normal |
|
||||
| Low visibility, time pressure, suppression | Disadvantage |
|
||||
|
||||
---
|
||||
|
||||
## 23. Modifier Quick Table
|
||||
|
||||
| Pressure Level | Typical Modifier |
|
||||
|---|---|
|
||||
| Minor help/hindrance | +/-10% |
|
||||
| Strong tactical impact | +/-20% |
|
||||
| Extreme condition | +/-30% or more |
|
||||
|
||||
---
|
||||
|
||||
## 24. Gear Utility Quick Table
|
||||
|
||||
| Gear | Typical Use |
|
||||
|---|---|
|
||||
| Headset Communicator | coordination under split movement |
|
||||
| Environment Scanner | hidden threat detection |
|
||||
| Forensic Kit | evidence-grade scene processing |
|
||||
| BOOPA Medical Kit | trauma and field treatment |
|
||||
| Grapple Gun | vertical movement/insertion |
|
||||
| Smoke Grenade | LOS disruption and reposition |
|
||||
| Ebb Damper | anti-EBB tactical control |
|
||||
| Magna-Clamp Tracker | covert vehicle tracking |
|
||||
|
||||
---
|
||||
|
||||
# Part V: Living Document Management
|
||||
|
||||
---
|
||||
|
||||
## 25. Update Protocol
|
||||
|
||||
When rules change:
|
||||
|
||||
1. Update affected chapter text.
|
||||
2. Add one concrete example reflecting new behavior.
|
||||
3. Add change note in changelog section.
|
||||
4. Ensure Foundry page map remains aligned.
|
||||
|
||||
---
|
||||
|
||||
## 26. Changelog (Rulebook)
|
||||
|
||||
### 2026-02-23
|
||||
- Created full rulebook foundation for players and GMs.
|
||||
- Added structured chapter split for Foundry journal/module teaching use.
|
||||
- Added core play examples and gear examples.
|
||||
- Added explicit expansion slots for future development.
|
||||
- Added Part VI: Wounds, Death & Healing (0-HP CON save flow, bleed-out, first aid, serious injury model).
|
||||
- Added Part VII: NPCs & Adversaries (tier model, BPN threat mapping, Foundry NPC workflow).
|
||||
|
||||
---
|
||||
|
||||
## 27. Final Note
|
||||
|
||||
This rulebook is intentionally built as a **foundation**, not a closed final text.
|
||||
|
||||
As the system grows, continue expanding chapter-by-chapter while preserving:
|
||||
- core resolution clarity,
|
||||
- edge consistency,
|
||||
- transparent modifier math,
|
||||
- SLA mission tone and consequence pressure.
|
||||
|
||||
---
|
||||
|
||||
# Part VI: Wounds, Death & Healing
|
||||
|
||||
---
|
||||
|
||||
## 28. Hit Points, Collapse, and CON Saves
|
||||
|
||||
This system uses a single HP track with a dangerous 0-HP threshold.
|
||||
|
||||
- At `HP > 0`: you remain active (with penalties at low HP).
|
||||
- At `HP <= 0`: you are in a critical state and must resolve a CON Save.
|
||||
|
||||
### Suggested HP Formula
|
||||
|
||||
Use BRP baseline:
|
||||
|
||||
- `HP = (CON + SIZ) / 2`, rounded up.
|
||||
|
||||
If a table is running without SIZ:
|
||||
|
||||
- `HP = CON` (or a similar simplified house variant).
|
||||
|
||||
### Wound State Language
|
||||
|
||||
Use these labels for clarity:
|
||||
|
||||
- `Healthy`: above half max HP.
|
||||
- `Wounded`: at or below half max HP, above 1 HP.
|
||||
- `Severely Wounded`: 1 HP or less, but still above 0.
|
||||
- `Critical`: 0 HP or below.
|
||||
|
||||
---
|
||||
|
||||
## 29. Low-HP Penalties
|
||||
|
||||
When HP falls to `<= half max HP` (round down):
|
||||
|
||||
- Apply `-10%` to most physical actions:
|
||||
- Athletics
|
||||
- Mobility
|
||||
- Melee
|
||||
- Firearms
|
||||
- similarly strenuous checks
|
||||
|
||||
When HP is `1` (or very close to collapse but still positive):
|
||||
|
||||
- Apply `-20%` to most physical actions.
|
||||
- GM may add Disadvantage on extreme exertion.
|
||||
|
||||
These penalties model pain, shock, and blood loss before total collapse.
|
||||
|
||||
---
|
||||
|
||||
## 30. 0 HP or Below: CON Save Procedure
|
||||
|
||||
When a character is reduced to `0 HP or below`:
|
||||
|
||||
1. Record the new HP value (including negative values if used).
|
||||
2. Immediately resolve a CON Save.
|
||||
|
||||
### CON Save Roll
|
||||
|
||||
- Roll `d100` against CON% (usually CON x5).
|
||||
- Apply Edge as normal:
|
||||
- Advantage for favorable survival factors.
|
||||
- Disadvantage for massive trauma, blast impact, or compounded injuries.
|
||||
|
||||
### CON Save Results
|
||||
|
||||
- `Success / Special / Critical`: **Down but Alive**.
|
||||
- `Failure`: **Dying**.
|
||||
- `Fumble`: **Dead** (or catastrophic non-survivable trauma).
|
||||
|
||||
This keeps 0-HP moments fast and decisive.
|
||||
|
||||
---
|
||||
|
||||
## 31. Critical States
|
||||
|
||||
### Down but Alive
|
||||
|
||||
The character is out of the fight (unconscious or barely responsive), but not immediately bleeding out.
|
||||
|
||||
- Cannot take normal actions.
|
||||
- Requires treatment before meaningful return to action.
|
||||
- Further damage can still kill.
|
||||
|
||||
### Dying
|
||||
|
||||
Without aid, the character will die.
|
||||
|
||||
- Start a bleed-out timer in rounds:
|
||||
- default guide: rounds remaining = CON score.
|
||||
- Timer expires -> death.
|
||||
|
||||
Successful stabilisation shifts state to **Down but Alive**.
|
||||
|
||||
### Dead
|
||||
|
||||
On CON Save fumble at 0 HP:
|
||||
|
||||
- character is dead, or
|
||||
- injuries are narratively irrecoverable without extraordinary intervention.
|
||||
|
||||
---
|
||||
|
||||
## 32. Serious Injury Table (Recommended)
|
||||
|
||||
When a character survives 0 HP (Down but Alive or stabilised), roll `1d6`:
|
||||
|
||||
| d6 | Injury Type | Effect |
|
||||
|---|---|---|
|
||||
| 1 | Close Call | No lasting body injury; shaken. -10% to SAN/COOL checks for rest of current BPN. |
|
||||
| 2 | Deep Flesh Wound | -10% to one physical skill until fully recovered. |
|
||||
| 3 | Fracture / Broken Limb | -20% to relevant limb actions until proper treatment. |
|
||||
| 4 | Internal Damage | Temporary max HP reduction (suggest `-2`) until clinical care. |
|
||||
| 5 | Disfigurement / Scar | Contextual social modifier (`-10%` with some NPCs, possible intimidation upside elsewhere). |
|
||||
| 6 | Near-Death Trauma | Mental scar: situational `-10%` to SAN/COOL until therapy, downtime processing, or equivalent recovery. |
|
||||
|
||||
Use this to preserve campaign consequence without overcomplicating core combat timing.
|
||||
|
||||
---
|
||||
|
||||
## 33. Healing Flow
|
||||
|
||||
### Natural Recovery
|
||||
|
||||
If stable and at positive HP:
|
||||
|
||||
- recover `1 HP/day` with full rest.
|
||||
- harsh conditions can reduce this rate.
|
||||
|
||||
If at 0 or below and only just stabilised:
|
||||
|
||||
- no natural recovery until first aid/medicine establishes stability.
|
||||
|
||||
### First Aid (Field)
|
||||
|
||||
A successful First Aid attempt can:
|
||||
|
||||
- stop bleeding (if Dying),
|
||||
- move Dying -> Down but Alive,
|
||||
- restore a small amount of HP (typical `1d3`, or `1d3+1` with superior kit).
|
||||
|
||||
Failure: no effect.
|
||||
Fumble: condition worsens at GM discretion.
|
||||
|
||||
### Medicine (Clinical / Advanced)
|
||||
|
||||
Medicine governs surgery, deep treatment, and structured recovery.
|
||||
|
||||
- Clinical care can restore HP faster and remove serious-injury penalties.
|
||||
- Field medicine with advanced kit can partially replicate this at higher risk.
|
||||
|
||||
---
|
||||
|
||||
## 34. Drugs and Trauma Gear in This Flow
|
||||
|
||||
Use existing system effects to support crisis resolution:
|
||||
|
||||
- `KickStart`: emergency HP regain; repeated use should carry crash risk.
|
||||
- `Splatter`: rapid bleed control and stabilisation support.
|
||||
- `Trauma Stabiliser`: buys extraction time for otherwise terminal casualties.
|
||||
|
||||
If your table has removed Fatigue tracking, convert any fatigue references into short-duration action penalties or post-scene crash effects.
|
||||
|
||||
---
|
||||
|
||||
## 35. Wounds Example (Table Flow)
|
||||
|
||||
Operative starts at 11 HP and takes 13 damage:
|
||||
|
||||
- New HP = `-2`.
|
||||
- Immediate CON Save at CON%.
|
||||
- Roll succeeds -> **Down but Alive**.
|
||||
- Medic applies field treatment and restores small HP.
|
||||
- Character is alive, out of action, and may carry a serious injury into later scenes.
|
||||
|
||||
This gives fast high-stakes resolution while preserving campaign consequences.
|
||||
|
||||
---
|
||||
|
||||
# Part VII: NPCs & Adversaries
|
||||
|
||||
---
|
||||
|
||||
## 36. Tiered Opposition Model
|
||||
|
||||
Use tiers to control prep effort and encounter intensity:
|
||||
|
||||
- Tier 1: Extras (fast to drop, low complexity)
|
||||
- Tier 2: Standard Opposition (core mission pressure)
|
||||
- Tier 3: Elite Operatives (specialists with strong action impact)
|
||||
- Tier 4: Horrors/Set Pieces (scene-defining threats)
|
||||
|
||||
This is the default adversary scaling model for SLA BRP.
|
||||
|
||||
---
|
||||
|
||||
## 37. Standard NPC Block
|
||||
|
||||
Keep NPC blocks concise and table-usable:
|
||||
|
||||
- Name and Role
|
||||
- Tier
|
||||
- STR/CON/DEX/INT/POW/CHA/SIZ
|
||||
- HP
|
||||
- Key Skills only
|
||||
- Attack lines (%, damage, notable effect)
|
||||
- Armour
|
||||
- Traits
|
||||
- Gear
|
||||
- BPN hooks
|
||||
|
||||
Do not overbuild non-essential NPCs.
|
||||
|
||||
---
|
||||
|
||||
## 38. BPN Colour to Threat Mix
|
||||
|
||||
Use colour code to choose opposition rapidly:
|
||||
|
||||
- Blue: gangers, low-end cults, minor Carrien
|
||||
- White: serial killers, corrupt staff, hidden handlers
|
||||
- Yellow: retrieval rivals, thieves, smugglers
|
||||
- Green: Carrien packs, Manchines, sector horrors
|
||||
- Red: cells, riot threats, heavy response contacts
|
||||
- Grey: rogue ops, corporate/internal enforcement
|
||||
- Jade: biohazard and Ebb-unstable entities
|
||||
- Black: high-tier deniable threats
|
||||
- Silver: media-facing sabotage and PR adversaries
|
||||
- Platinum: unique custom threats only
|
||||
|
||||
---
|
||||
|
||||
## 39. Foundry NPC Workflow
|
||||
|
||||
System support includes:
|
||||
|
||||
- Searchable NPC/adversary guide journal:
|
||||
- `SLA NPCS & ADVERSARIES – PLAYERS & GMS`
|
||||
- Sidebar access:
|
||||
- Journal button: `NPC Guide`
|
||||
- Actor button: `Seed NPC Adversaries`
|
||||
- Seeded archetype actors by tier:
|
||||
- Downtown Ganger
|
||||
- Basic Civilian
|
||||
- Shiver Patrol Officer
|
||||
- DarkNight Cell Operative
|
||||
- Cloak Division Agent
|
||||
- Thresher Powersuit Pilot
|
||||
- Carrien Hunter
|
||||
- Manchine
|
||||
|
||||
This gives immediate drag-and-run opposition without manual stat construction each session.
|
||||
@@ -0,0 +1,344 @@
|
||||
# SLA Industries BRP
|
||||
## Rules of Play Reference (Expanded)
|
||||
|
||||
Date: 2026-02-23
|
||||
Scope: Practical table-facing rules reference for the current Foundry implementation.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Resolution Engine
|
||||
|
||||
This system uses BRP-style percentile checks:
|
||||
|
||||
1. Determine target percentage.
|
||||
2. Apply modifiers.
|
||||
3. Roll d100 (or edge roll, see below).
|
||||
4. Compare kept result to target.
|
||||
|
||||
Lower results are better.
|
||||
|
||||
### Success Tiers
|
||||
- `Critical`
|
||||
- `Special`
|
||||
- `Success`
|
||||
- `Failure`
|
||||
- `Fumble`
|
||||
|
||||
The tier is determined from the kept d100 result against the final target.
|
||||
|
||||
---
|
||||
|
||||
## 2. Edge System (Advantage / Disadvantage)
|
||||
|
||||
Difficulty multipliers are replaced by edge mode:
|
||||
|
||||
- `Advantage`: roll `1D100`, reroll only the tens die, keep the lower result.
|
||||
- `Normal`: roll `1D100`.
|
||||
- `Disadvantage`: roll `1D100`, reroll only the tens die, keep the higher result.
|
||||
|
||||
### Why this matters
|
||||
- Advantage increases chance of critical/special/success.
|
||||
- Disadvantage increases chance of failure/fumble.
|
||||
- The system keeps BRP tier math; only the die selection changes.
|
||||
|
||||
### Example (Skill 60%)
|
||||
- **Advantage**: roll `74` and `32`, keep `32` -> Success.
|
||||
- **Disadvantage**: roll `41` and `89`, keep `89` -> Failure.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target Score Construction
|
||||
|
||||
The system calculates a final target from multiple layers.
|
||||
|
||||
Displayed in chat as:
|
||||
- `Base`
|
||||
- `Manual` (tactical flat modifier)
|
||||
- `SLA Mods` (drug/EBB/mental overlays)
|
||||
- `Final`
|
||||
|
||||
### Example Breakdown
|
||||
`Base 52% | Manual +10% | SLA Mods -20% | Final 42%`
|
||||
|
||||
This makes it clear what changed the roll and by how much.
|
||||
|
||||
---
|
||||
|
||||
## 4. Roll Dialog Workflow
|
||||
|
||||
The roll dialog now functions as `SLA Roll Control` with `EDGE CONTROL` framing.
|
||||
|
||||
Typical fields:
|
||||
- `Roll Edge` (Advantage/Normal/Disadvantage)
|
||||
- `Tactical Modifier (%)`
|
||||
- Context fields when relevant:
|
||||
- Resistance value
|
||||
- Range / hands used
|
||||
- Ammo type
|
||||
- Wound target (First Aid flows)
|
||||
|
||||
### Fast use pattern
|
||||
- Routine action: Normal, `0` modifier.
|
||||
- Favored tactical setup: Advantage, positive modifier if justified.
|
||||
- Harsh conditions: Disadvantage, negative modifier if justified.
|
||||
|
||||
---
|
||||
|
||||
## 5. Characteristic and Skill Checks
|
||||
|
||||
### Characteristic checks
|
||||
Use characteristic roll controls (e.g., STR/DEX/INT/POW).
|
||||
|
||||
### Skill checks
|
||||
Use skill row roll controls.
|
||||
|
||||
Both route into the same core pipeline:
|
||||
- edge selection,
|
||||
- target build,
|
||||
- kept roll,
|
||||
- BRP tier.
|
||||
|
||||
### Example: Athletics Under Fire
|
||||
- Base Athletics: `64%`
|
||||
- GM applies tactical penalty for suppression: `-20%`
|
||||
- Drug effect grants `+10%`
|
||||
- Final: `54%`
|
||||
- Roll (Disadvantage): `18` and `77`, keep `77` -> Failure.
|
||||
|
||||
---
|
||||
|
||||
## 6. Resistance and POW-v-POW
|
||||
|
||||
Resistance style flows remain supported for opposed-characteristic logic.
|
||||
|
||||
Use cases:
|
||||
- Strength vs physical force.
|
||||
- POW-v-POW contests.
|
||||
|
||||
Inputs include the opposing value, and target is recalculated accordingly.
|
||||
|
||||
### Example
|
||||
- Operative STR test vs opposing STR equivalent.
|
||||
- Enter resistance value in prompt.
|
||||
- System recalculates percentile chance and resolves as normal.
|
||||
|
||||
---
|
||||
|
||||
## 7. SAN and COOL Flows
|
||||
|
||||
SAN/COOL checks support edge mode like standard checks:
|
||||
- Advantage / Normal / Disadvantage.
|
||||
|
||||
Mental-state modifiers can alter target.
|
||||
|
||||
### Example: COOL Under Psychological Pressure
|
||||
- Base COOL: `48%`
|
||||
- Mental pressure modifier: `-15%`
|
||||
- Final COOL target: `33%`
|
||||
- Roll (Normal): `29` -> Success.
|
||||
|
||||
### Example: SAN Shock With Disadvantage
|
||||
- SAN target: `55%`
|
||||
- Disadvantage roll: `22` and `81`, keep `81` -> Failure.
|
||||
|
||||
---
|
||||
|
||||
## 8. Escalation Initiative (Current Play Intent)
|
||||
|
||||
Character sheet supports initiative actions:
|
||||
- `DEX INIT`
|
||||
- `INT INIT`
|
||||
- `SHIFT` + click invokes risk behavior.
|
||||
|
||||
Design intent:
|
||||
- One initiative roll per round.
|
||||
- Fast turn ordering with optional risk pressure.
|
||||
|
||||
### Example Round Start
|
||||
1. Op A uses `DEX INIT` -> 18.
|
||||
2. Op B uses `INT INIT` with risk -> 24.
|
||||
3. Op C uses `DEX INIT` -> 11.
|
||||
4. Turn order: B, A, C.
|
||||
|
||||
---
|
||||
|
||||
## 9. Combat Resolution Overview
|
||||
|
||||
Combat rolls use the same percentile edge pipeline, then branch to combat effects:
|
||||
- result tier,
|
||||
- ammo context,
|
||||
- optional quick-apply damage actions from chat card.
|
||||
|
||||
### Ammo Context in Combat
|
||||
Combat cards can show:
|
||||
- ammo tag/type,
|
||||
- ammo effect summary,
|
||||
- credits spent/surcharge where relevant.
|
||||
|
||||
### Example: Rifle Attack
|
||||
- Firearm (Rifle): `70%`
|
||||
- Long range and smoke: Disadvantage.
|
||||
- Roll: `14` and `83`, keep `83` -> Failure.
|
||||
- Ammo still consumed per fire mode.
|
||||
|
||||
---
|
||||
|
||||
## 10. Chat Card Reading Guide
|
||||
|
||||
For non-resistance checks, each card now shows:
|
||||
- Header: edge roll type (`Advantage Roll`, etc.)
|
||||
- Main line: skill/stat and final target.
|
||||
- Meta line 1: edge + target breakdown.
|
||||
- Meta line 2: roll details (`1D100 base + tens re-roll -> keep best/worst`).
|
||||
|
||||
### Example Chat Interpretation
|
||||
`Edge: Advantage | Base 60% | Manual +10% | SLA Mods -5% | Final 65%`
|
||||
`Roll: 1D100 base 74 | Edge tens d10:3 => 34 | keep lowest 34`
|
||||
Result: `Success: 32`
|
||||
|
||||
This gives full transparency for players and GM.
|
||||
|
||||
---
|
||||
|
||||
## 11. Drugs, EBB, and Mental Overlays
|
||||
|
||||
Subsystem overlays can add flat target modifiers or state effects.
|
||||
|
||||
Displayed/used in roll pipeline:
|
||||
- Drug summary + roll modifier.
|
||||
- EBB effects and roll modifier.
|
||||
- Mental effects and roll modifier.
|
||||
|
||||
### Example Combined Overlay
|
||||
- Base skill: `45%`
|
||||
- Tactical mod: `+10%`
|
||||
- Drug mod: `+15%`
|
||||
- Mental stress: `-20%`
|
||||
- Final: `50%`
|
||||
|
||||
Roll at Normal: `47` -> Success.
|
||||
|
||||
---
|
||||
|
||||
## 12. Equipment Play Usage (With Examples)
|
||||
|
||||
The system includes seeded drag/drop `gear` items from the SLA equipment catalogue.
|
||||
|
||||
### Common Gear Categories
|
||||
- Communications
|
||||
- Surveillance/Detection
|
||||
- Medical
|
||||
- Climbing/Exploration
|
||||
- Utility/Breaching
|
||||
- Personal/Field sustainment
|
||||
- Specialist tech
|
||||
- Non-lethal/security
|
||||
|
||||
### Example Gear in Play
|
||||
|
||||
#### A) Headset Communicator
|
||||
Use: keep squad coordinated in split locations.
|
||||
- Practical effect: easier coordinated tactical calls.
|
||||
- Example: two-team pincer where rear team feeds movement updates.
|
||||
|
||||
#### B) Environment Scanner (Standard)
|
||||
Use: detect hidden hostiles/heat signatures.
|
||||
- Example: squad sweeps maintenance corridor before breach.
|
||||
- GM can grant better detection outcomes where scanner is relevant.
|
||||
|
||||
#### C) Forensic Kit
|
||||
Use: evidence-grade scene processing.
|
||||
- Example: White BPN murder scene.
|
||||
- Without kit, forensics performance is worse; with kit, evidence capture is reliable.
|
||||
|
||||
#### D) BOOPA Medical Kit (Advanced)
|
||||
Use: trauma stabilization and field treatment.
|
||||
- Example: teammate drops to critical after hallway ambush.
|
||||
- Medic uses advanced kit to stabilize before extraction.
|
||||
|
||||
#### E) Grapple Gun + Climbing Harness
|
||||
Use: vertical insertion/exfiltration.
|
||||
- Example: rooftop access to avoid checkpoint firefight.
|
||||
|
||||
#### F) Smoke Grenade
|
||||
Use: break LOS, reposition, or extract.
|
||||
- Example: pop smoke across open street and relocate to hard cover.
|
||||
|
||||
#### G) Ebb Damper
|
||||
Use: suppress EBB activity in close zone.
|
||||
- Example: hostile EBB user in confined room; team deploys damper before entry.
|
||||
|
||||
#### H) Magna-Clamp Tracker
|
||||
Use: covertly mark target vehicle.
|
||||
- Example: Grey BPN convoy tail without open confrontation.
|
||||
|
||||
---
|
||||
|
||||
## 13. BPN Toolkit in Active Play
|
||||
|
||||
GM can generate mission packets and run operations quickly.
|
||||
|
||||
Typical cycle:
|
||||
1. Generate BPN journal from toolkit.
|
||||
2. Present player-facing brief.
|
||||
3. Keep GM-only debrief hidden.
|
||||
4. Link scenes, opposition actors, and items.
|
||||
5. Run mission and update debrief with outcomes.
|
||||
|
||||
### Example Session Start
|
||||
- Generate `WHITE BPN - Missing Precinct Team`.
|
||||
- Seed opposition clues and locations.
|
||||
- Squad rolls initial Investigate/Perception checks with edge based on scene conditions.
|
||||
|
||||
---
|
||||
|
||||
## 14. End-to-End Example of Play (Full Turn)
|
||||
|
||||
### Setup
|
||||
- Op skill (Firearm Pistol): `58%`
|
||||
- Tactical stance bonus: `+10%`
|
||||
- Panic penalty: `-15%`
|
||||
- Final target: `53%`
|
||||
|
||||
### Action 1: Opening Shot
|
||||
- Edge: Advantage (target exposed)
|
||||
- Roll: `64`, `27`, keep `27`
|
||||
- Result: Success (27 <= 53)
|
||||
- Combat card resolves hit path.
|
||||
|
||||
### Action 2: Return Fire Pressure
|
||||
- Same skill, now under smoke + movement penalty.
|
||||
- Edge: Disadvantage
|
||||
- Manual mod: `-10%`
|
||||
- Final target: `43%`
|
||||
- Roll: `18`, `71`, keep `71`
|
||||
- Result: Failure.
|
||||
|
||||
### Team Utility Action
|
||||
- Support op throws smoke grenade to cut enemy LOS.
|
||||
- Next ally movement occurs behind visual disruption.
|
||||
|
||||
This demonstrates how edge + modifiers drive tactical tempo.
|
||||
|
||||
---
|
||||
|
||||
## 15. GM Adjudication Tips
|
||||
|
||||
- Use edge mode for situational quality; use flat modifier for specific numeric pressure.
|
||||
- Keep modifier math visible to maintain trust.
|
||||
- Use equipment relevance as a real tactical lever.
|
||||
- Reserve disadvantage for genuinely degraded conditions, not routine difficulty.
|
||||
- When in doubt, prefer consistent edge rulings over ad hoc multiplier math.
|
||||
|
||||
---
|
||||
|
||||
## 16. Current Draft State
|
||||
|
||||
This reference reflects implemented behavior in the present build.
|
||||
|
||||
As the system evolves, update this file whenever these change:
|
||||
- edge logic,
|
||||
- initiative process,
|
||||
- combat/ammo handling,
|
||||
- SAN/COOL automation,
|
||||
- equipment effect handling.
|
||||
@@ -0,0 +1,110 @@
|
||||
# SLA Industries BRP
|
||||
## Technical Installation and Transfer Guide
|
||||
|
||||
Date: 2026-02-23
|
||||
System ID: `sla-industries-brp`
|
||||
Companion Module: `sla-industries-compendium`
|
||||
System Version: `13.1.52`
|
||||
Foundry Compatibility: v13 (verified `13.350`)
|
||||
|
||||
## 1. Purpose
|
||||
This guide documents how to install, transfer, validate, and maintain the current SLA Industries BRP system build between Foundry environments.
|
||||
|
||||
This system is not standalone content-only. It relies on:
|
||||
- System code: `systems/sla-industries-brp`
|
||||
- Seed/content module: `modules/sla-industries-compendium`
|
||||
|
||||
## 2. What Has Been Created
|
||||
Current implementation includes:
|
||||
- SLA-focused BRP fork for Foundry v13.
|
||||
- SLA actor-sheet UI pass:
|
||||
- SLA wordmark styling.
|
||||
- tighter personal panel layout.
|
||||
- improved roll affordances for characteristics and skills.
|
||||
- portrait/initiative side layout updates.
|
||||
- fatigue removed from front-sheet play UI.
|
||||
- Escalation Initiative support:
|
||||
- DEX/INT initiative actions.
|
||||
- risk roll support and COOL-trigger workflow.
|
||||
- Advantage/Disadvantage edge system:
|
||||
- `Advantage`: roll `1D100`, then reroll only the tens die and keep the better result.
|
||||
- `Disadvantage`: roll `1D100`, then reroll only the tens die and keep the worse result.
|
||||
- integrated across standard checks and SAN/COOL prompts.
|
||||
- Enhanced chat output for checks:
|
||||
- explicit edge mode shown.
|
||||
- edge roll details (dice and kept value).
|
||||
- target breakdown (base/manual/SLA mods/final target).
|
||||
- BPN Toolkit:
|
||||
- journal tooling and random generators.
|
||||
- color tables, mission tables, rewards, solo procedures.
|
||||
- cyberpunk dossier visual styling.
|
||||
- Equipment system expansion:
|
||||
- full equipment catalogue journal.
|
||||
- one-click world seeding of drag/drop `gear` items.
|
||||
- item folder structure and duplicate-safe BRP ID flags.
|
||||
- SLA subsystem APIs exposed on `game.brp`:
|
||||
- `SLASeedImporter`, `SLACharacterGenerator`, `SLASkillPoints`
|
||||
- `SLAAmmoCatalog`, `SLAAmmoTracker`
|
||||
- `SLADrugSystem`, `SLAEbbSystem`, `SLAMentalSystem`
|
||||
- `SLADamageResolver`, `SLAEscalationInitiative`, `SLABPNToolkit`
|
||||
|
||||
## 3. Required Directories
|
||||
On target Foundry Data path:
|
||||
- `Data/systems/sla-industries-brp`
|
||||
- `Data/modules/sla-industries-compendium`
|
||||
|
||||
## 4. Transfer Procedure (Recommended)
|
||||
1. Stop Foundry on the target machine.
|
||||
2. Back up target `Data/systems/sla-industries-brp` and `Data/modules/sla-industries-compendium` if they already exist.
|
||||
3. Extract transfer bundle so it writes into target `Data/`.
|
||||
4. Start Foundry.
|
||||
5. In the world:
|
||||
- enable system `SLA Industries BRP (Draft)`.
|
||||
- enable module `sla-industries-compendium`.
|
||||
6. Open Journal sidebar:
|
||||
- run `Create BPN` / `BPN Toolkit` as needed to ensure BPN journals.
|
||||
7. Open Items sidebar:
|
||||
- run `Seed SLA Equipment` to populate drag/drop equipment items.
|
||||
|
||||
## 5. Post-Transfer Validation Checklist
|
||||
Run these checks in a test world:
|
||||
- System loads without startup errors.
|
||||
- Character sheet opens and renders SLA title/header correctly.
|
||||
- Skill roll dialog shows edge selector and tactical modifier.
|
||||
- Chat card shows:
|
||||
- edge label (Advantage/Normal/Disadvantage),
|
||||
- roll detail (kept die logic),
|
||||
- target breakdown line.
|
||||
- Initiative buttons appear near portrait (`DEX INIT`, `INT INIT`).
|
||||
- BPN tools visible in Journal sidebar.
|
||||
- Equipment seeding button visible in Items sidebar.
|
||||
- Seeded equipment can be drag/dropped to actor sheets.
|
||||
|
||||
## 6. Content Ownership and Scope Notes
|
||||
- System folder transfer includes rules/UI/code and templates.
|
||||
- Companion module transfer includes source seed JSON and module assets.
|
||||
- World-specific journals/items/actors/scenes are world data and are not part of system folder backup unless world folder is separately copied.
|
||||
|
||||
## 7. If You Also Need World Data
|
||||
For complete campaign migration, additionally copy:
|
||||
- `Data/worlds/<your-world-id>`
|
||||
|
||||
Without world copy, seeded journals/items can be recreated by toolkit actions, but your custom world-created records will not transfer.
|
||||
|
||||
## 8. Known Implementation Notes
|
||||
- Rules are SLA-focused BRP and still evolving.
|
||||
- Some features are narrative-first by design rather than strict state enforcement.
|
||||
- Legacy BRP internals are still present beneath SLA overlays in some flows.
|
||||
|
||||
## 9. Recovery / Rollback
|
||||
To roll back:
|
||||
1. Stop Foundry.
|
||||
2. Replace system and module directories with previous backups.
|
||||
3. Restart Foundry.
|
||||
|
||||
## 10. Suggested Versioning Practice
|
||||
For each major change pass:
|
||||
- create timestamped backup archive.
|
||||
- update `CURRENT_SYSTEM_STATUS.md`.
|
||||
- record changed files and intended behavior.
|
||||
- validate in a scratch world before campaign world use.
|
||||
@@ -0,0 +1,393 @@
|
||||
# Vehicles & Chases (BRP - SLA Industries)
|
||||
|
||||
Document Status: First Draft - Playable
|
||||
Scope: Fast, table-ready vehicle and chase rules integrated into the SLA BRP Edge framework.
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Goals
|
||||
|
||||
These rules are built to:
|
||||
|
||||
- plug into existing BRP roll-under checks,
|
||||
- use the same Advantage/Normal/Disadvantage edge flow,
|
||||
- avoid hex-by-hex bookkeeping,
|
||||
- keep chase scenes tense and cinematic.
|
||||
|
||||
If a scene slows down, simplify to one opposed chase test per round.
|
||||
|
||||
---
|
||||
|
||||
## 2. Vehicle Stat Block
|
||||
|
||||
Each vehicle uses this profile:
|
||||
|
||||
- **Type** - Car, Van, Grav-Bike, APC, Train, etc.
|
||||
- **SCL Access** - Typical minimum SCL for legal operation or issue.
|
||||
- **Cost** - Credits (`c`).
|
||||
- **Crew** - Driver + passengers.
|
||||
- **Speed Bands** - Crawl / Street / Highway / Pursuit.
|
||||
- **Handling** - Percentile base for control tests.
|
||||
- **Armour** - Damage reduction against incoming damage.
|
||||
- **Hull** - Structural HP before vehicle failure.
|
||||
- **Size** - Small / Medium / Large.
|
||||
- **Traits** - Offroad, Enclosed, Open, Airborne, Sluggish, etc.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vehicle Templates
|
||||
|
||||
### Downtown Compact Car
|
||||
|
||||
Type: Civilian Car
|
||||
SCL Access: 10+
|
||||
Cost: 800c
|
||||
Crew: 1 + 3 passengers
|
||||
Speed: Crawl / Street / Highway / -
|
||||
Handling: 55%
|
||||
Armour: 2
|
||||
Hull: 20
|
||||
Size: Medium
|
||||
Traits: Enclosed, Common.
|
||||
|
||||
Notes:
|
||||
- baseline Mort civilian profile,
|
||||
- weak under sustained high-pressure pursuit.
|
||||
|
||||
### SLA Interceptor (Grav Car)
|
||||
|
||||
Type: SLA Patrol Interceptor
|
||||
SCL Access: 7+
|
||||
Cost: 8000c
|
||||
Crew: 1 + 1 to 2 passengers
|
||||
Speed: Crawl / Street / Highway / Pursuit
|
||||
Handling: 70%
|
||||
Armour: 4
|
||||
Hull: 30
|
||||
Size: Medium
|
||||
Traits: Enclosed, High-Performance, Airborne (low grav).
|
||||
|
||||
Notes:
|
||||
- strong pursuit platform,
|
||||
- favors aggressive closure and lane control.
|
||||
|
||||
### Downtown Delivery Van
|
||||
|
||||
Type: Panel Van
|
||||
SCL Access: 10+
|
||||
Cost: 1200c
|
||||
Crew: 1 + 1 passenger, cargo bay
|
||||
Speed: Crawl / Street / Highway / -
|
||||
Handling: 45%
|
||||
Armour: 3 front / 2 side
|
||||
Hull: 28
|
||||
Size: Large
|
||||
Traits: Enclosed, Sluggish, High Centre of Gravity.
|
||||
|
||||
Notes:
|
||||
- excellent load utility,
|
||||
- poor sudden maneuver profile.
|
||||
|
||||
### Mort Sector Sled Grav-Bike
|
||||
|
||||
Type: Grav-Bike
|
||||
SCL Access: 8+
|
||||
Cost: 3500c
|
||||
Crew: 1 rider (+1 at GM discretion)
|
||||
Speed: Crawl / Street / Highway / Pursuit
|
||||
Handling: 65%
|
||||
Armour: 1
|
||||
Hull: 16
|
||||
Size: Small
|
||||
Traits: Open, Agile, Exposed Rider.
|
||||
|
||||
Notes:
|
||||
- strong threading and corner recovery,
|
||||
- rider vulnerability remains high.
|
||||
|
||||
---
|
||||
|
||||
## 4. Vehicle Tests
|
||||
|
||||
Driving uses an appropriate skill:
|
||||
|
||||
- `Drive (Ground)`
|
||||
- `Drive (Grav)`
|
||||
- `Pilot` (for relevant craft)
|
||||
|
||||
### 4.1 When To Roll
|
||||
|
||||
Do not roll routine travel. Roll only when:
|
||||
|
||||
- maneuver is risky,
|
||||
- speed pressure is high,
|
||||
- conditions are degraded,
|
||||
- active opposition exists,
|
||||
- failure would matter to scene outcome.
|
||||
|
||||
### 4.2 Edge Assignment
|
||||
|
||||
**Advantage**
|
||||
- clean visibility,
|
||||
- familiar route,
|
||||
- prepped maneuver,
|
||||
- superior vehicle for current terrain.
|
||||
|
||||
**Normal**
|
||||
- standard Mort traffic/weather pressure.
|
||||
|
||||
**Disadvantage**
|
||||
- heavy rain/smog/oil,
|
||||
- panic crowds,
|
||||
- damaged vehicle,
|
||||
- overloaded cargo,
|
||||
- severe route complexity.
|
||||
|
||||
### 4.3 Modifiers
|
||||
|
||||
Apply tactical modifiers as normal:
|
||||
|
||||
- minor factor: +/-10%
|
||||
- strong factor: +/-20%
|
||||
- severe factor: +/-30%
|
||||
|
||||
Example:
|
||||
- Interceptor Handling 70%
|
||||
- heavy rain + skyway debris: Disadvantage, -10%
|
||||
- final target: 60%
|
||||
|
||||
---
|
||||
|
||||
## 5. Speed Bands
|
||||
|
||||
Use qualitative speed:
|
||||
|
||||
- **Crawl** - parking/crowds/alley creep
|
||||
- **Street** - normal city flow
|
||||
- **Highway** - fast lanes, harder recovery
|
||||
- **Pursuit** - near-limit operation
|
||||
|
||||
### Band Changes
|
||||
|
||||
Increasing one band:
|
||||
- usually free in calm scenes,
|
||||
- under pressure may require a control test.
|
||||
|
||||
Failing that test:
|
||||
- delay, poor line, or minor consequence.
|
||||
|
||||
Fumble:
|
||||
- major control loss, obstacle impact, or crash check.
|
||||
|
||||
---
|
||||
|
||||
## 6. Chase Structure (Abstract Bands)
|
||||
|
||||
Track relative distance only:
|
||||
|
||||
- **Engaged**
|
||||
- **Close**
|
||||
- **Far**
|
||||
- **Lost**
|
||||
|
||||
No exact meter tracking required.
|
||||
|
||||
### 6.1 Chase Round
|
||||
|
||||
1. Set current band.
|
||||
2. Each side declares approach:
|
||||
- Aggressive
|
||||
- Neutral
|
||||
- Evasive
|
||||
3. Each driver makes chase test.
|
||||
4. Compare outcomes.
|
||||
5. Shift range band accordingly.
|
||||
|
||||
### 6.2 Comparison Priority
|
||||
|
||||
- Higher successful tier wins (`Critical > Special > Success`).
|
||||
- If both fail, band usually does not change.
|
||||
- If same tier tie, compare kept roll by table convention (for this subsystem, higher kept roll inside same tier wins tie).
|
||||
|
||||
### 6.3 Band Shift
|
||||
|
||||
- Aggressive winner: shift 1 toward **Engaged**.
|
||||
- Evasive winner: shift 1 toward **Lost**.
|
||||
- Neutral winner: usually hold band, or gain minor narrative edge.
|
||||
|
||||
Special/Critical may justify 2-step shift in extreme scenes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Obstacles and Environmental Pressure
|
||||
|
||||
Insert obstacle beats to make chases dynamic:
|
||||
|
||||
Examples:
|
||||
- collapsed scaffold,
|
||||
- checkpoint wall,
|
||||
- flood lane,
|
||||
- jack-knifed cargo truck,
|
||||
- panicked civilian wave,
|
||||
- maintenance drone swarm.
|
||||
|
||||
### Obstacle Procedure
|
||||
|
||||
1. Describe hazard.
|
||||
2. Call test (`Drive`, `Mobility`, `COOL`, etc.).
|
||||
3. Apply edge from context.
|
||||
4. Resolve:
|
||||
- Success: pass cleanly.
|
||||
- Failure: lose initiative/position or take minor damage.
|
||||
- Fumble: collision/spin-out sequence.
|
||||
|
||||
---
|
||||
|
||||
## 8. Collisions and Crashes
|
||||
|
||||
### 8.1 Severity Ladder
|
||||
|
||||
- Low (Crawl): `1d6`
|
||||
- Medium (Street): `2d6-3d6`
|
||||
- High (Highway): `4d6-6d6`
|
||||
- Extreme (Pursuit): `8d6+`
|
||||
|
||||
### 8.2 Damage Application
|
||||
|
||||
1. Roll collision damage.
|
||||
2. Apply vehicle armour reduction.
|
||||
3. Subtract from Hull.
|
||||
4. Apply occupant damage (usually 1/2 or 1/3 of post-armour collision damage).
|
||||
|
||||
On a driving fumble during collision:
|
||||
- double damage or roll twice, take higher.
|
||||
|
||||
---
|
||||
|
||||
## 9. Vehicle Condition States
|
||||
|
||||
- **New** - no penalties.
|
||||
- **Used** - normal.
|
||||
- **Worn** - -10% Handling/chase tests.
|
||||
- **Damaged** - -20% Handling; Pursuit unsafe.
|
||||
- **Wrecked** - immobile, cover only.
|
||||
|
||||
When Hull drops below half:
|
||||
- degrade one condition step.
|
||||
|
||||
At 0 Hull:
|
||||
- Wrecked.
|
||||
|
||||
Repair uses existing repair gear/rules and costs.
|
||||
|
||||
---
|
||||
|
||||
## 10. On-Foot Chases
|
||||
|
||||
Use same band model (`Engaged/Close/Far/Lost`) with alternate skills:
|
||||
|
||||
- `Mobility` / `Athletics`
|
||||
- `Stealth`
|
||||
- `Survival`
|
||||
- occasional `COOL` under panic
|
||||
|
||||
Approach declarations stay identical:
|
||||
- Aggressive / Neutral / Evasive.
|
||||
|
||||
---
|
||||
|
||||
## 11. Example: RED BPN Skyway Pursuit
|
||||
|
||||
Setup:
|
||||
- squad in SLA Interceptor,
|
||||
- quarry in delivery van,
|
||||
- heavy rain,
|
||||
- starting at **Close**.
|
||||
|
||||
Round 1:
|
||||
- Interceptor: Aggressive.
|
||||
- Van: Evasive.
|
||||
- Interceptor rolls success; van fails.
|
||||
- Band shifts **Close -> Engaged**.
|
||||
|
||||
Round 2 (Obstacle):
|
||||
- GM adds partial skyway collapse.
|
||||
- Both roll at Disadvantage.
|
||||
- Interceptor succeeds, van fumbles.
|
||||
- Van takes collision damage and condition worsens.
|
||||
- GM applies tactical reroute consequences and continues chase.
|
||||
|
||||
End conditions:
|
||||
- quarry `Lost`,
|
||||
- forced stop/surrender,
|
||||
- vehicle disabled,
|
||||
- or scene transitions to on-foot pursuit.
|
||||
|
||||
---
|
||||
|
||||
## 12. Foundry Integration Pattern
|
||||
|
||||
### 12.1 For Vehicle Items
|
||||
|
||||
Store on vehicle records/items:
|
||||
- type,
|
||||
- crew,
|
||||
- speed bands,
|
||||
- handling,
|
||||
- armour,
|
||||
- hull,
|
||||
- size,
|
||||
- traits,
|
||||
- current condition.
|
||||
|
||||
### 12.2 For Chase Tracking
|
||||
|
||||
Use a chase helper flow:
|
||||
- select current band,
|
||||
- select approaches,
|
||||
- set edge + modifiers,
|
||||
- roll both sides,
|
||||
- output winner + next band.
|
||||
|
||||
Keep this visible in chat or a reference panel so all players can follow progress.
|
||||
|
||||
---
|
||||
|
||||
## 13. Quick Reference Tables
|
||||
|
||||
### 13.1 Edge Defaults for Chases
|
||||
|
||||
| Situation | Edge |
|
||||
|---|---|
|
||||
| Clean weather, known route, superior platform | Advantage |
|
||||
| Normal city pressure | Normal |
|
||||
| Storm/smog/debris/critical traffic | Disadvantage |
|
||||
|
||||
### 13.2 Band Shift Intent
|
||||
|
||||
| Winner Approach | Typical Shift |
|
||||
|---|---|
|
||||
| Aggressive | toward Engaged |
|
||||
| Neutral | maintain / minor gain |
|
||||
| Evasive | toward Lost |
|
||||
|
||||
### 13.3 Crash Severity
|
||||
|
||||
| Speed | Typical Collision Damage |
|
||||
|---|---|
|
||||
| Crawl | 1d6 |
|
||||
| Street | 2d6-3d6 |
|
||||
| Highway | 4d6-6d6 |
|
||||
| Pursuit | 8d6+ |
|
||||
|
||||
---
|
||||
|
||||
## 14. Expansion Slots (Future)
|
||||
|
||||
Reserved for future passes:
|
||||
- mounted weapon fire in chase bands,
|
||||
- airframe altitude strata,
|
||||
- convoy multi-vehicle initiative,
|
||||
- pursuit heat / law-response escalation,
|
||||
- route-map generators by district.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M18 18v15.797L179.434 215.41l9.484-16.424L28.04 18H18zm77.705 0l96.95 174.512 10.493-18.176L116.295 18h-20.59zm91.154 0c4.326 19.018 15.27 67.152 28.36 125.326.557 2.482 1.074 4.807 1.63 7.278l13.275-22.993C219.105 78.687 209.9 38.14 205.32 18h-18.46zM247 18v80.383l9-15.588 7.795 13.5L265 98.383V18h-18zm59.78 0l-24.51 110.297 13.314 23.058L325.22 18h-18.44zm88.925 0l-86.853 156.336 10.494 18.176L416.296 18h-20.59zm88.254 0L323.083 198.986l9.482 16.424L494 33.797V18h-10.04zM18 98.25v22.5l150.79 113.094 9.067-15.7L18 98.25zm476 0L334.145 218.143l9.064 15.7L494 120.75v-22.5zm-238 20.547l-74.656 129.308c20.297-9.102 46.254-14.35 74.656-14.35 28.402 0 54.36 5.248 74.656 14.35L256 118.797zm-238 70.34v19.226l136.05 51.018 9.122-15.804L18 189.136zm476 0l-145.172 54.44 9.123 15.804L494 208.364v-19.226zM18 249.18v18.14l122.615 15.326 9.77-16.92L18 249.18zm476 0l-132.383 16.547 9.768 16.92L494 267.32v-18.14zm-238 2.576c-14.737 0-26.49 11.75-26.49 26.488 0 8.536 3.948 16.065 10.13 20.895a28.09 28.09 0 0 1-1.384-8.603 28.09 28.09 0 0 1 28.09-28.09 28.09 28.09 0 0 1 13.02 3.223c-4.446-8.315-13.194-13.914-23.366-13.914zm-38.18 3.736c-12.656 2.568-23.93 6.328-33.082 10.904-17.63 8.815-26.21 19.758-26.21 29.594 0 9.837 8.58 20.777 26.21 29.592 17.63 8.815 43.077 14.643 71.262 14.643 28.185 0 53.632-5.828 71.262-14.643 17.63-8.815 26.21-19.755 26.21-29.592 0-9.836-8.58-20.78-26.21-29.594-9.153-4.576-20.426-8.336-33.082-10.904 4 6.67 6.31 14.453 6.31 22.752 0 24.465-20.025 44.49-44.49 44.49-24.465 0-44.49-20.025-44.49-44.49 0-8.3 2.31-16.083 6.31-22.752zm-83.867 38.694L18 308.68v18.14l104.666-13.082 11.287-19.552zm244.094 0l11.287 19.552L494 326.82v-18.14l-115.953-14.494zm-233.758 18.1l-57.112 98.92h337.644l-57.11-98.92c-5.763 12.078-17.677 22.035-32.4 29.398-20.907 10.452-48.695 16.54-79.312 16.54-30.617 0-58.405-6.088-79.31-16.54-14.725-7.363-26.64-17.32-32.4-29.4zm255.84 20.15l14.167 24.537L494 386.863v-19.226l-93.87-35.2zm-288.26.002L18 367.637v19.225l79.703-29.888 14.166-24.538zm-31.645 54.808L18 426.136v21.227l43.05-26.906 19.175-33.21zm351.55 0l19.174 33.21L494 447.364v-21.226l-62.225-38.89zm-350.82 41.96L18 484.29V494h16.238l74.05-64.795H80.956zm42.158 0L67.573 494h23.71l55.537-64.795h-23.707zm41.832 0L127.92 494h20.732l37.026-64.795h-20.733zm42.752 0c-8.665 28.96-15.66 51.885-19.61 64.794h18.82c4.678-15.314 11.433-37.537 19.573-64.795h-18.783zm39.303 0V494h18v-64.795h-18zm39.984 0L305.496 494h18.723l-18.515-64.795h-18.72zm39.338 0L363.348 494h20.732l-37.025-64.795h-20.733zm38.856 0L420.718 494h23.708l-55.54-64.795H365.18zm38.533 0L477.763 494H494v-9.71l-62.955-55.085H403.71z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M318.7 21c4.9 17.27 12.7 32.67 22.5 45.22 17.4 22.31 40.8 35.48 66.3 35.48s48.9-13.17 66.3-35.48c9.8-12.55 17.6-27.95 22.5-45.22zm174.1 49.71c-1.5 2.26-3.1 4.45-4.8 6.58-20.2 25.81-48.7 42.41-80.5 42.41-31.8 0-60.3-16.6-80.5-42.41-.4-.53-.8-1.07-1.2-1.6-37.7 6.23-78.4 12.65-112.2 26.21-18.3 37.5-34 75.5-47.7 113.6 28.8 8.6 57.9 20.9 80.6 37.9l8.4-27.6 17.6 3.4c-8 88.4-8.4 175.9-5.8 263.2h226.1zM82.54 179.6c-.71 3.1-1.61 6.2-2.82 9.2-1.9 4.7-4.36 9.1-7.52 12.8 9.67 9.4 15.64 19.7 19.01 30.1 1.56 4.8 2.55 9.6 3.23 14.3 6.66-5.8 15.26-9.4 24.16-7.3-6.4-22-17.9-43.8-36.06-59.1zm65.76 49.8c-3.1 6.4-6 13-8.6 19.6 19.5-2.3 38.8-2.5 57.7-2.6-15.7-7-32.5-12.7-49.1-17zm-35.4 26.8c-1.8.1-4.3 1.1-7.4 4 2.7 8.3 7.6 18.5 13.5 27.6.2-.6.3-1.2.5-1.8 2.3-8.3 2.2-16.2.7-21.7-1.5-5.4-3.9-7.5-5.7-8-.4-.1-.8-.1-1.2-.1zm117 7.8c-31.1.9-61.3-.5-90.8 3.2.4 4.5.3 9.1-.3 13.9 42.6 1.2 77 7.3 105.5 16.9 1.3-6.5 2.6-12.9 3.8-19.4-5.3-5.1-11.4-10-18.2-14.6zM94.47 281.5c-1.68 7.4-1.47 14.5-.1 19.4 1.52 5.4 3.83 7.5 5.73 8 1.8.5 4.9-.1 8.9-3.9-5.6-7.3-10.6-15.4-14.53-23.5zM134.1 299c-1.8 4.2-3.8 8-6.2 11.4 29.3 21.8 66.3 37.5 100.2 48.9 4.8-14.3 8.8-28.9 12.3-43.7-28-9.6-62.1-15.8-106.3-16.6zm-55.73 10.7c-14.13 11.1-31.09 21.4-47.75 31.4 28.08 7.4 50.7-.1 67.68-14.2-1-.1-2-.3-3-.6-8.31-2.3-13.85-8.9-16.93-16.6zm38.73 15.2c-1.7 7.1-3.3 14.3-5 21.5 31.8 23.1 67.1 35 104.4 42.8 1.9-4.3 3.7-8.6 5.4-12.9-34.6-11.7-73-27.8-104.8-51.4zm2.2 47.9c-9.8 40.2-18.1 80.2-25.73 119.6h79.93l27-88.3c-28-6.6-55.4-16-81.2-31.3z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M210 25c-10.012 0-19.372 5.32-26.74 15.145C175.892 49.968 171 64.15 171 80c0 15.85 4.892 30.032 12.26 39.855C190.628 129.68 199.988 135 210 135c10.012 0 19.372-5.32 26.74-15.145C244.108 110.032 249 95.85 249 80c0-15.85-4.892-30.032-12.26-39.855C229.372 30.32 220.012 25 210 25zm219.746 17.512l-18.701 15.21-22.76-9.206-7.72 15.271 85.86 36.313 16.807-34.532-53.486-23.056zm-5.264 59.392l-54.697 116.153 17.367 7.976L441.69 109.18l-17.207-7.276zM153 122.518c-5.86.764-11.17 1.81-16 3.086V253h146V125.604c-4.83-1.276-10.14-2.322-16-3.086v59.044l-4.975 2.489c-34.146 17.073-69.904 17.073-104.05 0L153 181.563v-59.045zm-34 9.982a61.312 61.312 0 0 0-5.008 2.988c-13.003 8.669-19.647 20.236-23.262 34.696-5.902 23.608-2.042 54.157-.416 82.816H119V132.5zm182 0V253h28.686c1.626-28.659 5.486-59.208-.416-82.816-3.615-14.46-10.26-26.027-23.262-34.696A61.312 61.312 0 0 0 301 132.5zm-130 .846v36.793c26.365 11.776 51.635 11.776 78 0v-36.793C239.103 145.188 225.36 153 210 153s-29.103-7.812-39-19.654zm-16.951 137.058l-.002.002v20.344H65.109c15.996 38.806 51.26 65.427 88.942 74.28V398h58.558c-12.115 30.534-33.527 55.682-58.5 77.592h-25.435v18.72h284.344v-18.72h-27.44c-28.728-21.894-50.024-47.017-61.594-77.594h63.657v-29.156c19.75-6.995 39.5-19.541 59.25-36.719-19.806-17.518-39.235-27.25-59.25-31.937v-29.782H154.049z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M485.846 30l-172.967 74.424 64.283 20.32-129.627 65.186 83.637 19.414-96.996 62.219 219.133-69.7-95.29-28.326L471.192 112.8l-72.115-15.024L485.846 30zm-280.46 45.766c-28.066-.117-49.926 56.532-57.726 90.607-11.26 49.19-14.529 83.515-.828 133.059l-17.348 4.798c-15.463-55.917-8.245-94.75 2.301-142.341 10.547-47.592 14.52-70.403-4.459-74.182C85.244 79.328 82.04 178.17 79.57 222.604c-1.396 25.808.71 57.017 6.54 77.552l-16.901 6.196c-14.43-53.35-6.657-97.957-1.693-150.77 2.493-15.582-1.787-25.677-19.102-25.166-15.833.467-27.015 143.362-13.275 179.041 8.713 53.061 31.247 130.572 10.955 152.766L18 494h205.973l19.986-28.592c23.08-5.008 28.42-19.86 37.023-33.787 25.291-40.946 82.384-83.166 129.114-99.226 21.142-7.51-21.912-48.546-53.836-32.782-55.005 27.162-81.646 56.298-117.772 38.295-55.855-27.834-47.245-100.648-35.861-162.83 6.141-33.544 40.41-89.602 7.156-98.824a21.158 21.158 0 0 0-4.396-.488z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M45.906 23.625v9.344c0 102.68 10.49 205.087 41.125 288.155 30.637 83.068 82.364 147.415 163.75 169.78l2.22.626 2.22-.5C344.49 472 396.864 407.39 425.593 323.47c28.728-83.92 35.25-187.777 35.25-290.5v-9.345H45.906zM64.72 42.313H442.06c-.413 98.8-7.497 197.342-34.125 275.125-27.21 79.487-73.39 136.637-154.375 154.78-73.28-21.03-119.914-78.696-149-157.562-28.475-77.212-39.206-174.13-39.843-272.344zm18.81 18.75c.807 84.13 12.486 172.823 39.314 244.812 7.825 20.998 16.874 40.495 27.25 58.063l45.844-38.438-40.75-75.063-5.47-10.062 10.97-3.344s38.865-11.786 60.406-18.25l20.437-7.25-94.843-150.468H83.53zm160.25 0l45.782 91.5 132.407-43.157c.69-16.205 1.053-32.366 1.155-48.344H243.78zm177.157 68.343L298.031 169.47l24.908 49.78 25.875 48.72 53.28-1.845c10.245-42.834 16.03-89.664 18.844-136.72zM310.908 236.47l-30.75 10.374 67.25 145.187c17.813-23.2 31.737-51.6 42.624-83.686 2.578-7.595 4.966-15.39 7.19-23.344l-53.626 1.844-5.844.22-2.75-5.158-24.094-45.437zm-99.22 4.686c-13.133 3.972-27.346 8.262-34.625 10.47L216 323.28l3.625 6.72-5.844 4.906-53.56 44.906c24.67 35.91 55.887 61.828 94.655 73.125 30.34-6.91 55.36-21.496 76.03-42.062L211.69 241.155z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><g class="" transform="translate(0,0)" style=""><path d="M104 37.25V215.1c3.6 9.9 10 14.1 20.9 17 11.4 2.9 27.1 3.1 43.9 3.1 16.8 0 34.6-.1 51.4 3.6 9.5 2.1 18.7 5.7 26.8 11.2V55.43c-7.2-9.9-15.9-13.69-27.3-15.09-12.2-1.49-27.3.55-42.9 2.83-15.7 2.28-31.9 4.81-47.7 2.88-8.7-1.07-17.3-3.87-25.1-8.8zm304 0c-7.8 4.93-16.4 7.73-25.1 8.8-15.8 1.93-32-.6-47.7-2.88-15.6-2.28-30.7-4.32-42.9-2.83-11.4 1.4-20.1 5.19-27.3 15.09V250c8.1-5.5 17.3-9.1 26.8-11.2 16.8-3.7 34.6-3.6 51.4-3.6 16.8 0 32.5-.2 43.9-3.1 10.9-2.9 17.3-7.1 20.9-17V37.25zM130.8 80.03h89.4v18h-89.4v-18zm161 0h89.4v18h-89.4v-18zm-161 44.47h89.4v18h-89.4v-18zm161 0h89.4v18h-89.4v-18zm-161 46.8h89.4v18h-89.4v-18zm161 0h89.4v18h-89.4v-18zM96 249c-17.3 0-29.19 7.3-37.77 18.9C49.66 279.4 45 295.7 45 312s4.66 32.6 13.23 44.1C66.81 367.7 78.7 375 96 375c22 0 35.7-7.1 44.4-14 8.6-6.9 11.5-13 11.5-13l2.5-5h203.2l2.5 5s2.9 6.1 11.5 13c8.7 6.9 22.4 14 44.4 14 17.3 0 29.2-7.3 37.8-18.9 8.5-11.5 13.2-27.8 13.2-44.1s-4.7-32.6-13.2-44.1c-8.6-11.6-20.5-18.9-37.8-18.9-22 0-35.7 7.1-44.4 14-8.6 6.9-11.5 13-11.5 13l-2.5 5H154.4l-2.5-5s-2.9-6.1-11.5-13c-8.7-6.9-22.4-14-44.4-14zm4.6 21.9c.9 0 1.9 0 2.9.1 13.5 1.2 28.2 8.9 44.1 24h216.8c15.9-15.1 30.6-22.8 44.1-24 14.2-1.2 26.6 5.8 33.1 16.2 13.1 20.9 7 53.9-20.6 72.3l-10-15c20.4-13.6 22.3-36.6 15.4-47.7-3.5-5.6-8.1-8.6-16.4-7.8-8.2.7-20.6 6.3-35.6 21.4l-2.7 2.6H140.3l-2.7-2.6c-15-15.1-27.4-20.7-35.6-21.4-8.26-.8-12.9 2.2-16.37 7.8-6.92 11.1-4.99 34.1 15.37 47.7l-9.99 15c-27.65-18.4-33.72-51.4-20.64-72.3 5.72-9.1 15.9-15.6 27.92-16.3h2.31zM169 361v126h30V361h-30zm48 0v126h30V361h-30zm48 0v126h30V361h-30zm48 0v126h30V361h-30z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M254.1 18.63c-81.4 0-231.43 155.97-171.63 300.77 8 25.3 27.83 50.4 49.13 77.1 24.4 30.6 51.6 63.2 68.7 96.9h20.5c-18.1-39.8-48.5-75.9-74.6-108.6-27.4-34.3-48.73-65.2-48.73-87.9.1-9.1 2.23-18.1 5.53-26.3 23-61.4 114-119.7 148.5-135l3.6-2 3.9 1.3c60.9 20.9 129.3 66.7 154 135.7 4.1 11.7 5.9 18 5.6 27.3-.5 15.8-24.5 54.7-55 88.7-29.1 32.4-62.4 67.7-80 106.7h20.5c16.8-32.2 46.2-64 73.3-94.2 23.2-25.6 45.3-50 54.9-74.8 52.9-124-99.2-305.67-178.2-305.67zm.8 135.47c-38.7 21.5-85.1 52.2-113.7 88.2 9.7 83 59 146.1 118.3 146.1 59.2 0 108.3-62.7 118.2-145.3-28.9-42.1-78-72.9-122.8-89zm-58.3 83h2.4c13.1.1 26.1 2.7 39.1 7.4-16.8 40.6-59 42-78.1 0 12.2-4.8 24.4-7.2 36.6-7.4zm124.9 0c13-.1 26 2.3 39.1 7.4-19.2 42-61.3 40.6-78.2 0 13.1-4.7 26.1-7.3 39.1-7.4z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1017 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M196.38 16.29l12.018 214.677-69.382-213.75h-19.65l70.52 217.25c-17.16-18.22-34.434-44.74-52.243-80.246 18.864 62.058 35.573 139.067 40.552 192.04L19.38 62.393v38.277l144.89 258.98c-33.493-21.316-67.86-56.375-97.918-92.87 26.712 52.73 55.26 104.847 73.076 160.54L19.378 289.453v28.46l107.997 124.026C99 434.69 70.625 422.05 42.25 408.165c38.03 26.607 62.036 50.897 84.234 85.82H230.84l-6.785-91.082H197.77c0-44.845 2.87-108.728 40.767-115.86-6.993-8.433-11.533-20.27-11.533-33.523 0-23.93 14.228-43.758 32.45-46.127h.005c.303-.038.61-.056.923-.063.934-.02 1.895.063 2.83.063 19.957 0 36.205 20.602 36.205 46.128 0 12.928-4.304 24.595-10.996 32.99 41.4 6.42 40.496 71.424 40.496 116.394h-24.94l-6.003 91.082h90.96c19.418-30.77 60.864-56.727 96.524-75.234-38.585 10.67-75.927 17.602-109.66 21.02l117.97-86.97v-23.218l-125.78 92.728c24.4-49.363 55.902-88.075 90.164-122.648-40.56 27.323-73.25 37.7-107.027 43.785L493.77 158.7v-30.58L339.297 328.19c1.19-51.24 16.946-114.427 39.156-171.047-17.383 25.054-33.876 46.073-49.713 62.742l56.406-202.668h-19.398l-53.412 191.906 3.832-192.834h-119.79z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M121.844 18.22c-10.248 46.5-5.753 86.598 7.562 121 31.722 35.503 74.78 60.367 119.5 84.405-15.144-14.727-29.5-29.294-41.97-44.188h72.94c5.318-6.197 10.267-12.425 14.812-18.687H192.5c-4.946-7.035-9.397-14.18-13.25-21.5-2.036-3.867-3.926-7.78-5.656-11.75h140.594c2.73-6.087 5.032-12.27 6.875-18.688h-154.22c-3.453-12.16-5.326-25.127-5.218-39.437h164.25c-.176-5.948-.708-12.168-1.656-18.72H162.843c1.202-10.093 3.29-20.866 6.437-32.436h-47.436zm216.03 0c10.033 36.836 9.616 65.44 2.47 90.593h-.156c-3.215 13.34-8.158 25.723-14.375 37.53-15.708 29.833-40.195 56.197-67.094 82.532 12.75 6.806 25.567 13.607 38.25 20.625 26.73-26.167 52.8-54.185 71.03-88.813 20.463-38.87 29.83-85.945 17.375-142.468h-47.5zm-129 253.81c-20.338 21.212-39.452 44.344-53.936 71.69-21.326 40.26-31.11 89.958-19.438 149.467h45.625c-5.036-22.15-6.48-42.845-5.03-62 .458-7.468 1.38-14.606 2.75-21.468H179c1.278-6.435 2.896-12.663 4.813-18.69.024-.07.038-.145.062-.217 3.025-9.47 6.796-18.44 11.188-26.875 14.96-28.73 35.86-51.34 56.5-71.813-14.107-6.74-28.446-13.326-42.688-20.094zm47.595 22.47c14.437 14.55 27.88 29.196 39.186 44.563h-61.812c-4.798 5.982-9.337 12.195-13.53 18.687h87.75c1.445 2.45 2.832 4.94 4.155 7.438 4.388 8.287 8.147 16.85 11.155 25.843H203.5c-2.184 5.98-4.007 12.218-5.47 18.69h130.314c2.428 12.173 3.525 25.21 2.937 39.436H194.19c.137 6.05.58 12.28 1.375 18.688h134.062c-1.072 8.062-2.65 16.496-4.75 25.344h46.813c9.533-48.618 4.736-90.682-8.97-126.407-28.443-31.848-66.276-52.97-106.25-72.28z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M418.813 30.625c-21.178 26.27-49.712 50.982-84.125 70.844-36.778 21.225-75.064 33.62-110.313 38.06 2.048 6.063 4.316 12.15 6.813 18.25 16.01.277 29.366-.434 36.406-1.5l9.47-1.53 8.436-1.28.22 10.186c-.17 6.172-.535 12.41-1.095 18.72l56.625 8.843c.86-.095 1.713-.15 2.563-.157 11.188-.114 21.44 7.29 24.468 18.593.657 2.448.922 4.903.845 7.313 5.972-2.075 11.753-4.305 17.28-6.72l9.595-4.188 2.313 10.22c3.483 15.308 6.028 31.385 7.375 48.062C438.29 247.836 468.438 225.71 493 197.5c-3.22-36.73-16.154-78.04-39.125-117.813-.735-1.272-1.47-2.526-2.22-3.78l-27.56 71.374c5.154.762 10.123 3.158 14.092 7.126 9.81 9.807 9.813 25.69 0 35.5-9.812 9.81-25.722 9.807-35.53 0-8.86-8.858-9.69-22.68-2.532-32.5l38.938-100.844c-6.45-9.18-13.215-17.83-20.25-25.937zM51.842 118.72c-8.46 17.373-15.76 36.198-21.187 56.436-14.108 52.617-13.96 103.682-2.812 143.438 13.3-2.605 26.442-3.96 39.312-4.03 1.855-.012 3.688.02 5.53.06 20.857.48 40.98 4.332 59.97 11.5-1.08-11.156-1.656-22.574-1.656-34.218 0-27.8 3.135-54.377 9-78.937l2.47-10.407 9.655 4.562c29.467 13.98 66.194 23.424 106.28 25.22 5.136-20.05 8.19-39.78 9.408-58.75-35.198 4.83-75.387 2.766-116.407-8.22-38.363-10.272-72.314-26.78-99.562-46.656zm230.594 82.218c-1.535 10.452-3.615 21.03-6.218 31.687 15.873-.193 31.286-1.594 46-3.97-1.056-1.84-1.895-3.822-2.47-5.968-1.455-5.432-1-10.925.938-15.78l-38.25-5.97zM105 201.375l4.156 18.22-21.594 4.905c8.75 5.174 13.353 15.703 10.594 26-3.32 12.394-16.045 19.758-28.437 16.438-12.394-3.32-19.76-16.075-16.44-28.47.676-2.517 1.756-4.82 3.126-6.874l-21.062 4.78-4.125-18.218 73.78-16.78zm388.594 22.813c-25.53 25.46-55.306 45.445-86.906 60.5.05 2.397.093 4.8.093 7.218 0 9.188-.354 18.232-1.03 27.125 16.635 1.33 32.045-1.7 45.344-9.374 25.925-14.962 40.608-45.694 42.5-85.47zm-338.844 3c-4.03 19.993-6.33 41.31-6.406 63.593l.125-.342c30.568 10.174 62.622 17.572 95.25 21.375l7.5.875.718 7.5 5.687 60.125-18.625 1.75-2.53-26.75c-4.547 1.875-9.726 2.34-14.845.968-12.393-3.32-19.76-16.042-16.438-28.436.285-1.06.647-2.08 1.063-3.063-19.535-3.727-38.736-8.592-57.406-14.53 2.69 49.62 16.154 94.04 36.094 126.656 22.366 36.588 52.13 57.78 83.968 57.78 31.838.003 61.602-21.19 83.97-57.78 19.536-31.96 32.846-75.244 35.905-123.656-15.773 4.657-31.894 8.552-48.25 11.656 1.914 4.57 2.415 9.78 1.033 14.938-3.322 12.394-16.045 19.758-28.438 16.437-.732-.195-1.43-.427-2.125-.686l-2.5 26.47-18.594-1.752 5.688-60.125.72-7.5 7.498-.875c29.245-3.407 57.995-9.717 85.657-18.312v-1.594c0-21.573-2.27-42.23-6.064-61.75C351.132 242.653 313.092 250 272.312 250c-43.59 0-83.986-8.658-117.562-22.813zm-87.5 105.968c-10.87.102-21.995 1.22-33.375 3.313 12.695 31.62 33.117 53.07 59 60 16.9 4.523 34.896 2.536 52.813-5.25-4.382-13.89-7.874-28.606-10.344-43.97-21.115-9.623-43.934-14.32-68.094-14.094zm137.5 80.22h130.813c-40.082 44.594-92.623 42.844-130.813 0z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M263.844 40.344C234.1 213.202 145.594 248.03 145.594 369.22c0 60.804 60.106 105.5 118.25 105.5 59.45 0 115.937-41.803 115.937-99.533 0-116.332-85.2-162.312-115.936-334.843zm-58.28 217.094c-27.963 75.53-5.105 154.567 54.25 179.375 15.185 6.348 31.724 7.714 47.905 6.28-116.134 49.787-185.836-79.816-102.158-185.656z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 580 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><g class="" transform="translate(0,0)" style=""><path d="M256 32A224 224 0 0 0 32 256a224 224 0 0 0 224 224 224 224 0 0 0 223.672-217.45c-15.468 4.148-.306.22-16.467 4.51-9.1-2.965-28.42 10.264-29.197 10.264-1.756 0-14.65-49.66-14.65-49.66L381.08 208H368l-1.316 6.32 30.2 28.444-47.76 20.718-35.466-49.162-5.24 2.328 11.21 36.3 42.84 23.177-42.84 50.568 1.403 35.82-31.276 67.182-52.168-6.716-16-88.843-3.094-33.43-71.517-3.838-9.258-60.77 32.314-35.827 54.782-6.32 40.034 21.772 36.93-9.72c-1.112-18.374.05-12.54-1.112-18.374l-19.666-.7-1.053-10.184-5.97-2.107-2.81 12.64-9.48-.526-11.94-26.514-8.43 2.81 10.185 22.475-9.13 3.16-13.694-20.718-8.428-1.406-21.773 2.81-18.26 23.177-18.613 1.406-1.405-34.064 24.23-1.757 2.108-10.184-5.795-5.618-.174-3.69 16.182-7.94 11.56-2.944 19.665-6.848 6.32-7.375 3.512 7.375 22.474 2.81 8.78-9.13 5.97-24.582-6.673-2.81-11.238 16.857-8.43-9.834-11.586 14.75-14.4 2.458-.7-10.184 11.236-23.88 31.606-13.694 43.894-3.16 3.864 13.344s-4.916 13.344-5.97 13.695c-.138.045.428 1.754 1.464 4.565h31.195l14.047-11.59 29.484-9.138A224 224 0 0 0 256 32zM145.098 69.465l6.144 1.053s1.756 8.076.352 10.007c-1.405 1.932-10.885 7.903-10.885 7.903l-6.148-12.115 10.536-6.848zm41.086 11.588l8.252 19.49 3.337 16.68s-11.588 5.793-12.466 6.496c-.878.7-6.147-8.077-6.147-8.077l-1.93-17.91 2.81-15.1 6.144-1.58zm-12.116 22.474v16.155l-10.007 3.86-2.107-7.724 12.115-12.29zm189.104 238.618l-7.365 61.798-16.164 3.166s4.868-50.492 7.326-53.3l16.202-11.665z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M263.28 18.47c-1.164.028-2.346.124-3.5.124-32.842 0-59.592 33.925-59.592 75.937 0 21.274 7.08 40.466 18.093 54.282-68.132 10.567-82.852 105.422-66.655 191.563h23.563l-1.032-105.344 18.688-.186 1.25 126.687 8.75 132.876h46.875V318.812h18.686v175.594h44.313l11.5-154.03h.03l1.063-105.532 18.687.187-1.063 105.345h24.532c18.362-88.46-4.724-178.95-67.095-190.688 11.507-13.88 18.97-33.344 18.97-55.156 0-39.387-23.416-72.038-53.408-75.936-.5-.075-.987-.11-1.5-.125-.384-.012-.767-.01-1.156 0zm-4.717 137.686c11.48 0 20.78 10.683 20.78 23.875 0 8.73-4.072 16.365-10.156 20.532h38.907v18.688h-40.22v23.344l24.563 49.437-16.75 8.314-17.156-34.53-17.155 34.53-16.75-8.313 24.563-49.436V219.25h-40.22v-18.688h38.97c-6.084-4.167-10.157-11.802-10.157-20.53 0-13.192 9.303-23.876 20.782-23.876z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><g class="" transform="translate(1,0)" style=""><path d="M320.938 13.28c-16.646 34.584-38.466 60.157-63.094 60.157-24.522 0-47.035-25.275-63.656-59.593.366 39.358-9.71 90.884-30.938 105.125-21.228 14.24-49.64-12.002-78.844-32.126 17.455 34.04 42.095 67.5 29.78 92.28-12.21 24.576-59.172 35.96-92.874 35.626 29.338 19.29 78.842 45.803 78.844 74.188.002 28.384-49.504 53.71-78.844 73 33.702-.333 80.663 11.612 92.876 36.187 12.227 24.61-9.03 56.31-33.75 85.563 44.826-15.413 65.142-5.735 85.374 10.812h31.75c-42.74-35.413-72.062-107.828-72.062-191.563-.002-118.62 58.92-214.906 131.406-214.906 72.488 0 131.406 96.29 131.406 214.907 0 83.74-29.317 156.153-72.062 191.563h27.313c19.847-14.62 39.796-25.65 89.687-9.28-26.233-30.264-42.2-62.484-29.97-87.095 12.257-24.665 56.658-36.612 90.533-36.188-29.4-19.297-75.344-44.584-75.344-73 0-28.415 45.943-54.89 75.342-74.187-33.874.424-78.273-10.962-90.53-35.625-12.315-24.78 9.982-58.24 27.437-92.28-29.202 20.12-57.583 46.385-78.845 32.124-21.262-14.263-31.382-66.13-30.938-105.69zm-68.97 93.75c-19.56 2.543-37.343 25.564-37.343 55.407 0 16.447 5.67 30.986 14 41.032l10.156 12.218-15.593 2.937c-10.815 2.035-18.743 7.737-25.53 17.063-6.79 9.325-11.984 22.344-15.626 37.343-6.585 27.128-8.078 60.24-8.31 89.47h36.093l.656 8.656 9.124 122.563h76.187l8.095-122.5.563-8.72h34.375c-.026-29.592-.44-63.166-6.407-90.5-3.295-15.095-8.287-28.096-15.156-37.313-6.87-9.216-15.133-14.897-27.28-16.78l-15.94-2.47 10.064-12.593c7.97-9.996 13.375-24.36 13.375-40.406-.002-31.817-19.884-55.313-41.44-55.313-2.54 0-3.96-.103-4.03-.094h-.03z" fill="#000000" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M230.125 18.156V247h49.313V18.156h-49.313zM78.812 21.438l-16 136.906c48.707 30.112 97.637 47.843 148.625 53.094V33.125c-44.244-1.822-88.46-5.89-132.625-11.688zm349.438.28c-43.398 6.814-86.784 10.647-130.125 11.97v175c46.732-7.458 95.816-24.375 148.438-50.844L428.25 21.72zm-1.938 166.532c-44.474 19.847-87.06 32.836-128.187 38.97V247h37.031v143.188h-37.031v8.718c0 34.41-20.516 56.084-43.25 56.28-22.734.2-43.438-21.34-43.438-56.28v-8.72l-27.656.002h-9.343V247h37.001v-17.188c-43.774-4.164-86.14-16.857-127.687-38.062 5.04 92.69 3.66 185.37-5.063 278.063 117.402 32.047 234.788 31.002 352.188 0-6.853-93.858-9.223-187.706-4.563-281.563zm-233.187 77.438V371.5H316.47V265.687H193.124zm20.47 18.156H296v67.5H213.594v-67.5zm18.686 18.687v30.126h45.032V302.53h-45.03zm-2.155 87.658v8.718c0 28.23 13.32 37.692 24.594 37.594 11.27-.098 24.718-10.018 24.718-37.594v-8.72l-49.313.002z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M158.094 75.156c-20.642 13.654-46.023 22.503-69.438 26.03 6.792 48.935 26.14 84.133 45.97 97.845 10.523 7.28 20.357 9.012 30.405 5.564 8.93-3.064 18.63-10.97 27.75-25.594-26.933-30.227-31.606-71.05-34.686-103.844zM355 75.25c-3.08 32.79-7.787 73.58-34.72 103.78 9.117 14.61 18.857 22.5 27.783 25.564 10.047 3.448 19.85 1.715 30.375-5.563 19.8-13.693 39.12-48.822 45.937-97.655-24.495-3.17-47.802-11.35-69.375-26.125zm-98.5 12.97c-31.2 0-58.752 6.99-75.47 17.655.988 5.463 2.15 10.912 3.564 16.25 16.138-6.942 42.334-11.47 71.937-11.47 29.604 0 55.8 4.528 71.94 11.47 1.412-5.338 2.575-10.787 3.56-16.25C315.31 95.203 287.716 88.22 256.5 88.22zM75.406 132.374l-47.72 45.47c29.75 37.184 63.04 56.826 86.47 58.718 1.145.092 2.253.135 3.344.156 1.792-7.583 3.963-15.05 6.5-22.345-21.724-15.023-39.014-43.736-48.594-82zm362.25 0c-9.59 38.26-26.87 66.978-48.594 82-.03.022-.062.04-.093.063 2.54 7.276 4.728 14.72 6.53 22.28 1.11-.02 2.24-.06 3.406-.155 23.434-1.892 56.75-21.525 86.5-58.72l-47.75-45.468zm-245 12.47c4.54 9.7 10.45 18.604 18.25 26.155l5.125 4.97-3.436 6.218c-1.8 3.292-3.672 6.388-5.594 9.312 32.592 11.965 67.862 11.136 98.938-.188-1.88-2.868-3.707-5.902-5.47-9.125l-3.406-6.218 5.094-4.97c7.53-7.29 13.308-15.846 17.78-25.156-43.627 10.534-85.4 8.503-127.28-1zm125.5 61.81c-38.403 15.68-83.03 16.82-123.406.157-7.3 7.37-15.218 12.574-23.656 15.47-9.922 3.404-20.24 3.312-30.063.374-6.907 20.592-10.78 42.597-10.78 64.406 0 7.314.562 14.825 1.656 22.47l122.406-29.907 2.22-.563 2.218.563 122.75 29.97c.886-7.647 1.344-15.173 1.344-22.533 0-21.79-3.89-43.792-10.844-64.375-9.812 2.928-20.122 2.995-30.03-.406-8.5-2.915-16.472-8.172-23.814-15.624zm158.47 8.564c-25.404 24.168-52.068 38.018-76.22 39.968-.427.034-.855.036-1.28.062 1.214 8.075 1.99 16.228 2.28 24.406 22.806 3.78 54.723.098 90-14.344l-14.78-50.093zm-440.188.03l-14.75 50.063c35.268 14.433 67.17 18.12 89.968 14.343.29-8.18 1.076-16.33 2.28-24.406-.425-.026-.852-.028-1.28-.063-24.146-1.95-50.82-15.777-76.22-39.937zm220.093 83.063l-121.06 29.593c1.972 8.128 4.51 16.35 7.5 24.625 33.536-7.734 71.23-11.506 108.5-11.78 2.784-.02 5.564-.018 8.343 0 40.257.264 79.61 4.576 112.28 12.25 2.622-8.343 4.8-16.65 6.47-24.875L256.53 298.312zm-4.905 61.406c-35.09.24-70.5 3.556-101.625 10.405 3.586 8.164 7.618 16.326 12.03 24.438 28.365-2.455 59.055-3.677 89.47-3.782 2.763-.008 5.525-.007 8.28 0 33.542.107 66.446 1.56 95.22 4.158 4.04-8.015 7.635-16.103 10.813-24.188-32.65-7.392-73.185-11.312-114.188-11.03zm-.063 49.81c-26.842.09-53.864 1.036-79.125 2.94 5.517 8.88 11.473 17.652 17.813 26.217 4.832 1.102 13.818 2.332 25.344 3.032 13.2.8 29.062 1.137 44.812 1.06 15.75-.074 31.41-.565 44.22-1.405 10.923-.716 19.234-1.848 23.468-2.72 6.205-8.426 11.88-17.067 17.062-25.81-28.492-2.267-60.905-3.422-93.594-3.314z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M20.72 19.34v39.72L151.6 132.9l94.9-45.12L151 19.34H20.72zm196.98.1L313 76.78C226.5 118 135.1 161.4 57.53 198.3l161.57 86.1 59.6-39.2-39.4-34.9c82.3-40.6 168.3-83.5 241.8-119.93l-93.6-70.93H217.7zM361.9 170.5l-76.5 37.9 44.9 25.3c-54.2 35.6-111.8 73.6-160.6 105.7l325.2 154.8L307.5 347c57.6-32.3 117.5-65.9 168.8-94.6l-114.4-81.9zm13.2 160.1l-33.9 18.9 139.3 74.3c-35.1-31.1-70.3-62.2-105.4-93.2z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 662 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M61.85 19.51c-15.08-.07-30.16 2.37-45.2 7.64C77.61 52.92 136.1 109.7 193.1 176.8l60.3-40.1C192.4 67.49 127.2 19.84 61.85 19.51zM442 32.08L109.9 252.7C90 265.9 70.45 268.9 53.86 267c-12.28-1.4-22.98-5.3-31.77-9.6-4.18 11.3-3.73 21-.16 27.5 4.67 8 14.54 13.6 35.43 10.7l22.8-3.2-14.01 18.1c-27.23 35.3-43.29 105 7.58 167.4 10.57 12.7 22.97 18 36.27 18.9 13.1 1 27-3 38.2-9.9 11.2-6.8 19.3-16.3 22.2-25.1 2.9-9 1.7-16.6-7.6-25.6-14.4-13.9-29.1-29.4-37-47.8 23.3-15.2 42.8-29.4 54.1-46.8 5.9-9.2 9.3-19.8 8.8-30.9-.6-11.1-4.8-22.3-12.4-34.2 95.2-68 199.2-130 296.4-197.68zM309.6 207.9l-59.5 39.2c26.7 34.1 53.2 69 79.6 102.4-14.7 12.4-28.6 17.5-37.5 16.7l-1.6 18.6c19.7 1.7 41-9.7 61.3-30.4 21.9-22.4 44.7-28.1 65.2-24.7 20.5 3.5 38.9 16.8 49.6 34.2 10.7 17.5 13.7 38.3 4.7 58.1-9 19.9-30.8 39.7-73.1 52.7l5.4 17.8c45.8-14 72.9-36.8 84.7-62.7 11.8-25.9 7.7-53.8-5.8-75.6-13.4-21.9-36-38.5-62.4-42.9-13-2.2-26.9-1.2-40.6 3.4-22.1-36.7-45.5-73-70-106.8zm-148.1 80.2c5.7 9.3 8.1 17 8.5 23.6.3 7-1.5 13.2-5.8 19.8-7.7 11.9-23.4 24.2-44.1 38.2-.3-2.2-.5-4.4-.6-6.8-1.1-23.6 11-48.7 42-74.8zm223 65c-6.6 3-13.4 7.4-20.2 13.6 8.6 26.1 36.2 62.1 60.3 77.1 8.6-4.9 15.2-10.3 20.1-16-8.9-25.8-37.4-59.9-60.2-74.7z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><g class="" transform="translate(0,0)" style=""><path d="M254.47 53.094s-4.808 37.12-49.5 49.5c-44.695 12.38-129.282 0-129.282 0L61.343 115.78l8.187 9.157-1.093 2.876-51.843 137.312L16 266.72v1.717c0 18.897 8.253 34.243 20.344 44 12.09 9.758 27.563 14.31 42.937 14.313 15.376.003 30.878-4.556 42.97-14.313 12.092-9.756 20.344-25.094 20.344-44v-1.843l-.688-1.688L86.97 130.28c23.946-3.003 80.866-8.54 115.5 1.532 23.064 6.71 36.151 20.345 43.436 31.97L210.78 354.468l21.407 30.31c-17.75 7.75-32.593 24.84-37.562 51.345-56.076 6.195-95.47 20.74-95.47 37.688h311.876c0-16.947-39.392-31.493-95.467-37.688-4.91-26.6-19.57-44.112-37.188-51.906l21-29.75L264 162.28c7.457-11.275 20.388-24.045 42.47-30.468 34.955-10.167 92.615-4.42 116.155-1.437l-50.875 134.75-.625 1.594v1.717c0 18.897 8.253 34.243 20.344 44 12.09 9.758 27.593 14.31 42.967 14.313 15.375.003 30.877-4.556 42.97-14.313 12.09-9.756 20.343-25.094 20.343-44v-1.843l-.688-1.688L441 127.562l-.938-2.28 8.782-8.438-15.594-14.25s-84.556 12.38-129.25 0-49.53-49.5-49.53-49.5zM77.53 156.656l44.22 108.375H36.594L77.53 156.657zm355.158 0l44.218 108.375H391.72l40.967-108.374z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M103.432 17.844c-1.118.005-2.234.032-3.348.08-2.547.11-5.083.334-7.604.678-20.167 2.747-39.158 13.667-52.324 33.67-24.613 37.4 2.194 98.025 56.625 98.025.536 0 1.058-.012 1.583-.022v.704h60.565c-10.758 31.994-30.298 66.596-52.448 101.43-2.162 3.4-4.254 6.878-6.29 10.406l34.878 35.733-56.263 9.423c-32.728 85.966-27.42 182.074 48.277 182.074v-.002l9.31.066c23.83-.57 46.732-4.298 61.325-12.887 4.174-2.458 7.63-5.237 10.467-8.42h-32.446c-20.33 5.95-40.8-6.94-47.396-25.922-8.956-25.77 7.52-52.36 31.867-60.452 5.803-1.93 11.723-2.834 17.565-2.834v-.406h178.33c-.57-44.403 16.35-90.125 49.184-126 23.955-26.176 42.03-60.624 51.3-94.846l-41.225-24.932 38.272-6.906-43.37-25.807h-.005l.002-.002.002.002 52.127-8.85c-5.232-39.134-28.84-68.113-77.37-68.113C341.14 32.26 222.11 35.29 149.34 28.496c-14.888-6.763-30.547-10.723-45.908-10.652zm.464 18.703c13.137.043 27.407 3.804 41.247 10.63l.033-.07c4.667 4.735 8.542 9.737 11.68 14.985H82.92l10.574 14.78c10.608 14.83 19.803 31.99 21.09 42.024.643 5.017-.11 7.167-1.814 8.836-1.705 1.67-6.228 3.875-15.99 3.875-40.587 0-56.878-44.952-41.012-69.06C66.238 46.64 79.582 39.22 95.002 37.12c2.89-.395 5.863-.583 8.894-.573zM118.5 80.78h46.28c4.275 15.734 3.656 33.07-.544 51.51H131.52c1.9-5.027 2.268-10.574 1.6-15.77-1.527-11.913-7.405-24.065-14.62-35.74zm101.553 317.095c6.44 6.84 11.192 15.31 13.37 24.914 3.797 16.736 3.092 31.208-1.767 43.204-4.526 11.175-12.576 19.79-22.29 26h237.19c14.448 0 24.887-5.678 32.2-14.318 7.312-8.64 11.2-20.514 10.705-32.352-.186-4.473-.978-8.913-2.407-13.18l-69.91-8.205 42.017-20.528c-8.32-3.442-18.64-5.537-31.375-5.537H220.053zm-42.668.506c-1.152-.003-2.306.048-3.457.153-2.633.242-5.256.775-7.824 1.63-15.11 5.02-25.338 21.54-20.11 36.583 3.673 10.57 15.347 17.71 25.654 13.938l1.555-.57h43.354c.946-6.36.754-13.882-1.358-23.192-3.71-16.358-20.543-28.483-37.815-28.54z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 0h512v512H0z"/><path fill="#fff" d="M142.094 20.063l37.594 76.78c-9.028-2.783-18.278-4.158-27.47-4.187-7.775-.024-15.51.92-23 2.78-9.593 2.388-18.805 6.298-27.312 11.533L18.53 23.593v70.312l56.19 37.813c-12.143 16.172-19.905 36.81-20.657 60.78-.14 4.438-.026 8.76.312 12.97l-35.844-9.657v41.843l44.72 3.094c3.274 7.523 7.29 14.613 11.906 21.28 9.205 13.295 20.61 25.098 33 36.345L18.53 322.188V368.5l109.157-53.28c13.032 10.868 26.395 21.638 39.094 33L20.345 494.28H91.78l88.97-132.936c14.037 13.884 26.635 28.95 36.188 46.344l-23.22 86.593h41.595l4.593-65.936 1.938-5.03c9.503-24.743 24.99-45.3 42.937-63.94l35.626 134.907h47.22L296.218 348c9.033-8.604 18.47-16.856 27.936-25.03L492.78 491.593v-70.938l-158.374-106.53c1.105-.955 2.21-1.922 3.313-2.876 10.166-8.807 20.203-17.72 29.467-27.063l125.594 33.907v-42.22l-111-7.718c13.545-16.36 24.01-34.353 28.533-55.437l82.468-21.94v-47.843l-80.186 39.47c-1.48-25.603-11.335-46.322-25.938-61.25L487.75 20.062h-70.375l-55.906 83.062c-7.092-3.406-14.625-5.89-22.376-7.406-14.422-2.82-29.657-2.43-44.47 1.186l20.813-76.844h-41.843l-5.938 87.438c-12.74 7.1-24.463 16.97-34.187 29.688-5.627-8.07-11.943-14.987-18.72-20.813L189 20.062h-46.906zm10.125 91.124c27.033-.222 54.69 13.835 72.655 47.563l7.906 14.813 8.44-14.532c21.855-37.654 61.142-51.45 94.31-44.968 33.17 6.483 59.992 31.78 58.5 78.438-1.38 43.233-31.41 72.474-68.53 104.625-32.58 28.218-70.07 57.918-92.594 100.78-23.6-42.682-62.317-71.126-95.156-98.624-18.69-15.648-35.51-30.997-47.22-47.905-11.707-16.908-18.508-35.107-17.78-58.28 1.364-43.454 28.753-71.513 61-79.532 6.046-1.504 12.23-2.324 18.47-2.376zM314.97 135.5c-11.794 0-23.042 3.426-32.25 9.406 20.885-2.46 44.746 10.98 59.03 35.72 12.43 21.526 14.015 45.497 5.938 62.468 15.646-10.572 25.78-28.802 25.78-49.094 0-32.468-26.032-58.5-58.5-58.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M119.1 25v.1c-25 3.2-47.1 32-47.1 68.8 0 20.4 7.1 38.4 17.5 50.9L99.7 157 84 159.9c-13.7 2.6-23.8 9.9-32.2 21.5-8.5 11.5-14.9 27.5-19.4 45.8-8.2 33.6-9.9 74.7-10.1 110.5h44l11.9 158.4h96.3L185 337.7h41.9c0-36.2-.3-77.8-7.8-111.7-4-18.5-10.2-34.4-18.7-45.9-8.6-11.4-19.2-18.7-34.5-21l-16-2.5L160 144c10-12.5 16.7-30.2 16.7-50.1 0-39.2-24.8-68.8-52.4-68.8-2.9 0-4.7-.1-5.2-.1zM440 33c-17.2 0-31 13.77-31 31s13.8 31 31 31 31-13.77 31-31-13.8-31-31-31zM311 55v48H208v18h103v158h-55v18h55v110H208v18h103v32h80.8c-.5-2.9-.8-5.9-.8-9 0-3.1.3-6.1.8-9H329V297h62.8c-.5-2.9-.8-5.9-.8-9 0-3.1.3-6.1.8-9H329V73h62.8c-.5-2.92-.8-5.93-.8-9 0-3.07.3-6.08.8-9H311zm129 202c-17.2 0-31 13.8-31 31s13.8 31 31 31 31-13.8 31-31-13.8-31-31-31zm0 160c-17.2 0-31 13.8-31 31s13.8 31 31 31 31-13.8 31-31-13.8-31-31-31z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><path d="M0 0h512v512H0z" fill="#000" fill-opacity="1"></path><g class="" transform="translate(0,0)" style=""><path d="M255.422 18.346c-111.086 0-204.14 76.375-229.615 179.533-11.867 70.7 11.228 143.745 60.048 194.24-5.034 28.402-5.687 61.755-5.687 100.54h18.687c0-57.013 2.106-100.723 14.88-130.744 12.776-30.02 34.883-47.983 81.394-56.728l15.61-2.934-10.148-12.22c-16.364-19.7-27.22-48.142-27.22-79.84 0-30.586 9.785-58.117 25.093-77.66 15.308-19.54 35.707-30.99 57.99-30.99s42.683 11.45 57.99 30.99c15.308 19.543 25.094 47.074 25.094 77.66 0 31.072-9.61 59.05-25.186 78.55l-10.036 12.565 15.885 2.497c50.69 7.97 72.17 25.994 83.77 56.398 11.598 30.404 11.513 75.137 11.513 132.455h18.69c0-30.09.238-57.06-1.506-81.117 36.888-41.708 59.28-96.54 59.28-156.668 0-130.742-105.786-236.53-236.528-236.53zm8.2 43.45c30.12.1 60.5 7.25 88.24 21.995 99.02 52.642 132.557 179.59 80.525 276.79-1.816 3.392-3.722 6.703-5.698 9.95-1.455-5.895-3.186-11.558-5.256-16.985-.896-2.347-1.872-4.636-2.913-6.877 43.444-87.576 12.394-199.683-75.432-246.373-24.95-13.264-52.307-19.685-79.436-19.775-59.683-.2-118.266 30.23-147.71 85.798-25.907 48.89-22.56 108.01 5.224 152.838-4.82 4.473-9.11 9.337-12.906 14.582-34-50.89-38.835-119.552-8.834-176.17 32.91-62.107 97.928-95.992 164.195-95.77zm86.657 98.9c37.642 37.22 50.227 94.933 32.638 145.548-5.115-3.25-10.7-6.19-16.773-8.81 10.177-31.69 6.895-66.347-8.112-95-.702-14.7-3.382-28.753-7.754-41.74zM158.92 173.642c-2.762 11.597-4.24 23.864-4.24 36.55 0 7.46.533 14.768 1.543 21.875-1.932 21.073 3.05 42.43 14.21 60.077-6.35 1.94-12.302 4.153-17.867 6.638-19.113-32.745-21.045-74.92-2.818-110.215 2.726-5.28 5.8-10.255 9.172-14.925zm60.45 29.34c-15.794.205-19.24 4.585-24.546 8.15 12.483 11.99 36.9 10.832 47.748 0-4.992-4.043-10.37-8.317-23.203-8.15zm74.69.177c-15.794.205-19.24 4.586-24.546 8.15 12.483 11.992 36.9 10.833 47.748 0-4.992-4.042-10.37-8.317-23.203-8.15z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="height: 512px; width: 512px;"><g class="" transform="translate(0,0)" style=""><path d="M256 17.492l20.912 34.856L306 34.895 296.973 71h-81.946L206 34.896l29.088 17.452zm64.3 34.414C334.08 62.311 343 77 343 96v64.648c-10.638-7.27-22.444-13.917-34.984-18.63C311.239 132.817 313 122.663 313 112c0-8.018-1.005-15.744-1.489-19.372zm-128.6 0l8.789 40.722C200.005 96.256 199 103.982 199 112c0 10.663 1.76 20.817 4.984 30.018-12.54 4.713-24.346 11.36-34.984 18.63V96c0-19.001 8.92-33.689 22.7-44.094zM291.356 89c2.31 6.965 3.643 14.753 3.643 23 0 15.85-4.892 30.032-12.26 39.855C275.372 161.68 266.012 167 256 167c-10.012 0-19.372-5.32-26.74-15.145C221.892 142.032 217 127.85 217 112c0-8.247 1.334-16.035 3.643-23zm8.541 69.67c25.391 9.618 49.716 29.5 65.356 43.92-3.358 1.38-6.477 3.137-9.281 5.24-4.585 3.438-8.377 7.98-10.655 13.223l-23.134-5.783-4.368 17.46 6.9 1.725c2.53 16.035 3.235 31.57 3.249 43.781-5.706-6.514-12.54-12.787-21.897-14.996-3.203-.756-6.388-.989-9.529-.838-9.422.453-18.448 4.359-26.393 7.94A163.812 163.812 0 0 0 256 277.566a163.812 163.812 0 0 0-14.146-7.224c-10.593-4.775-23.11-10.126-35.922-7.102-9.358 2.21-16.19 8.482-21.897 14.996.014-12.212.72-27.746 3.248-43.78l6.9-1.726-4.367-17.46-23.134 5.783c-2.278-5.244-6.07-9.785-10.655-13.223-2.804-2.103-5.923-3.86-9.28-5.24 15.639-14.42 39.964-34.302 65.355-43.92a71.055 71.055 0 0 0 2.757 3.984c6.988 9.318 16.241 16.507 26.805 20.004L256 240l14.336-57.342c10.564-3.497 19.817-10.686 26.805-20.004a71.06 71.06 0 0 0 2.757-3.984zM384 217c7.013 0 13.194 2.204 17.227 5.229 3.974 2.98 5.704 6.29 5.753 9.625-.292.051-.395.103-.884.132-3.949.234-12.367-.986-22.096-.986s-18.147 1.22-22.096.986c-.49-.029-.592-.081-.884-.132.05-3.336 1.779-6.645 5.753-9.625C370.806 219.204 376.987 217 384 217zm-256 0c7.013 0 13.194 2.204 17.227 5.229 3.974 2.98 5.704 6.29 5.753 9.625-.292.051-.395.103-.884.132-3.949.234-12.367-.986-22.096-.986s-18.147 1.22-22.096.986c-.49-.029-.592-.081-.884-.132.05-3.336 1.779-6.645 5.753-9.625C114.806 219.204 120.987 217 128 217zm279 32v174h-46V249zm-256 0v174h-46V249zm149.23 31.6c.677 0 1.251.054 1.702.16 2.735.646 10.877 7.205 16.595 14.224 4.698 5.766 7.671 10.392 8.8 12.182l11.118 88.953c-3.57-4.158-7.477-8.167-11.738-11.615C320.277 379.3 312.839 375 304 375c-20.5 0-34.72 14.856-43.488 28.008a111.855 111.855 0 0 0-4.512 7.39 111.855 111.855 0 0 0-4.512-7.39C242.72 389.856 228.5 375 208 375c-8.839 0-16.276 4.3-22.707 9.504-4.26 3.448-8.169 7.457-11.738 11.615l11.119-88.953c1.128-1.79 4.101-6.416 8.799-12.182 5.718-7.019 13.86-13.578 16.595-14.224 3.603-.85 15.085 1.798 24.389 5.992 2.106.95 4.095 1.919 5.945 2.857L256 352l15.598-62.39c1.85-.939 3.84-1.909 5.945-2.858 6.978-3.146 15.182-5.423 20.367-6.006a20.831 20.831 0 0 1 2.32-.146zM304 393c2.475 0 6.694 1.7 11.385 5.496 4.69 3.796 9.634 9.323 13.949 14.969 2.518 3.294 4.72 6.502 6.717 9.535h-66.31c1.592-3.153 3.419-6.515 5.747-10.008C282.72 402.144 292.5 393 304 393zm-96 0c11.5 0 21.28 9.144 28.512 19.992 2.328 3.493 4.155 6.855 5.748 10.008h-66.31c1.995-3.033 4.198-6.24 6.716-9.535 4.315-5.646 9.259-11.173 13.95-14.969C201.305 394.7 205.525 393 208 393zm231 48v14H73v-14zm48 32v14H25v-14z" fill="#fff" fill-opacity="1"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="61" height="39" viewBox="378.95 68.58 1142.9 726.85"><g fill="#39b54a"><path d="M1518.94 422.93c-2.58-3.61-64.46-89.47-165.29-176.64-59.41-51.36-119.98-92.33-180.02-121.77-75.7-37.12-150.81-55.94-223.23-55.94S802.87 87.4 727.17 124.52c-60.04 29.44-120.6 70.41-180.02 121.77-100.83 87.17-162.71 173.03-165.29 176.64a15.605 15.605 0 0 0 0 18.15c2.58 3.61 64.46 89.47 165.29 176.64 59.41 51.36 119.98 92.33 180.02 121.77 75.7 37.12 150.81 55.94 223.23 55.94s147.53-18.82 223.23-55.94c60.04-29.44 120.6-70.41 180.02-121.77 100.83-87.17 162.71-173.03 165.29-176.64a15.605 15.605 0 0 0 0-18.15ZM950.4 644.89c-117.39 0-212.89-95.5-212.89-212.89s95.5-212.89 212.89-212.89 212.89 95.5 212.89 212.89-95.5 212.89-212.89 212.89Z"></path><path d="M950.4 337.76c-51.97 0-94.24 42.28-94.24 94.24s42.28 94.24 94.24 94.24 94.24-42.28 94.24-94.24-42.28-94.24-94.24-94.24Z"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 919 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="61" height="43" viewBox="378.95 68.58 1142.9 726.85"><g fill="#ed1c24"><path d="M1518.94 422.93c-2.58-3.61-64.46-89.47-165.29-176.64-59.41-51.36-119.98-92.33-180.02-121.77-75.7-37.12-150.81-55.94-223.23-55.94S802.87 87.4 727.17 124.52c-60.04 29.44-120.6 70.41-180.02 121.77-100.83 87.17-162.71 173.03-165.29 176.64a15.605 15.605 0 0 0 0 18.15c2.58 3.61 64.46 89.47 165.29 176.64 59.41 51.36 119.98 92.33 180.02 121.77 75.7 37.12 150.81 55.94 223.23 55.94s147.53-18.82 223.23-55.94c60.04-29.44 120.6-70.41 180.02-121.77 100.83-87.17 162.71-173.03 165.29-176.64a15.605 15.605 0 0 0 0-18.15ZM950.4 644.89c-117.39 0-212.89-95.5-212.89-212.89s95.5-212.89 212.89-212.89 212.89 95.5 212.89 212.89-95.5 212.89-212.89 212.89Z"/><path d="M950.4 337.76c-51.97 0-94.24 42.28-94.24 94.24s42.28 94.24 94.24 94.24 94.24-42.28 94.24-94.24-42.28-94.24-94.24-94.24Z"/></g><path fill="#ed1c24" d="m430.146 673.795 989.78-571.45 50.73 87.866-989.78 571.45z"/></svg>
|
||||
|
After Width: | Height: | Size: 992 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><defs><linearGradient id="a" x1="24" x2="24" y1="4.9" y2="28" gradientTransform="matrix(1 0 0 -1 0 50)" gradientUnits="userSpaceOnUse"><stop offset=".8" stop-color="#889097"/><stop offset=".9" stop-color="#838c93"/><stop offset=".9" stop-color="#758089"/><stop offset="1" stop-color="#64717c"/></linearGradient></defs><path fill="url(#a)" d="M26 20h-4v21c0 2 2 4 2 4s2-2 2-4V20Z"/><circle cx="24" cy="13" r="9" fill="#280e17"/></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><linearGradient id="a" x1="24" x2="24" y1="45.057" y2="22.031" gradientUnits="userSpaceOnUse"><stop offset=".791" stop-color="#889097"/><stop offset=".858" stop-color="#838c93"/><stop offset=".937" stop-color="#758089"/><stop offset="1" stop-color="#64717c"/></linearGradient><path fill="url(#a)" d="M26 20h-4v21c0 2 2 4 2 4s2-2 2-4V20z"/><linearGradient id="b" x1="19.969" x2="28.093" y1="5.61" y2="20.504" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44f5a"/><stop offset=".443" stop-color="#ee3d4a"/><stop offset="1" stop-color="#e52030"/></linearGradient><circle cx="24" cy="13" r="9" fill="url(#b)"/></svg>
|
||||
|
After Width: | Height: | Size: 691 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 160">
|
||||
<rect width="1200" height="160" fill="#0f1114"/>
|
||||
<text x="20" y="96" fill="#f2f4f6" font-size="74" font-family="Arial, Helvetica, sans-serif" letter-spacing="4">SLA INDUSTRIES</text>
|
||||
<text x="680" y="96" fill="#9ca4ab" font-size="38" font-family="Arial, Helvetica, sans-serif" letter-spacing="2">BRP DRAFT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="0.2" cy="0.2" r="1.1">
|
||||
<stop offset="0%" stop-color="#2c3238"/>
|
||||
<stop offset="100%" stop-color="#0d0f12"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1024" height="1024" rx="80" fill="url(#g)"/>
|
||||
<rect x="64" y="64" width="896" height="896" rx="56" fill="none" stroke="#9ea5ad" stroke-width="6"/>
|
||||
<text x="512" y="430" text-anchor="middle" fill="#f3f5f7" font-size="220" font-family="Arial, Helvetica, sans-serif" letter-spacing="12">SLA</text>
|
||||
<text x="512" y="560" text-anchor="middle" fill="#c0c7ce" font-size="56" font-family="Arial, Helvetica, sans-serif" letter-spacing="7">INDUSTRIES</text>
|
||||
<text x="512" y="690" text-anchor="middle" fill="#8f969e" font-size="54" font-family="Arial, Helvetica, sans-serif" letter-spacing="6">BRP DRAFT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 885 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 360">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0f1114"/>
|
||||
<stop offset="100%" stop-color="#1f2429"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="line" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stop-color="#8f969e"/>
|
||||
<stop offset="100%" stop-color="#d4d8dd"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="360" rx="20" fill="url(#bg)"/>
|
||||
<rect x="48" y="46" width="1104" height="268" rx="12" fill="none" stroke="url(#line)" stroke-width="2"/>
|
||||
<text x="72" y="156" fill="#f2f4f6" font-size="92" font-family="Arial, Helvetica, sans-serif" letter-spacing="6">SLA INDUSTRIES</text>
|
||||
<text x="75" y="228" fill="#b9c0c7" font-size="44" font-family="Arial, Helvetica, sans-serif" letter-spacing="4">BASIC ROLEPLAYING SYSTEM DRAFT</text>
|
||||
<text x="75" y="284" fill="#8f969e" font-size="28" font-family="Arial, Helvetica, sans-serif" letter-spacing="3">FOUNDRY VTT</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,61 @@
|
||||
# SLA BRP Trait Reference (Installed Traits + SLA 2e Effects)
|
||||
|
||||
## Scope
|
||||
This document lists every trait currently installed in your SLA BRP system and pairs each with the corresponding **SLA Industries 2nd Edition** game effect summary.
|
||||
|
||||
## Sources Used
|
||||
- Installed trait records: `/Users/timevans/FoundryVTT/Data/systems/sla-industries/packs/traits.db`
|
||||
- 2e rule text extraction: `/Users/timevans/Downloads/494461073-SLA-Industries-2nd-Edition-Final-2020.txt`
|
||||
- SLA BRP trait seed bridge: `/Users/timevans/FoundryVTT/Data/systems/sla-industries-brp/module/apps/sla-seed-importer.mjs`
|
||||
|
||||
## Point Value Convention
|
||||
- `Cost X` = spend X trait points per rank (advantage-style trait)
|
||||
- `Gain X` = gain X trait points per rank (disadvantage-style trait)
|
||||
|
||||
## Installed Trait Catalogue
|
||||
| Trait | Type | Ranks | Point Value | 2e Game Effect Summary |
|
||||
|---|---|---:|---|---|
|
||||
|
||||
| Addiction/Compulsion | disadvantage | 3 | Gain 1 | Specify a non-drug compulsion/addiction (for drugs use Drug Addict). For each rank, the character must feed the compulsion twice per day or take -1 to all dice on all rolls. Multiple instances allowed. |
|
||||
| Allergy | disadvantage | 3 | Gain 1 | Specify allergen. While exposed: -1 to all dice on CONC-related rolls per rank until medication or removal. Rank 1 uncommon allergen, rank 2 common, rank 3 common and potentially life-threatening (immediate medication may be required). Multiple instances allowed. |
|
||||
| Ambidextrous | advantage | 1 | Cost 2 | No off-hand penalties or increased difficulty for using the non-dominant hand. |
|
||||
| Anger | disadvantage | 1 | Gain 2 | Must prioritize attacking anyone who has attacked them until target is neutralized; refuses retreat while such a target remains. Strong roleplay pressure toward violent escalation. |
|
||||
| Anxiety | disadvantage | 3 | Gain 1 | -1 to dice on Fear Tests per rank. Character should be roleplayed as persistently on edge/worrying. Typically up to 2 ranks at creation, may rise to 3 later. |
|
||||
| Arrogant | disadvantage | 1 | Gain 1 | -1 to all dice on CHA-related rolls when dealing with anyone whose SCL is not higher than the character's. Strong roleplay pressure toward entitlement/snobbery. |
|
||||
| Attractive | advantage | 2 | Cost 1 | +1 Skill Die success per rank on CHA-based rolls where physical attractiveness matters (for example Seduction). Mutually exclusive with Unattractive. |
|
||||
| Chicken | disadvantage | 1 | Gain 3 | Species maximum COOL reduced by 2. Fear effects persist 2 hours instead of 1 after leaving fear stimulus. Strong roleplay pressure to avoid danger. Mutually exclusive with Exceedingly Cool. |
|
||||
| Contact | advantage | 4 | Cost 1 | A reliable contact/friend can provide intel/resources/help. Rank scales contact value from street-level source to high-ranking corporate/faction asset. Multiple instances allowed. |
|
||||
| Debt | disadvantage | 3 | Gain 1 | Character owes a substantial debt. Pays 10% of earnings per rank; at rank 3 may be pursued/hounded for additional repayments. Can coexist with Savings. |
|
||||
| Depression | disadvantage | 3 | Gain 1 | -1 to Success Die per rank on CHA-based skill rolls. Roleplay ongoing depressive effects. Usually up to 2 ranks at creation, may rise to 3 later. |
|
||||
| Drug Addict | disadvantage | 3 | Gain 1 | Character is addicted to one or more specified drugs. Use the 2e drug addiction rules for dependency/withdrawal handling. Multiple instances allowed. |
|
||||
| Enemy | disadvantage | 4 | Gain 1 | A person/organization has vendetta against the character. Rank scales from local threat to high-level dangerous adversary. GM should actively surface enemy pressure in play. Multiple instances allowed. |
|
||||
| Exceedingly Cool | advantage | 1 | Cost 2 | Species maximum COOL +1 (to max 6). Once per session: automatic Fear Test success without rolling. Mutually exclusive with Chicken. |
|
||||
| Good Hearing | advantage | 2 | Cost 1 | +1 Skill Die success per rank on hearing-based Detect rolls. Mutually exclusive with Poor Hearing. |
|
||||
| Good Housing | advantage | 2 | Cost 1 | Safe and private base, secure storage, better living conditions and lower day-to-day vulnerability. Mutually exclusive with Poor Housing. |
|
||||
| Good Vision | advantage | 2 | Cost 1 | +1 Skill Die success per rank on sight-based Detect rolls. Mutually exclusive with Poor Vision. |
|
||||
| Illness | disadvantage | 3 | Gain 1 | Specify illness; regular medication/treatment required. Rank indicates severity. Multiple illness traits allowed. 2e examples: Asthma (max DEX -1; max 1 Body RP; daily meds), Migraine (-2 CONC/DEX during attacks), Diabetes (-2 STR/DEX/CONC during episodes), Epilepsy (light-triggered incapacitation), Cancer (max STR/DEX -1 and progressive decline), Haemophilia (bleeding worsened: 2 HP/20 min). |
|
||||
| Natural Aptitude: Skill | advantage | 3 | Cost 1 | Exceptional capability in one non-combat/non-weapon skill. Once per session reroll a failed roll using Aptitude rank; Aptitude rank cannot exceed current skill rank. Only one Natural Aptitude: Skill may be taken. |
|
||||
| Natural Aptitude: Stat | advantage | 1 | Cost 3 | Increase species maximum of one stat by 1 (to max 6). Does not increase current stat rank. COOL and LUCK/FLUX cannot be selected. Only one Natural Aptitude: Stat may be taken. |
|
||||
| Pacifist | disadvantage | 1 | Gain 3 | Actively avoids violence and seeks peaceful outcomes. Never rolls initiative and is always treated as lowest initiative in combat. Violence should trigger guilt/internal conflict roleplay. |
|
||||
| Phobia | disadvantage | 3 | Gain 1 | Specify fear stimulus. Rank 1: +2 Fear Rating when stimulus present. Each additional rank adds +1 further Fear Rating. Multiple phobias allowed with GM approval. |
|
||||
| Poor Hearing | disadvantage | 2 | Gain 1 | -1 to Success Die per rank on hearing-based Detect rolls. Mutually exclusive with Good Hearing. |
|
||||
| Poor Housing | disadvantage | 2 | Gain 1 | Unsafe/insecure living situation with increased risk of theft, vandalism and personal vulnerability. Mutually exclusive with Good Housing. |
|
||||
| Poor Vision | disadvantage | 2 | Gain 1 | -1 to Success Die per rank on sight-based Detect rolls; rank 2 generally requires glasses/visual aid. Mutually exclusive with Good Vision. |
|
||||
| Psychosis | disadvantage | 3 | Gain 2 | Severe mental illness; one rank usually at creation, can escalate with trauma. If raised to rank 3, character becomes non-playable under GM control. 2e examples include Sociopathy (harder CHA social success), Delusions (fear state when delusion triggered), DID (alternate personality episodes), Paranoia (periodic social/concentration penalties), Schizophrenia (concentration disruption and GM-driven intrusive prompts). |
|
||||
| Savings | advantage | 3 | Cost 1 | Each rank represents 500c in saved funds. May attract unwanted attention. Can coexist with Debt (hidden reserves while owing money). |
|
||||
| Sterile | disadvantage | 1 | Gain 1 | Character is infertile. Social consequences can apply (notably in Shiver culture). Stormers are already sterile and may not take this trait; Shaktar may face cultural ostracism. |
|
||||
| Unattractive | disadvantage | 2 | Gain 1 | -1 to Success Die per rank on CHA-based rolls where appearance matters. Mutually exclusive with Attractive. |
|
||||
|
||||
|
||||
## Notes for SLA BRP Conversion
|
||||
- These entries preserve the **2e intent text** so you have a complete GM/player reference.
|
||||
- Several effects use 2e terms (for example Skill Die / Success Die / Fear Rating). If you want, these can be translated into explicit BRP-percent mechanics in a second pass.
|
||||
- Traits that are paired in 2e as one shared entry are split in your installed list:
|
||||
- `Hearing (good or poor)` -> `Good Hearing`, `Poor Hearing`
|
||||
- `Vision (good or poor)` -> `Good Vision`, `Poor Vision`
|
||||
- `Housing (good or poor)` -> `Good Housing`, `Poor Housing`
|
||||
- `Looks (attractive or unattractive)` -> `Attractive`, `Unattractive`
|
||||
|
||||
## Installed Count Check
|
||||
- Total installed traits in source pack: **29**
|
||||
- Total rows in this document: **29**
|
||||
@@ -0,0 +1,52 @@
|
||||
# SLA BRP Trait Coverage Audit
|
||||
|
||||
Generated from live trait engine definitions.
|
||||
|
||||
- Total mapped traits: 29
|
||||
- SLA2 seed rows: 29
|
||||
- Result: all seeded SLA2 traits are recognized by the BRP trait engine key map.
|
||||
|
||||
| Trait | Type | Max Rank | Automation | BRP Conversion Summary |
|
||||
|---|---:|---:|---|---|
|
||||
| Addiction / Compulsion | disadvantage | 3 | Full | Unmet condition applies -30% to rolls. |
|
||||
| Allergy | disadvantage | 3 | Partial | Exposure applies major CON/physical penalties; severe rank tracks hazard pressure. |
|
||||
| Ambidextrous | advantage | 1 | Full | Off-hand combat penalty suppression when condition is set. |
|
||||
| Anger | disadvantage | 1 | Partial | Attacked state enforces behavioral pressure and tactical focus penalty. |
|
||||
| Anxiety | disadvantage | 3 | Full | COOL and fear SAN penalties (-30%). |
|
||||
| Arrogant | disadvantage | 1 | Full | Social penalty vs equal/lower SCL contexts. |
|
||||
| Attractive | advantage | 2 | Full | Appearance-social bonus (+20%). |
|
||||
| Chicken | disadvantage | 1 | Partial | COOL cap reduction and optional first-combat COOL gate. |
|
||||
| Contact | advantage | 4 | Full | Once/session Bureaucracy leverage when invoked. |
|
||||
| Debt | disadvantage | 3 | Partial | Economic pressure tracked; rank 3 adds Bureaucracy audit strain. |
|
||||
| Depression | disadvantage | 3 | Full | Communication penalty (-30%); additional SAN-linked pressure. |
|
||||
| Drug Addict | disadvantage | 3 | Full | Withdrawal penalties to physical, COOL, and SAN rolls. |
|
||||
| Enemy | disadvantage | 4 | Narrative | Narrative antagonist trigger; no flat roll modifier by default. |
|
||||
| Exceedingly Cool | advantage | 1 | Full | COOL cap boost and once/session auto-pass hook. |
|
||||
| Good Hearing | advantage | 2 | Full | Listen bonus (+20%). |
|
||||
| Good Housing | advantage | 2 | Full | Downtime recovery bonus. |
|
||||
| Good Vision | advantage | 2 | Full | Spot Hidden bonus (+20%). |
|
||||
| Illness | disadvantage | 3 | Partial | Scoped penalties when active; severe rank retains long-term characteristic pressure. |
|
||||
| Natural Aptitude: Skill | advantage | 3 | Full | Once/session failed-skill reroll automation. |
|
||||
| Natural Aptitude: Stat | advantage | 1 | Full | Selected characteristic roll boost for max+1 equivalent. |
|
||||
| Pacifist | disadvantage | 1 | Full | Combat attacks require COOL pass and initiative priority is forced last. |
|
||||
| Phobia | disadvantage | 3 | Partial | Exposure applies COOL penalties; severe rank flags immediate SAN pressure. |
|
||||
| Poor Hearing | disadvantage | 2 | Full | Listen penalty (-20%). |
|
||||
| Poor Housing | disadvantage | 2 | Full | Downtime recovery penalty. |
|
||||
| Poor Vision | disadvantage | 2 | Full | Spot/ranged-vision penalties (-20% in scoped cases). |
|
||||
| Psychosis | disadvantage | 3 | Partial | Active scoped instability penalties with rank scaling. |
|
||||
| Savings | advantage | 3 | Narrative | Economic reserve trait tracked narratively (credits policy). |
|
||||
| Sterile | disadvantage | 1 | Narrative | Narrative-only biological consequence. |
|
||||
| Unattractive | disadvantage | 2 | Full | Appearance-social penalty (-20%). |
|
||||
|
||||
## Notes
|
||||
- `Full`: direct BRP modifier/behavior implemented in roll, COOL/SAN, or initiative pipeline.
|
||||
- `Partial`: core modifier path implemented, with remaining narrative or GM-triggered context flags.
|
||||
- `Narrative`: tracked for rules fidelity but intentionally not auto-modifying rolls by default.
|
||||
|
||||
## Actor Sheet
|
||||
Use the character `TRAITS` tab to:
|
||||
- drag/drop SLA traits to the actor
|
||||
- inspect rank/type/automation status
|
||||
- view BRP effect summary
|
||||
- run validator checks
|
||||
- edit runtime trait conditions (GM)
|
||||
@@ -0,0 +1,70 @@
|
||||
# SLA BRP Trait Engine (Automation Map)
|
||||
|
||||
## Runtime Modules
|
||||
- `module/traits/trait-definitions.mjs`
|
||||
- `module/traits/trait-engine.mjs`
|
||||
- `module/traits/trait-validator.mjs`
|
||||
- `module/traits/trait-hooks.mjs`
|
||||
- `module/traits/trait-ui.mjs`
|
||||
|
||||
## Integration Points
|
||||
- Core roll pipeline: `module/apps/check.mjs`
|
||||
- SAN/COOL checks: `module/apps/sla-mental-system.mjs`
|
||||
- Escalation initiative sorting: `module/combat/sla-escalation-initiative.mjs`
|
||||
|
||||
## Automation Scope
|
||||
The trait engine applies dynamic modifiers at roll time only. Base skill values are not mutated.
|
||||
|
||||
Hard cap: trait-derived roll modifier contribution is clamped to `±40%`.
|
||||
|
||||
## Trait State Flags
|
||||
Trait condition state is stored on actor flags:
|
||||
- `flags.sla-industries-brp.slaTraits.conditions`
|
||||
|
||||
Session-use state is stored on actor flags:
|
||||
- `flags.sla-industries-brp.slaTraits.session`
|
||||
|
||||
Edit state using:
|
||||
- `game.brp.SLATraitUI.openStateEditor(actor)`
|
||||
|
||||
Validate actor traits using:
|
||||
- `game.brp.SLATraitValidator.validateActor(actor)`
|
||||
|
||||
## Implemented Mechanical Conversions
|
||||
|
||||
### Disadvantages
|
||||
- Addiction/Compulsion: `unmet` => `-10%` per rank to percentile rolls.
|
||||
- Allergy: `exposed` => `-10/-20%` CON/physical penalties by rank.
|
||||
- Anger: `attacked` can impose fixation penalty on non-combat actions.
|
||||
- Anxiety: penalties to COOL and fear-tagged SAN checks.
|
||||
- Arrogant: social penalty vs equal/lower SCL contexts.
|
||||
- Chicken: COOL cap pressure (`-10%`) and optional first-combat COOL gate.
|
||||
- Debt: rank 3 audit pressure on Bureaucracy in monthly audit contexts.
|
||||
- Depression: Communication penalties (`-10%` per rank), extra if SAN<50 context supplied.
|
||||
- Drug Addict: `withdrawal` penalties to physical/COOL/SAN.
|
||||
- Illness: rank-scaled penalty in configured scope while active.
|
||||
- Pacifist: forces COOL gate before combat attack rolls; initiative always last.
|
||||
- Phobia: exposure-based COOL penalty; severe SAN pressure marker at rank 3.
|
||||
- Poor Hearing: Listen penalties by rank.
|
||||
- Poor Vision: Spot Hidden penalties and optional long-range ranged-combat penalties.
|
||||
- Psychosis: active rank-scaled penalties in configured scope.
|
||||
- Unattractive: appearance-based social penalties.
|
||||
|
||||
### Advantages
|
||||
- Ambidextrous: off-hand mitigation when `offHandUsed` condition is set.
|
||||
- Attractive: appearance-based social bonuses.
|
||||
- Contact: once/session Bureaucracy boost when invoked.
|
||||
- Exceedingly Cool: COOL cap pressure (`+10%`) and optional once/session auto-pass.
|
||||
- Good Hearing: Listen bonuses.
|
||||
- Good Vision: Spot Hidden bonuses.
|
||||
- Good Housing: downtime recovery bonuses.
|
||||
- Natural Aptitude: Skill: once/session failed-skill reroll support.
|
||||
- Natural Aptitude: Stat: +1 characteristic max equivalent (`+5%`) on selected stat rolls.
|
||||
- Savings: retained as non-roll economy trait.
|
||||
|
||||
## Session Ability Reset
|
||||
Session-use flags reset on world `ready` for GM via trait hooks.
|
||||
|
||||
## Notes
|
||||
- Narrative-only traits (e.g., Enemy, Sterile) are retained with minimal/no roll automation.
|
||||
- Trait conditions are intentionally explicit flags to avoid hidden side effects in normal play.
|
||||
@@ -0,0 +1,765 @@
|
||||
import { BRPSelectLists } from '../apps/select-lists.mjs'
|
||||
import { SkillsSelectDialog } from "../apps/skill-selection.mjs"
|
||||
import { BRPUtilities } from '../apps/utilities.mjs';
|
||||
import { SLAEbbSystem } from "../apps/sla-ebb-system.mjs";
|
||||
import { SLASeedImporter } from "../apps/sla-seed-importer.mjs";
|
||||
import { SLASkillPoints } from "../apps/sla-skill-points.mjs";
|
||||
|
||||
export class BRPactorItemDrop {
|
||||
|
||||
// Change default on Drop Item Create routine for requirements (single items and folder drop)-----------------------------------------------------------------
|
||||
static async _BRPonDropItemCreate(actor, itemData) {
|
||||
const newItemData = [];
|
||||
itemData = itemData instanceof Array ? itemData : [itemData];
|
||||
//TODO: Consider adding a bypass to just create the items with no checks
|
||||
// return actor.createEmbeddedDocuments("Item", itemData);
|
||||
for (let nItm of itemData) {
|
||||
let reqResult = 1;
|
||||
let errMsg = "";
|
||||
|
||||
//Automatically allow gear to be added
|
||||
if (nItm.type != 'gear') {
|
||||
|
||||
|
||||
//Don't allow the following items to be dropped directly on a character sheet
|
||||
if (['powerMod'].includes(nItm.type)) {
|
||||
reqResult = 0;
|
||||
errMsg = game.i18n.localize('BRP.' + nItm.type) + "(" + nItm.name + "): " + game.i18n.localize('BRP.noDirectDrop');
|
||||
}
|
||||
|
||||
|
||||
//When dropping armour check against using HPL and HPL compatabaility
|
||||
if (nItm.type === 'armour') {
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL') && nItm.system.HPL) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.armourNotHPL');
|
||||
}
|
||||
//If using hit locations select a Hit Location for the armour
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
let usage = await BRPactorItemDrop.hitLocationDialog(actor)
|
||||
if (usage) {
|
||||
nItm.system.hitlocID = usage.get('hitLoc')
|
||||
} else {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.armourNoHitLoc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//When dropping get the base score
|
||||
if (nItm.type === 'skill') {
|
||||
nItm.system.base = await this._calcBase(nItm, actor)
|
||||
}
|
||||
|
||||
//When dropping a weapon check to see if character has the skills and if not add them to the character sheet
|
||||
if (nItm.type === 'weapon') {
|
||||
//If there isn't a skill1 then give an error message
|
||||
if (nItm.system.skill1 === "" || nItm.system.skill1 === "none") {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.weaponNeedsSkill');
|
||||
} else {
|
||||
|
||||
nItm.system.equipStatus = 'carried'
|
||||
nItm.system.actEnc = nItm.system.enc
|
||||
nItm.system.hpCurr = nItm.system.hp
|
||||
let skill1Test = 0
|
||||
let skill2Test = 0
|
||||
let newSkill = "";
|
||||
//Test to see if skill1 or 2 exist on the character
|
||||
if ((await actor.items.filter(itm => itm.type === 'skill' && nItm.system.skill1 === itm.flags.brp.brpidFlag.id)).length > 0) { skill1Test = 1 }
|
||||
if (nItm.system.skill2 != 'none') {
|
||||
if ((await actor.items.filter(itm => itm.type === 'skill' && nItm.system.skill2 === itm.flags.brp.brpidFlag.id)).length > 0) { skill2Test = 1 }
|
||||
}
|
||||
|
||||
//Test to see if the skill1 or 2 are in the newItems due to be created
|
||||
if ((await newItemData.filter(itm => itm.type === 'skill' && nItm.system.skill1 === itm.flags.brp.brpidFlag.id)).length > 0) { skill1Test = 1 }
|
||||
if (nItm.system.skill2 != 'none') {
|
||||
if ((await actor.items.filter(itm => itm.type === 'skill' && nItm.system.skill2 === itm.flags.brp.brpidFlag.id)).length > 0) { skill2Test = 1 }
|
||||
}
|
||||
|
||||
|
||||
if (skill1Test === 0 && nItm.system.skill1 != 'none') {
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: nItm.system.skill1 }))[0]
|
||||
if (newSkill) {
|
||||
newSkill.system.base = await this._calcBase(newSkill, actor)
|
||||
newItemData.push(newSkill)
|
||||
}
|
||||
}
|
||||
if (skill2Test === 0 && nItm.system.skill2 != 'none') {
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: nItm.system.skill2 }))[0]
|
||||
if (newSkill) {
|
||||
newSkill.system.base = await this._calcBase(newSkill, actor)
|
||||
newItemData.push(newSkill)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Drop Culture, Personality & Professions - check one doesnt already exist and add relevant skills etc
|
||||
if (['personality', 'profession', 'culture'].includes(nItm.type)) {
|
||||
let check = await this._dropPersonality(nItm, actor)
|
||||
reqResult = check.reqResult;
|
||||
errMsg = check.errMsg;
|
||||
}
|
||||
|
||||
//If skill, don't let Group Skills be added and check to see if skill exists already unless a non-named specialism skill e.g. Craft (specify)
|
||||
if (nItm.type === 'skill') {
|
||||
if (nItm.system.group) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.stopGroupSkill');
|
||||
} else if (nItm.system.specialism && !nItm.system.chosen) {
|
||||
nItm = await this._getSpecialism(foundry.utils.duplicate(nItm), actor)
|
||||
}
|
||||
if (!nItm.system.specialism || (nItm.system.specialism && nItm.system.chosen)) {
|
||||
let dupItm = await actor.items.filter(itm => itm.type === 'skill' && itm.flags.brp.brpidFlag.id === nItm.flags.brp.brpidFlag.id)
|
||||
if (dupItm.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.dupItem');
|
||||
}
|
||||
}
|
||||
//If skill is to be added then check that the Skill Category is on the actor
|
||||
}
|
||||
|
||||
//If a failing then check that superpower has been selected
|
||||
if (nItm.type === 'failing' && actor.system.super === "") {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.needPower') + " (" + game.i18n.localize('BRP.super') + ")";
|
||||
}
|
||||
|
||||
//If a power check that the appropriate game setting is true
|
||||
if (nItm.type === 'power' && !game.settings.get('sla-industries-brp', [nItm.system.category])) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.nopower');
|
||||
}
|
||||
|
||||
//If an allegiance check that the appropriate game setting is true
|
||||
if (nItm.type === 'allegiance') {
|
||||
if (!game.settings.get('sla-industries-brp', 'useAlleg')) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.noAlleg');
|
||||
} else {
|
||||
let dupItm = await actor.items.filter(itm => itm.type === 'allegiance' && itm.flags.brp.brpidFlag.id === nItm.flags.brp.brpidFlag.id)
|
||||
if (dupItm.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.dupItem');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If a reputation check that the appropriate game setting is true
|
||||
if (nItm.type === 'reputation') {
|
||||
if (game.settings.get('sla-industries-brp', 'useReputation') === "0") {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.noRep');
|
||||
} else if (game.settings.get('sla-industries-brp', 'useReputation') === "1") {
|
||||
let repNum = await actor.items.filter(itm => itm.type === 'reputation')
|
||||
if (repNum.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.oneRep');
|
||||
}
|
||||
} else {
|
||||
let dupItm = await actor.items.filter(itm => itm.type === 'reputation' && itm.flags.brp.brpidFlag.id === nItm.flags.brp.brpidFlag.id)
|
||||
if (dupItm.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.dupItem');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If a Personality Trait check that the appropriate game setting is true
|
||||
if (nItm.type === 'persTrait') {
|
||||
const isSlaTrait = Boolean(
|
||||
nItm.flags?.[game.system.id]?.slaTrait
|
||||
|| nItm.flags?.brp?.slaTrait
|
||||
|| String(nItm.flags?.[game.system.id]?.brpidFlag?.id ?? nItm.flags?.brp?.brpidFlag?.id ?? "").includes("i.persTrait.sla2-")
|
||||
);
|
||||
if (!game.settings.get('sla-industries-brp', 'usePersTrait') && !isSlaTrait) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.noPersTrait');
|
||||
} else {
|
||||
let dupItm = await actor.items.filter(itm => itm.type === 'persTrait' && itm.flags.brp.brpidFlag.id === nItm.flags.brp.brpidFlag.id)
|
||||
if (dupItm.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.dupItem');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If a passion check that the appropriate game setting is true
|
||||
if (nItm.type === 'passion') {
|
||||
if (!game.settings.get('sla-industries-brp', 'usePassion')) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.noPassion');
|
||||
} else {
|
||||
let dupItm = await actor.items.filter(itm => itm.type === 'passion' && itm.flags.brp.brpidFlag.id === nItm.flags.brp.brpidFlag.id)
|
||||
if (dupItm.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.dupItem');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//If a magic spell, mutation, psychic ability, sorcery spell or super-power check that the appropriate power is present
|
||||
if (nItm.type === 'magic' || nItm.type === 'mutation' || nItm.type === 'psychic' || nItm.type === 'sorcery' || nItm.type === 'super') {
|
||||
if (actor.system[nItm.type] === "") {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.needPower') + " (" + game.i18n.localize('BRP.' + nItm.type) + ")";
|
||||
}
|
||||
}
|
||||
if (nItm.type === 'psychic' && !SLAEbbSystem.isEbbSpecies(actor)) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.ebbSpeciesOnly');
|
||||
}
|
||||
|
||||
//If a power,magic,mutation,psychic,sorcery,superpower etc and not failed a previous test, check to see if the item already exists on the character sheet
|
||||
if ((['power', 'magic', 'mutation', 'psychic', 'sorcery', 'super', 'hit-location', 'skillcat'].includes(nItm.type)) && reqResult === 1) {
|
||||
let dupItm = await actor.items.filter(itm => itm.type === nItm.type && itm.flags.brp.brpidFlag.id === nItm.flags.brp.brpidFlag.id)
|
||||
if (dupItm.length > 0) {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + "(" + nItm.flags.brp.brpidFlag.id + "): " + game.i18n.localize('BRP.dupItem');
|
||||
}
|
||||
}
|
||||
|
||||
//If a hit-location and not using HPL then only allow general hit location
|
||||
if (nItm.type === 'hit-location' && !game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
if (nItm.system.locType != 'general') {
|
||||
reqResult = 0;
|
||||
errMsg = nItm.name + " : " + game.i18n.localize('BRP.noHPL');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
//Check to see if we can drop the Item
|
||||
if (reqResult != 1) {
|
||||
ui.notifications.warn(errMsg);
|
||||
} else {
|
||||
newItemData.push(nItm);
|
||||
}
|
||||
}
|
||||
return (newItemData);
|
||||
}
|
||||
|
||||
//Calculate Base Skill on Dropping the item on actor
|
||||
static async _calcBase(itm, actor) {
|
||||
//Check the skill Category exists
|
||||
await this._checkSkillCat(itm, actor)
|
||||
if (itm.system.variable) {
|
||||
let stat1 = itm.system.baseFormula[1].stat
|
||||
let stat2 = itm.system.baseFormula[2].stat
|
||||
let opt1 = 0
|
||||
let opt2 = 0
|
||||
let newScore = 0
|
||||
if (stat1 != 'fixed') {
|
||||
if (stat1 != 'edu' || game.settings.get('sla-industries-brp', 'useEDU')) {
|
||||
opt1 = Math.ceil((actor.system.stats[stat1].base + actor.system.stats[stat1].redist + actor.system.stats[stat1].culture) * itm.system.baseFormula[1].value)
|
||||
}
|
||||
}
|
||||
if (stat2 != 'fixed') {
|
||||
if (stat2 != 'edu' || game.settings.get('sla-industries-brp', 'useEDU')) {
|
||||
opt2 = Math.ceil((actor.system.stats[stat2].base + actor.system.stats[stat2].redist + actor.system.stats[stat2].culture) * itm.system.baseFormula[2].value)
|
||||
}
|
||||
}
|
||||
|
||||
if (itm.system.baseFormula.Func === 'and') {
|
||||
newScore = opt1 + opt2
|
||||
} else {
|
||||
newScore = Math.max(opt1, opt2)
|
||||
}
|
||||
return newScore
|
||||
}
|
||||
return itm.system.base
|
||||
}
|
||||
|
||||
static async hitLocationDialog(actor) {
|
||||
let hitLocOptions = await BRPSelectLists.getHitLocOptions(actor)
|
||||
let label = game.i18n.localize('BRP.chooseHitLoc')
|
||||
let data = {
|
||||
hitLocOptions,
|
||||
label,
|
||||
}
|
||||
const html = await foundry.applications.handlebars.renderTemplate('systems/sla-industries-brp/templates/dialog/hitLocChoice.html', data)
|
||||
return new Promise(resolve => {
|
||||
let formData = null
|
||||
const dlg = new Dialog({
|
||||
title: "",
|
||||
content: html,
|
||||
buttons: {
|
||||
roll: {
|
||||
label: game.i18n.localize("BRP.proceed"),
|
||||
callback: html => {
|
||||
formData = new FormData(html[0].querySelector('#hitLocChoice-form'))
|
||||
return resolve(formData)
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'roll',
|
||||
close: () => { }
|
||||
})
|
||||
dlg.render(true)
|
||||
})
|
||||
}
|
||||
|
||||
//Make skill selections etc when dropping a personality, culture or profession
|
||||
static async _dropPersonality(itm, actor) {
|
||||
const addItems = []
|
||||
const updateItems = []
|
||||
const skillList = []
|
||||
const powerList = []
|
||||
let newSkill = {}
|
||||
if (await actor.items.filter(nitm => nitm.type === itm.type).length > 0) {
|
||||
return ({ 'reqResult': 0, 'errMsg': itm.name + " : " + game.i18n.format('BRP.stopPersonality', { type: game.i18n.localize('BRP.' + itm.type) }) })
|
||||
}
|
||||
|
||||
//If Profession then check for powers
|
||||
if (itm.type === 'profession') {
|
||||
for (let nPwr of (itm.system.powers ?? [])) {
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: nPwr.brpid }))[0]
|
||||
if (!newSkill) continue
|
||||
if (!game.settings.get('sla-industries-brp', [newSkill.system.category])) {
|
||||
ui.notifications.warn(newSkill.name + " : " + game.i18n.localize('BRP.nopower'))
|
||||
continue
|
||||
}
|
||||
if (newSkill) {
|
||||
if (await actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === newSkill.flags.brp.brpidFlag.id)) {
|
||||
continue
|
||||
} else {
|
||||
powerList.push(foundry.utils.duplicate(newSkill))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Go through each Optional Skill Group and make selections
|
||||
for (let nGrp of (itm.system.groups ?? [])) {
|
||||
const selected = await this._selectSkillGroup(nGrp)
|
||||
if (selected.length < 1 || !selected) { continue }
|
||||
for (let nSkill of selected) {
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: nSkill.id }))[0]
|
||||
//If a group skill then pass to the selection
|
||||
if (newSkill.system.group) {
|
||||
const selected = await this._selectGroupSkill(newSkill, actor, 1)
|
||||
if (selected.length < 1 || !selected) { continue }
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: selected[0].id }))[0]
|
||||
if (newSkill) {
|
||||
let placeholder = foundry.utils.duplicate(newSkill)
|
||||
if (itm.type === 'culture') {
|
||||
placeholder.system.culture = nSkill.bonus
|
||||
}
|
||||
skillList.push(placeholder)
|
||||
}
|
||||
} else {
|
||||
if (newSkill) {
|
||||
let placeholder = foundry.utils.duplicate(newSkill)
|
||||
if (itm.type === 'culture') {
|
||||
placeholder.system.culture = nSkill.bonus
|
||||
}
|
||||
skillList.push(placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const primarySkills = Array.isArray(itm.system.skills) ? [...itm.system.skills] : []
|
||||
if (itm.type === 'culture') {
|
||||
const speciesRows = await this._speciesFlagSkillRows(itm)
|
||||
for (const row of speciesRows) {
|
||||
const existing = primarySkills.find((entry) => entry.brpid === row.brpid)
|
||||
if (existing) {
|
||||
existing.bonus = Number(existing.bonus ?? 0) + Number(row.bonus ?? 0)
|
||||
} else {
|
||||
primarySkills.push(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Get each skill etc on the main list
|
||||
for (let nItm of primarySkills) {
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: nItm.brpid }))[0]
|
||||
//If you can't find the skill then skip it
|
||||
if (!newSkill) continue
|
||||
//If a group skill then pass to the selection
|
||||
if (newSkill.system.group) {
|
||||
const selected = await this._selectGroupSkill(newSkill, actor, 1)
|
||||
if (selected.length < 1 || !selected) { continue }
|
||||
newSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: selected[0].id }))[0]
|
||||
if (newSkill) {
|
||||
let placeholder = foundry.utils.duplicate(newSkill)
|
||||
if (itm.type === 'culture') {
|
||||
placeholder.system.culture = nItm.bonus
|
||||
}
|
||||
skillList.push(placeholder)
|
||||
}
|
||||
} else {
|
||||
if (newSkill) {
|
||||
let placeholder = foundry.utils.duplicate(newSkill)
|
||||
if (itm.type === 'culture') {
|
||||
placeholder.system.culture = nItm.bonus
|
||||
}
|
||||
skillList.push(placeholder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//This is the list of selected skills
|
||||
for (let nItm of skillList) {
|
||||
//If a specialism skill and not specified then get the name etc
|
||||
if (nItm.system.specialism && !nItm.system.chosen) {
|
||||
nItm = await this._getSpecialism(nItm, actor)
|
||||
}
|
||||
|
||||
//If personality then set Personality score to 20 and flag personality skills
|
||||
if (itm.type === 'personality') {
|
||||
nItm.system.personality = 20
|
||||
nItm.system.prsnlty = true
|
||||
}
|
||||
|
||||
//If profession then flag profession skills
|
||||
if (itm.type === 'profession') {
|
||||
nItm.system.occupation = true
|
||||
}
|
||||
|
||||
//If culture then flag profession skills
|
||||
if (itm.type === 'culture') {
|
||||
nItm.system.cultural = true
|
||||
}
|
||||
|
||||
//If existing skill on actor then push to updateItems otherwise calculate base and push to addItems
|
||||
const newItemBrpid = nItm.flags?.[game.system.id]?.brpidFlag?.id ?? nItm.flags?.brp?.brpidFlag?.id
|
||||
let actItem = (await actor.items
|
||||
.filter(dItm => (dItm.flags?.[game.system.id]?.brpidFlag?.id ?? dItm.flags?.brp?.brpidFlag?.id))
|
||||
.filter(cItm => (cItm.flags?.[game.system.id]?.brpidFlag?.id ?? cItm.flags?.brp?.brpidFlag?.id) === newItemBrpid))[0]
|
||||
if (actItem) {
|
||||
if (itm.type === 'personality') {
|
||||
updateItems.push({ _id: actItem._id, 'system.personality': nItm.system.personality, 'system.prsnlty': nItm.system.prsnlty })
|
||||
} else if (itm.type === 'profession') {
|
||||
updateItems.push({ _id: actItem._id, 'system.occupation': nItm.system.occupation })
|
||||
} else if (itm.type === 'culture') {
|
||||
updateItems.push({ _id: actItem._id, 'system.culture': nItm.system.culture, 'system.cultural': nItm.system.cultural })
|
||||
}
|
||||
} else {
|
||||
nItm.system.base = await this._calcBase(nItm, actor)
|
||||
addItems.push(nItm)
|
||||
}
|
||||
}
|
||||
|
||||
await Item.createDocuments(addItems, { parent: actor })
|
||||
await Item.createDocuments(powerList, { parent: actor })
|
||||
await Item.updateDocuments(updateItems, { parent: actor })
|
||||
|
||||
|
||||
|
||||
//If a culture add the dice and bonuses
|
||||
if (itm.type === 'culture') {
|
||||
let changes = {}
|
||||
for (let [key, stat] of Object.entries(itm.system.stats)) {
|
||||
if (stat.formula != "") {
|
||||
changes = Object.assign(changes, { [`system.stats.${key}.formula`]: stat.formula })
|
||||
}
|
||||
changes = Object.assign(changes, { [`system.stats.${key}.culture`]: Number(stat.mod) ?? 0 })
|
||||
}
|
||||
//Now add the move score
|
||||
changes = Object.assign(changes, {
|
||||
'system.move': Number(itm.system.move),
|
||||
'system.culture': itm.name
|
||||
})
|
||||
await actor.update(changes)
|
||||
await this._setSpeciesChoiceFlags(itm, actor)
|
||||
if (game.brp?.BRPActor?.ensureSLASkillRoster) {
|
||||
await game.brp.BRPActor.ensureSLASkillRoster(actor, {
|
||||
includeEbb: SLAEbbSystem.isEbbSpecies(actor),
|
||||
pruneEbbForNonEligible: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//If a profession then select wealth level
|
||||
if (itm.type === 'profession') {
|
||||
let wealthOptions = await BRPSelectLists.getWealthOptions(itm.system.minWealth, itm.system.maxWealth)
|
||||
let selected = await this.selectFromRadio(wealthOptions, "Select Wealth Level")
|
||||
await actor.update({ 'system.wealth': selected })
|
||||
await SLASkillPoints.initializeForActor({
|
||||
actor,
|
||||
trainingPackage: itm,
|
||||
resetAllocations: true,
|
||||
preserveGeneral: false,
|
||||
creationMode: true,
|
||||
seedPackageProfessional: true,
|
||||
professionalSeedPerSkill: 20
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
return ({ 'reqResult': 1, 'errMsg': "" })
|
||||
}
|
||||
|
||||
|
||||
static async personalityDelete(event, actor) {
|
||||
const confirmation = await BRPUtilities.triggerDelete(event, actor, "itemId")
|
||||
//Reset all actor personality points on skills to zero
|
||||
let changes = []
|
||||
if (!confirmation) { return }
|
||||
for (let itm of actor.items) {
|
||||
if (['skill', 'magic', 'psychic'].includes(itm.type)) {
|
||||
changes.push({
|
||||
_id: itm.id,
|
||||
'system.personality': 0,
|
||||
'system.prsnlty': false
|
||||
})
|
||||
}
|
||||
}
|
||||
await Item.updateDocuments(changes, { parent: actor })
|
||||
}
|
||||
|
||||
static async professionDelete(event, actor) {
|
||||
const confirmation = await BRPUtilities.triggerDelete(event, actor, "itemId")
|
||||
//Reset all actor profession skills to zero
|
||||
let changes = []
|
||||
if (!confirmation) { return }
|
||||
for (let itm of actor.items) {
|
||||
if (['skill', 'magic', 'psychic'].includes(itm.type)) {
|
||||
changes.push({
|
||||
_id: itm.id,
|
||||
'system.profession': 0,
|
||||
'system.occupation': false
|
||||
})
|
||||
}
|
||||
}
|
||||
await Item.updateDocuments(changes, { parent: actor })
|
||||
await actor.update({ 'system.wealth': "" })
|
||||
await SLASkillPoints.initializeForActor({
|
||||
actor,
|
||||
trainingPackage: null,
|
||||
resetAllocations: false,
|
||||
creationMode: true
|
||||
})
|
||||
}
|
||||
|
||||
static async cultureDelete(event, actor) {
|
||||
const confirmation = await BRPUtilities.triggerDelete(event, actor, "itemId")
|
||||
//Reset all actor culture skills to zero
|
||||
let changes = []
|
||||
if (!confirmation) { return }
|
||||
for (let itm of actor.items) {
|
||||
if (['skill', 'magic', 'psychic'].includes(itm.type)) {
|
||||
changes.push({
|
||||
_id: itm.id,
|
||||
'system.culture': 0,
|
||||
'system.cultural': false
|
||||
})
|
||||
}
|
||||
}
|
||||
await Item.updateDocuments(changes, { parent: actor })
|
||||
//Reset dice and culture stat mods
|
||||
await actor.update({
|
||||
'system.stats.str.formula': "",
|
||||
'system.stats.con.formula': "",
|
||||
'system.stats.int.formula': "",
|
||||
'system.stats.siz.formula': "",
|
||||
'system.stats.pow.formula': "",
|
||||
'system.stats.dex.formula': "",
|
||||
'system.stats.cha.formula': "",
|
||||
'system.stats.edu.formula': "",
|
||||
'system.stats.str.culture': 0,
|
||||
'system.stats.con.culture': 0,
|
||||
'system.stats.int.culture': 0,
|
||||
'system.stats.siz.culture': 0,
|
||||
'system.stats.pow.culture': 0,
|
||||
'system.stats.dex.culture': 0,
|
||||
'system.stats.cha.culture': 0,
|
||||
'system.stats.edu.culture': 0,
|
||||
'system.move': 0,
|
||||
'system.culture': ""
|
||||
})
|
||||
await actor.unsetFlag(game.system.id, "slaPendingSkillChoices")
|
||||
if (game.brp?.BRPActor?.ensureSLASkillRoster) {
|
||||
await game.brp.BRPActor.ensureSLASkillRoster(actor, {
|
||||
includeEbb: false,
|
||||
pruneEbbForNonEligible: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
static async _selectGroupSkill(newSkill, actor, picks) {
|
||||
let selectOptions = []
|
||||
if (newSkill.system.groupSkills.length > 0) {
|
||||
for (let skillOpt of newSkill.system.groupSkills) {
|
||||
let tempSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: skillOpt.brpid }))[0]
|
||||
if (tempSkill) { selectOptions.push({ id: skillOpt.brpid, selected: false, name: tempSkill.name }) }
|
||||
}
|
||||
} else {
|
||||
let skillList = await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skill'), type: 'i' })
|
||||
skillList.sort(function (a, b) {
|
||||
let x = a.name;
|
||||
let y = b.name;
|
||||
if (x < y) { return -1 };
|
||||
if (x > y) { return 1 };
|
||||
return 0;
|
||||
});
|
||||
for (let skillOpt of skillList) {
|
||||
selectOptions.push({ id: skillOpt.flags.brp.brpidFlag.id, selected: false, name: skillOpt.name })
|
||||
}
|
||||
}
|
||||
let selectedSkill = await SkillsSelectDialog.create(selectOptions, picks, game.i18n.localize('BRP.skills'))
|
||||
if (selectedSkill) {
|
||||
return selectedSkill
|
||||
} else { return false }
|
||||
}
|
||||
|
||||
static async _speciesFlagSkillRows(itm) {
|
||||
const scope = game.system.id
|
||||
const speciesMeta = itm?.flags?.[scope]?.slaSpecies ?? itm?.flags?.brp?.slaSpecies ?? {}
|
||||
const bonuses = Array.isArray(speciesMeta?.skillBonuses) ? speciesMeta.skillBonuses : []
|
||||
if (!bonuses.length) return []
|
||||
|
||||
const aggregate = new Map()
|
||||
for (const row of bonuses) {
|
||||
const skillRef = String(row?.skillRef ?? row?.ref ?? "").trim()
|
||||
const bonus = Number(row?.bonus ?? 0)
|
||||
if (!skillRef || !Number.isFinite(bonus) || bonus === 0) continue
|
||||
const brpid = await SLASeedImporter.resolveSkillBrpid(skillRef)
|
||||
if (!brpid || brpid === "none") continue
|
||||
aggregate.set(brpid, Number(aggregate.get(brpid) ?? 0) + bonus)
|
||||
}
|
||||
|
||||
return [...aggregate.entries()].map(([brpid, bonus]) => ({ brpid, bonus }))
|
||||
}
|
||||
|
||||
static async _setSpeciesChoiceFlags(itm, actor) {
|
||||
const scope = game.system.id
|
||||
const speciesMeta = itm?.flags?.[scope]?.slaSpecies ?? itm?.flags?.brp?.slaSpecies ?? {}
|
||||
const choices = Array.isArray(speciesMeta?.choiceBonuses) ? speciesMeta.choiceBonuses : []
|
||||
if (!choices.length) {
|
||||
await actor.unsetFlag(scope, "slaPendingSkillChoices")
|
||||
return
|
||||
}
|
||||
|
||||
await actor.setFlag(scope, "slaPendingSkillChoices", choices)
|
||||
const totalChoices = choices.reduce(
|
||||
(acc, row) => acc + Math.max(0, Number(row?.count ?? 0)),
|
||||
0
|
||||
)
|
||||
if (totalChoices > 0) {
|
||||
ui.notifications.info(
|
||||
`${actor.name}: ${itm.name} has ${totalChoices} unassigned species skill choice bonus(es).`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static async _selectSkillGroup(newGroup) {
|
||||
let selectOptions = []
|
||||
let picks = newGroup.options
|
||||
for (let skillOpt of newGroup.skills) {
|
||||
let tempSkill = (await game.system.api.brpid.fromBRPIDBest({ brpid: skillOpt.brpid }))[0]
|
||||
if (tempSkill) { selectOptions.push({ id: skillOpt.brpid, selected: false, name: tempSkill.name, bonus: skillOpt.bonus ?? 0 }) }
|
||||
}
|
||||
let selectedSkill = await SkillsSelectDialog.create(selectOptions, picks, game.i18n.localize('BRP.skills'))
|
||||
if (selectedSkill) {
|
||||
return selectedSkill
|
||||
} else { return false }
|
||||
}
|
||||
|
||||
static async _getSpecialism(newSkill, actor) {
|
||||
|
||||
let title = game.i18n.format('BRP.getSpecialism', { entity: newSkill.name })
|
||||
let specName = await new Promise(resolve => {
|
||||
const dlg = new Dialog({
|
||||
title: title,
|
||||
content: `<input class="centre" type="text" name="entry">`,
|
||||
buttons: {
|
||||
roll: {
|
||||
label: game.i18n.localize("BRP.confirm"),
|
||||
callback: html => {
|
||||
let inpB = html.find('[name="entry"]').val()
|
||||
resolve(inpB)
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'roll',
|
||||
close: () => { }
|
||||
}, { classes: ["brp", "sheet"] })
|
||||
dlg.render(true);
|
||||
})
|
||||
newSkill.system.specName = specName
|
||||
newSkill.name = newSkill.system.mainName + ' (' + specName + ')'
|
||||
newSkill.flags.brp.brpidFlag.id = "i.skill." + await BRPUtilities.toKebabCase(newSkill.name)
|
||||
newSkill.system.chosen = true;
|
||||
return newSkill
|
||||
}
|
||||
|
||||
|
||||
static async selectFromRadio(list, title) {
|
||||
//Get list of items
|
||||
let newList = list
|
||||
|
||||
//If there's only one item on the list then return it
|
||||
let selected = ""
|
||||
if (newList.length < 1) { return false }
|
||||
if (Object.keys(newList).length === 1) {
|
||||
selected = Object.values(newList)[0]
|
||||
|
||||
//Otherwise call the dialog selection
|
||||
} else {
|
||||
let destination = 'systems/sla-industries-brp/templates/dialog/selectItem.html';
|
||||
let data = {
|
||||
headTitle: title,
|
||||
newList,
|
||||
}
|
||||
const html = await foundry.applications.handlebars.renderTemplate(destination, data);
|
||||
let usage = await new Promise(resolve => {
|
||||
let formData = null
|
||||
const dlg = new Dialog({
|
||||
title: title,
|
||||
content: html,
|
||||
buttons: {
|
||||
roll: {
|
||||
label: game.i18n.localize("BRP.confirm"),
|
||||
callback: html => {
|
||||
formData = new FormData(html[0].querySelector('#selectItem'))
|
||||
return resolve(formData)
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'roll',
|
||||
close: () => { }
|
||||
}, { classes: ["brp", "sheet"] })
|
||||
dlg.render(true);
|
||||
})
|
||||
|
||||
//Get the PID from the form
|
||||
if (usage) {
|
||||
selected = usage.get('selectItem');
|
||||
}
|
||||
}
|
||||
if (selected === "") { return false }
|
||||
return selected
|
||||
}
|
||||
|
||||
static async _checkSkillCat(skill, actor) {
|
||||
//Check to see if the skill category already exists and if it does then do nothing
|
||||
let newSkillCats = []
|
||||
if (actor.items
|
||||
.filter(dItm => (dItm.flags?.[game.system.id]?.brpidFlag?.id ?? dItm.flags?.brp?.brpidFlag?.id))
|
||||
.filter(nitm => (nitm.flags?.[game.system.id]?.brpidFlag?.id ?? nitm.flags?.brp?.brpidFlag?.id) === skill.system.category).length > 0) {
|
||||
return
|
||||
}
|
||||
//Get the best version of the skill category
|
||||
let newSkillCat = (await game.system.api.brpid.fromBRPIDBest({ brpid: skill.system.category }))[0]
|
||||
if (newSkillCat) {
|
||||
newSkillCats.push(newSkillCat)
|
||||
await Item.createDocuments(newSkillCats, { parent: actor })
|
||||
} else {
|
||||
let errMsg = game.i18n.format('BRP.noSkillCat', { skillCat: skill.system.category, skillName: skill.name })
|
||||
ui.notifications.warn(errMsg);
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
import { BRPactorItemDrop } from './actor-itemDrop.mjs'
|
||||
import { BRPID } from '../brpid/brpid.mjs';
|
||||
import { SLAEbbSystem } from "../apps/sla-ebb-system.mjs";
|
||||
import { SLASeedImporter } from "../apps/sla-seed-importer.mjs";
|
||||
import { SLASkillPoints } from "../apps/sla-skill-points.mjs";
|
||||
|
||||
export class BRPActor extends Actor {
|
||||
|
||||
prepareData() {
|
||||
super.prepareData();
|
||||
}
|
||||
|
||||
prepareBaseData() {
|
||||
this.system.brpidFlagItems = {}
|
||||
for (const i of this.items) {
|
||||
if (i.flags.brp?.brpidFlag?.id) {
|
||||
const parts = i.flags.brp?.brpidFlag?.id.match(/^([^\.]+)\.([^\.]+)\.([^\.]+)$/)
|
||||
if (parts) {
|
||||
if (typeof this.system.brpidFlagItems[parts[1]] === 'undefined') {
|
||||
this.system.brpidFlagItems[parts[1]] = {}
|
||||
}
|
||||
if (typeof this.system.brpidFlagItems[parts[1]][parts[2]] === 'undefined') {
|
||||
this.system.brpidFlagItems[parts[1]][parts[2]] = {}
|
||||
}
|
||||
this.system.brpidFlagItems[parts[1]][parts[2]][parts[3]] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prepareDerivedData() {
|
||||
const actorData = this;
|
||||
const systemData = actorData.system;
|
||||
const flags = actorData.flags.boilerplate || {};
|
||||
|
||||
//Prepare data for different actor types
|
||||
this._prepareCharacterData(actorData);
|
||||
this._prepareNpcData(actorData);
|
||||
}
|
||||
|
||||
//Prepare Character specific data
|
||||
async _prepareCharacterData(actorData) {
|
||||
if (actorData.type !== 'character') return;
|
||||
const systemData = actorData.system;
|
||||
systemData.sla = systemData.sla ?? {};
|
||||
if (!Number.isFinite(Number(systemData.sla.credits))) {
|
||||
systemData.sla.credits = 0;
|
||||
} else {
|
||||
systemData.sla.credits = Number(systemData.sla.credits);
|
||||
}
|
||||
this._prepStats(actorData)
|
||||
this._prepDerivedStats(actorData)
|
||||
|
||||
systemData.health.value = systemData.health.max;
|
||||
|
||||
//Initialise Powers IDs
|
||||
systemData.magic = "";
|
||||
systemData.mutation = "";
|
||||
systemData.psychic = "";
|
||||
systemData.sorcery = "";
|
||||
systemData.super = "";
|
||||
|
||||
//Initialise health statuses and other values
|
||||
systemData.dead = false;
|
||||
systemData.severed = false;
|
||||
systemData.unconscious = false;
|
||||
systemData.injured = false;
|
||||
systemData.incapacitated = false;
|
||||
systemData.bleeding = false
|
||||
systemData.enc = 0;
|
||||
systemData.av1 = 0;
|
||||
systemData.av2 = 0;
|
||||
systemData.avr1 = "";
|
||||
systemData.avr2 = "";
|
||||
systemData.psCurr = 0;
|
||||
systemData.psMax = 0;
|
||||
|
||||
//Set Hit Location AP to zero
|
||||
for (let itm of actorData.items) {
|
||||
|
||||
if (itm.type === 'hit-location') {
|
||||
itm.system.av1 = 0
|
||||
itm.system.av2 = 0
|
||||
itm.system.avr1 = ""
|
||||
itm.system.avr2 = ""
|
||||
itm.system.enc = 0
|
||||
itm.system.ppCurr = 0
|
||||
itm.system.pSCurr = 0
|
||||
itm.system.mnplmod = 0
|
||||
itm.system.percmod = 0
|
||||
itm.system.physmod = 0
|
||||
itm.system.stealthmod = 0
|
||||
}
|
||||
}
|
||||
|
||||
//Calculate Skill Category Bonuses
|
||||
for (let itm of actorData.items) {
|
||||
if (itm.type === 'skillcat') {
|
||||
let bonus = 0;
|
||||
if (game.settings.get('sla-industries-brp', 'skillBonus') === "1" && itm.system.stat != 'none') {
|
||||
bonus = Math.ceil(systemData.stats[itm.system.stat].total / 2);
|
||||
} else if (game.settings.get('sla-industries-brp', 'skillBonus') === "2") {
|
||||
for (let [key, value] of Object.entries(itm.system.attrib)) {
|
||||
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { continue }
|
||||
|
||||
switch (value) {
|
||||
case "0": //No adjustment
|
||||
break;
|
||||
case "1": //Primary
|
||||
bonus += this._categoryprimary(systemData.stats[key].total)
|
||||
break;
|
||||
case "2": //Secondary
|
||||
bonus += this._categorysecondary(systemData.stats[key].total)
|
||||
break;
|
||||
case "3": //Negative
|
||||
bonus += this._categorynegative(systemData.stats[key].total)
|
||||
break;
|
||||
case "4": //Negative Secondary
|
||||
bonus += this._categorynegsec(systemData.stats[key].total)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
itm.system.bonus = bonus
|
||||
itm.system.total = itm.system.bonus + itm.system.mod
|
||||
let key = itm.flags.brp.brpidFlag.id
|
||||
systemData.skillcategory[key] = itm.system.total;
|
||||
}
|
||||
}
|
||||
|
||||
//Calcualte/adjust scores for items (itm)
|
||||
const skillCreationCap = SLASkillPoints.getCreationCapForActor(actorData)
|
||||
for (let itm of actorData.items) {
|
||||
|
||||
//Does the item have transferrable effects
|
||||
if (['gear', 'armour', 'weapon','wound'].includes(itm.type)) {
|
||||
if (itm.transferredEffects.length > 0) {
|
||||
itm.system.hasEffects = true;
|
||||
} else {
|
||||
itm.system.hasEffects = false;
|
||||
}
|
||||
}
|
||||
|
||||
//If skill, magic or psychic calculate the total score and record the category bonus
|
||||
if (itm.type === 'skill' || itm.type === 'magic' || itm.type === 'psychic') {
|
||||
const rawTotal = itm.system.base + itm.system.xp + itm.system.effects + itm.system.profession + itm.system.personality + itm.system.personal + itm.system.culture
|
||||
itm.system.total = (systemData.creationMode && itm.type === "skill")
|
||||
? Math.min(skillCreationCap, rawTotal)
|
||||
: rawTotal
|
||||
itm.system.catBonus = systemData.skillcategory[itm.system.category]
|
||||
|
||||
//If Allegiance the set total and potential Ally status
|
||||
} else if (itm.type === 'allegiance') {
|
||||
itm.system.total = itm.system.allegPoints + itm.system.xp
|
||||
itm.system.potAlly = false
|
||||
|
||||
//If Passion the set total
|
||||
} else if (itm.type === 'passion') {
|
||||
itm.system.total = itm.system.base + itm.system.xp
|
||||
|
||||
//If Personality Trait the set total
|
||||
} else if (itm.type === 'persTrait') {
|
||||
itm.system.total = Math.max(0, Math.min(100, itm.system.base + itm.system.xp))
|
||||
itm.system.opptotal = 100 - itm.system.total
|
||||
|
||||
//If Reputation set total
|
||||
} else if (itm.type === 'reputation') {
|
||||
itm.system.total = itm.system.base
|
||||
|
||||
//If gear/weapon, calculates the encumbrance
|
||||
} else if (['gear', 'weapon'].includes(itm.type)) {
|
||||
if (itm.system.equipStatus === 'carried') {
|
||||
itm.system.actlEnc = itm.system.quantity * itm.system.enc
|
||||
systemData.enc = Number(systemData.enc + itm.system.actlEnc)
|
||||
} else { itm.system.actlEnc = 0 }
|
||||
|
||||
if (itm.system.equipStatus != 'stored') {
|
||||
if (itm.system.pSCurr > 0) { systemData.psCurr = systemData.psCurr + itm.system.pSCurr }
|
||||
if (itm.system.pSMax > 0) { systemData.psMax = systemData.psMax + itm.system.pSMax }
|
||||
}
|
||||
|
||||
//Does the item have transferrable effects _TODO is this duplicate of 126-131, if so delete
|
||||
if (itm.transferredEffects.length > 0) {
|
||||
itm.system.hasEffects = true;
|
||||
} else {
|
||||
itm.system.hasEffects = false;
|
||||
}
|
||||
|
||||
//If armour
|
||||
} else if (itm.type === 'armour') {
|
||||
const hitLoc = game.settings.get('sla-industries-brp', 'useHPL') ? actorData.items.get(itm.system.hitlocID) : null
|
||||
//Calc encumbrance based on carry status and if using HPL
|
||||
if (itm.system.equipStatus === 'carried') {
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system?.fractionENC) {
|
||||
itm.system.actlEnc = Math.round(itm.system.quantity * itm.system.enc / hitLoc.system.fractionENC * 10) / 10
|
||||
} else {
|
||||
itm.system.actlEnc = Number(itm.system.quantity * itm.system.enc)
|
||||
}
|
||||
//If not carried then zero ENC
|
||||
} else {
|
||||
itm.system.actlEnc = 0
|
||||
}
|
||||
systemData.enc = systemData.enc + itm.system.actlEnc
|
||||
if (itm.system.equipStatus != 'stored') {
|
||||
if (itm.system.pSCurr > 0) { systemData.psCurr = systemData.psCurr + itm.system.pSCurr }
|
||||
if (itm.system.pSMax > 0) { systemData.psMax = systemData.psMax + itm.system.pSMax }
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system) {
|
||||
hitLoc.system.ppCurr += itm.system.ppCurr
|
||||
hitLoc.system.pSCurr += Number(itm.system.pSCurr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Add the Armour Point Score to the Hit Location if worn
|
||||
if (itm.system.equipStatus === 'worn') {
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system) {
|
||||
hitLoc.system.av1 += itm.system.av1
|
||||
hitLoc.system.mnplmod += itm.system.mnplmod
|
||||
hitLoc.system.percmod += itm.system.percmod
|
||||
hitLoc.system.physmod += itm.system.physmod
|
||||
hitLoc.system.stealthmod += itm.system.stealthmod
|
||||
if (itm.system.av2 > 0) {
|
||||
hitLoc.system.av2 += itm.system.av2
|
||||
}
|
||||
if (itm.system.avr1 != "") {
|
||||
hitLoc.system.avr1 += "+" + itm.system.avr1
|
||||
}
|
||||
if (itm.system.avr2 != "") {
|
||||
hitLoc.system.avr2 += "+" + itm.system.avr2
|
||||
}
|
||||
} else {
|
||||
systemData.av1 += itm.system.av1
|
||||
systemData.av2 += itm.system.av2
|
||||
if (itm.system.avr1 != "") {
|
||||
systemData.avr1 += "+" + itm.system.avr1
|
||||
}
|
||||
if (itm.system.avr2 != "") {
|
||||
systemData.avr2 += "+" + itm.system.avr2
|
||||
}
|
||||
}
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system) {
|
||||
hitLoc.system.enc += itm.system.actlEnc
|
||||
}
|
||||
//If power then record the ID against the category
|
||||
} else if (itm.type === 'power') {
|
||||
systemData[itm.system.category] = itm._id;
|
||||
} else if (itm.type === 'profession') {
|
||||
systemData.profession = itm.name
|
||||
systemData.professionId = itm._id;
|
||||
//If hit locations calculation HPL max and current and statuses
|
||||
} else if (itm.type === 'hit-location' && game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
itm.system.injured = false;
|
||||
itm.system.maxHP = Math.max((Math.ceil(systemData.health.max / itm.system.fractionHP) + itm.system.adj), 0);
|
||||
itm.system.currHP = itm.system.maxHP
|
||||
//Loop through items (wnd) to find wounds
|
||||
for (let wnd of actorData.items) {
|
||||
if (wnd.type === 'wound' && wnd.system.locId === itm._id) {
|
||||
itm.system.currHP = itm.system.currHP - wnd.system.value
|
||||
}
|
||||
}
|
||||
if (itm.system.currHP < 1 && itm.system.locType === 'limb') {
|
||||
itm.system.injured = true
|
||||
} else if (itm.system.currHP < 1 && itm.system.locType === 'abdomen') {
|
||||
itm.system.injured = true
|
||||
systemData.injured = true
|
||||
} else if (itm.system.currHP < 1 && itm.system.locType === 'chest') {
|
||||
itm.system.incapacitated = true
|
||||
systemData.incapacitated = true
|
||||
} else if (itm.system.currHP < 1 && itm.system.locType === 'head') {
|
||||
itm.system.unconscious = true
|
||||
systemData.unconscious = true
|
||||
}
|
||||
if (itm.system.bleeding) { systemData.bleeding = true }
|
||||
if (itm.system.unconscious) { systemData.unconscious = true }
|
||||
if (itm.system.incapacitated) { systemData.incapacitated = true }
|
||||
if (itm.system.severed) { systemData.severed = true }
|
||||
if (itm.system.dead) { systemData.dead = true }
|
||||
//If wound calculate total HPL
|
||||
} else if (itm.type === 'wound') {
|
||||
systemData.health.value = systemData.health.value - itm.system.value
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Calculate allegiance target
|
||||
let allegiances = actorData.items.filter(itm => itm.type === 'allegiance')
|
||||
allegiances.sort(function (a, b) {
|
||||
let x = a.system.total;
|
||||
let y = b.system.total;
|
||||
if (x < y) { return 1 };
|
||||
if (x > y) { return -1 };
|
||||
return 0;
|
||||
});
|
||||
//If there is one allegiance
|
||||
let ally = ""
|
||||
if (allegiances.length === 1 && allegiances[0].system.total >= 20) {
|
||||
actorData.items.get(allegiances[0]._id).system.potAlly = true
|
||||
} else if (allegiances.length > 1 && (allegiances[0].system.total - allegiances[1].system.total) >= 20) {
|
||||
actorData.items.get(allegiances[0]._id).system.potAlly = true
|
||||
}
|
||||
|
||||
|
||||
//Round ENC to 2 decimals and then adjust fatigue
|
||||
systemData.enc = (systemData.enc).toFixed(2)
|
||||
systemData.fatigue.max = Math.ceil(systemData.stats.str.total + systemData.stats.con.total - systemData.enc + systemData.fatigue.mod + (systemData.fatigue.effects ?? 0));
|
||||
|
||||
//Derive Health Statuses from total HP
|
||||
if (systemData.health.value < 1) {
|
||||
systemData.dead = true;
|
||||
} else if (systemData.health.value < 3) {
|
||||
systemData.unconscious = true;
|
||||
}
|
||||
}
|
||||
|
||||
//Prepare NPC specific data.
|
||||
async _prepareNpcData(actorData) {
|
||||
if (actorData.type !== 'npc') return;
|
||||
const systemData = actorData.system;
|
||||
this._prepStats(actorData)
|
||||
this._prepDerivedStats(actorData)
|
||||
|
||||
for (let [key, stat] of Object.entries(actorData.system.baseStats)) {
|
||||
stat.label = game.i18n.localize(CONFIG.BRP.stats[key]) ?? key;
|
||||
stat.labelShort = game.i18n.localize(CONFIG.BRP.statsAbbreviations[key]) ?? key;
|
||||
//Mark all stats as visible except for EDU where it's not being used
|
||||
stat.visible = true;
|
||||
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { stat.visible = false }
|
||||
}
|
||||
|
||||
|
||||
let damage = 0
|
||||
systemData.fatigue.max = Math.ceil(systemData.stats.str.total + systemData.stats.con.total + systemData.fatigue.mod + (systemData.fatigue.effects ?? 0));
|
||||
|
||||
for (let itm of actorData.items) {
|
||||
if (['skill', 'psychic', 'magic', 'passion', 'reputation'].includes(itm.type)) {
|
||||
itm.system.total = itm.system.base + (itm.system.effects ?? 0);
|
||||
} else if (['allegiance'].includes(itm.type)) {
|
||||
itm.system.total = itm.system.allegPoints;
|
||||
} else if (['persTrait'].includes(itm.type)) {
|
||||
itm.system.total = itm.system.base;
|
||||
itm.system.opptotal = 100 - itm.system.base;
|
||||
}
|
||||
else if (itm.type === 'hit-location' && game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
itm.system.injured = false;
|
||||
itm.system.maxHP = Math.max((Math.ceil(systemData.health.max / itm.system.fractionHP) + itm.system.adj), 0);
|
||||
damage = damage + Math.max(itm.system.maxHP - itm.system.currHP, 0)
|
||||
}
|
||||
}
|
||||
|
||||
//If using HPL then set current health
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
systemData.health.value = systemData.health.max - damage
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
getRollData() {
|
||||
const data = super.getRollData();
|
||||
|
||||
// Prepare character roll data.
|
||||
this._getCharacterRollData(data);
|
||||
this._getNpcRollData(data);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Prepare character roll data.
|
||||
_getCharacterRollData(data) {
|
||||
if (this.type !== 'character') return;
|
||||
|
||||
// Copy the ability scores to the top level
|
||||
if (data.stats) {
|
||||
for (let [k, v] of Object.entries(data.stats)) {
|
||||
data[k] = foundry.utils.deepClone(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare NPC roll data
|
||||
_getNpcRollData(data) {
|
||||
if (this.type !== 'npc') return;
|
||||
}
|
||||
|
||||
|
||||
//Prepare Stats
|
||||
_prepStats(actorData) {
|
||||
// Loop through ability scores, calculating total and derived scores
|
||||
actorData.system.redistInc = 0
|
||||
actorData.system.redistDec = 0
|
||||
for (let [key, stat] of Object.entries(actorData.system.stats)) {
|
||||
stat.label = game.i18n.localize(CONFIG.BRP.stats[key]) ?? key;
|
||||
stat.labelShort = game.i18n.localize(CONFIG.BRP.statsAbbreviations[key]) ?? key;
|
||||
stat.labelDeriv = game.i18n.localize(CONFIG.BRP.statsDerived[key]) ?? key;
|
||||
stat.total = Number(stat.base) + Number(stat.redist) + Number(stat.culture) + Number(stat.exp) + Number(stat.effects) + Number(stat.age);
|
||||
stat.deriv = stat.total * 5;
|
||||
if (stat.redist < 0) { actorData.system.redistDec = actorData.system.redistDec + stat.redist }
|
||||
if (stat.redist > 0) { actorData.system.redistInc = actorData.system.redistInc + stat.redist }
|
||||
|
||||
//Mark all stats as visible except for EDU where it's not being used
|
||||
stat.visible = true;
|
||||
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { stat.visible = false }
|
||||
}
|
||||
if (actorData.type === 'npc')
|
||||
for (let [key, stat] of Object.entries(actorData.system.baseStats)) {
|
||||
stat.labelShort = game.i18n.localize(CONFIG.BRP.statsAbbreviations[key]) ?? key;
|
||||
//Mark all stats as visible except for EDU where it's not being used
|
||||
stat.visible = true;
|
||||
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { stat.visible = false }
|
||||
}
|
||||
actorData.system.redistTotal = actorData.system.redistInc + actorData.system.redistDec
|
||||
}
|
||||
|
||||
// Calculate derived scores
|
||||
_prepDerivedStats(actorData) {
|
||||
let systemData = actorData.system
|
||||
if (actorData.type === 'character') {
|
||||
const hpMod = Number(game.settings.get('sla-industries-brp', 'hpMod') ?? 1);
|
||||
const pulpMultiplier = game.settings.get('sla-industries-brp', 'pulpSla') ? 2 : 1;
|
||||
systemData.health.max = Math.ceil((systemData.stats.con.total + systemData.stats.siz.total) * hpMod * pulpMultiplier / 2) + systemData.health.mod + (systemData.health.effects ?? 0);
|
||||
} else {
|
||||
let health = 0
|
||||
let formula = ""
|
||||
let statCount = 0
|
||||
if (systemData.hp.stat1 != 'none') {
|
||||
health += systemData.stats[systemData.hp.stat1].total
|
||||
statCount++
|
||||
formula = game.i18n.localize(CONFIG.BRP.statsAbbreviations[systemData.hp.stat1])
|
||||
}
|
||||
if (systemData.hp.stat2 != 'none') {
|
||||
health += systemData.stats[systemData.hp.stat2].total
|
||||
statCount++
|
||||
if (statCount === 2) { formula = formula + " + " }
|
||||
formula = formula + game.i18n.localize(CONFIG.BRP.statsAbbreviations[systemData.hp.stat2])
|
||||
}
|
||||
health = Math.ceil(health * systemData.hp.multi)
|
||||
if (statCount === 1) {
|
||||
formula = "(" + formula + " * " + systemData.hp.multi + ")"
|
||||
} else if (statCount === 2) {
|
||||
formula = "((" + formula + ") * " + systemData.hp.multi + ")"
|
||||
}
|
||||
if (systemData.health.mod < 0) {
|
||||
formula = formula + " " + systemData.health.mod
|
||||
} else if (systemData.health.mod > 0) {
|
||||
formula = formula + " + " + systemData.health.mod
|
||||
}
|
||||
systemData.hp.formula = formula
|
||||
systemData.health.max = health + (systemData.health.mod ?? 0) + (systemData.health.effects ?? 0);
|
||||
}
|
||||
systemData.health.mjrwnd = Math.ceil(systemData.health.max / 2);
|
||||
systemData.power.max = systemData.stats.pow.total + systemData.power.mod + (systemData.power.effects ?? 0);
|
||||
if (actorData.type === 'character' && !SLAEbbSystem.isEbbSpecies(actorData)) {
|
||||
// Flux is SLA EBB-only and only available to Ebon / Brain Waster characters.
|
||||
systemData.power.max = 0;
|
||||
systemData.power.value = 0;
|
||||
} else {
|
||||
systemData.power.value = Math.max(0, Math.min(Number(systemData.power.value ?? 0), Number(systemData.power.max ?? 0)));
|
||||
}
|
||||
|
||||
const derivedSanMax = Math.max(1, Number(systemData.stats.pow.total ?? 0) * 5);
|
||||
const currentSanMax = Number(systemData.sanity?.max ?? 0);
|
||||
const currentSan = Number(systemData.sanity?.value ?? 0);
|
||||
const needsSanInit = (!Number.isFinite(currentSanMax) || currentSanMax <= 0 || (currentSanMax === 5 && currentSan === 5 && derivedSanMax > 5));
|
||||
const needsLegacySanMigration = (Number.isFinite(currentSanMax) && currentSanMax > 0 && currentSanMax < derivedSanMax && currentSanMax <= 10);
|
||||
if (needsSanInit || needsLegacySanMigration) {
|
||||
// Migrate legacy small SAN pools (e.g. 3/5) to POWx5 while preserving any existing SAN loss.
|
||||
const legacyLoss = (Number.isFinite(currentSanMax) && Number.isFinite(currentSan))
|
||||
? Math.max(0, Number(currentSanMax) - Number(currentSan))
|
||||
: 0;
|
||||
systemData.sanity.max = derivedSanMax;
|
||||
systemData.sanity.value = Math.max(0, derivedSanMax - legacyLoss);
|
||||
} else {
|
||||
systemData.sanity.max = Math.max(1, currentSanMax);
|
||||
systemData.sanity.value = Math.max(0, Math.min(Number(currentSan ?? 0), Number(systemData.sanity.max ?? 0)));
|
||||
}
|
||||
|
||||
if (game.settings.get('sla-industries-brp', 'useRes5')) {
|
||||
const derivedCoolMax = Math.max(1, Number(systemData.stats.pow.total ?? 0) * 2);
|
||||
systemData.res5.max = derivedCoolMax;
|
||||
if (!Number.isFinite(Number(systemData.res5.value))) {
|
||||
systemData.res5.value = derivedCoolMax;
|
||||
}
|
||||
systemData.res5.value = Math.max(0, Math.min(Number(systemData.res5.value ?? 0), Number(systemData.res5.max ?? 0)));
|
||||
}
|
||||
|
||||
systemData.xpBonus = Math.ceil(systemData.stats.int.total / 2);
|
||||
systemData.dmgBonus = this._damageBonus(systemData.stats.str.total + systemData.stats.siz.total)
|
||||
|
||||
//Set Resource Labels
|
||||
if (game.settings.get('sla-industries-brp', 'ppLabelLong')) {
|
||||
systemData.power.label = game.settings.get('sla-industries-brp', 'ppLabelLong')
|
||||
} else {
|
||||
systemData.power.label = game.i18n.localize('BRP.pp')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'ppLabelShort')) {
|
||||
systemData.power.labelAbbr = game.settings.get('sla-industries-brp', 'ppLabelShort')
|
||||
} else {
|
||||
systemData.power.labelAbbr = game.i18n.localize('BRP.ppShort')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'hpLabelLong')) {
|
||||
systemData.health.label = game.settings.get('sla-industries-brp', 'hpLabelLong')
|
||||
} else {
|
||||
systemData.health.label = game.i18n.localize('BRP.health')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'hpLabelShort')) {
|
||||
systemData.health.labelAbbr = game.settings.get('sla-industries-brp', 'hpLabelShort')
|
||||
} else {
|
||||
systemData.health.labelAbbr = game.i18n.localize('BRP.hp')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'fpLabelLong')) {
|
||||
systemData.fatigue.label = game.settings.get('sla-industries-brp', 'fpLabelLong')
|
||||
} else {
|
||||
systemData.fatigue.label = game.i18n.localize('BRP.fatigue')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'fpLabelShort')) {
|
||||
systemData.fatigue.labelAbbr = game.settings.get('sla-industries-brp', 'fpLabelShort')
|
||||
} else {
|
||||
systemData.fatigue.labelAbbr = game.i18n.localize('BRP.fp')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'res5LabelLong')) {
|
||||
systemData.res5.label = game.settings.get('sla-industries-brp', 'res5LabelLong')
|
||||
} else {
|
||||
systemData.res5.label = game.i18n.localize('BRP.res5')
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'res5LabelShort')) {
|
||||
systemData.res5.labelAbbr = game.settings.get('sla-industries-brp', 'res5LabelShort')
|
||||
} else {
|
||||
systemData.res5.labelAbbr = game.i18n.localize('BRP.res5Abbr')
|
||||
}
|
||||
}
|
||||
|
||||
//Used for Rolling NPCs when token dropped
|
||||
get hasRollableCharacteristics() {
|
||||
for (const [, value] of Object.entries(this.system.baseStats)) {
|
||||
if (isNaN(Number(value.random))) return true
|
||||
if (isNaN(Number(value.average))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//Roll Random Stats
|
||||
async rollCharacteristicsValue() {
|
||||
const stats = {}
|
||||
for (const [key, value] of Object.entries(this.system.baseStats)) {
|
||||
if (value.random && !value.random.startsWith('@')) {
|
||||
const r = new Roll(value.random)
|
||||
await r.evaluate()
|
||||
if (r.total) {
|
||||
stats[`system.stats.${key}.base`] = Math.floor(
|
||||
r.total
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.update(stats)
|
||||
await this.updateVitals()
|
||||
}
|
||||
|
||||
//Roll Average Stats
|
||||
async averageCharacteristicsValue() {
|
||||
const stats = {}
|
||||
for (const [key, value] of Object.entries(this.system.baseStats)) {
|
||||
if (value.average && !value.average.startsWith('@')) {
|
||||
const r = new Roll(value.average)
|
||||
await r.evaluate()
|
||||
if (r.total) {
|
||||
stats[`system.stats.${key}.base`] = Math.floor(
|
||||
r.total
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.update(stats)
|
||||
await this.updateVitals()
|
||||
}
|
||||
|
||||
//Update Current HP, FP etc and HPL when Rolled or Average Roll
|
||||
async updateVitals() {
|
||||
let checkProp = {
|
||||
'system.health.value': this.system.health.max,
|
||||
'system.power.value': this.system.power.max,
|
||||
'system.fatigue.value': this.system.fatigue.max,
|
||||
'system.sanity.value': this.system.sanity.max
|
||||
}
|
||||
await this.update(checkProp)
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
let hitLocs = this.items.filter(itm => itm.type === 'hit-location').map(itm => {
|
||||
return { _id: itm.id, 'system.currHP': itm.system.maxHP }
|
||||
})
|
||||
await Item.updateDocuments(hitLocs, { parent: this })
|
||||
}
|
||||
}
|
||||
|
||||
//Create a new actor - When creating an actor set basics including tokenlink, bars, displays sight
|
||||
static async create(data, options = {}) {
|
||||
//If dropping from compendium check to see if the actor already exists in game.actors and if it does then get the game.actors details rather than create a copy
|
||||
if (options.fromCompendium) {
|
||||
let tempActor = await (game.actors.filter(actr => actr.flags?.brp?.brpidFlag?.id === data.flags.brp.brpidFlag.id && actr.flags?.brp?.brpidFlag?.priority === data.flags.brp.brpidFlag.priority))[0]
|
||||
if (tempActor) { return tempActor }
|
||||
}
|
||||
|
||||
|
||||
if (data.type === 'character') {
|
||||
data.prototypeToken = foundry.utils.mergeObject({
|
||||
actorLink: true,
|
||||
disposition: 1,
|
||||
displayName: CONST.TOKEN_DISPLAY_MODES.ALWAYS,
|
||||
displayBars: CONST.TOKEN_DISPLAY_MODES.ALWAYS,
|
||||
sight: {
|
||||
enabled: true
|
||||
},
|
||||
detectionModes: [{
|
||||
id: 'basicSight',
|
||||
range: 30,
|
||||
enabled: true
|
||||
}]
|
||||
}, data.prototypeToken || {})
|
||||
}
|
||||
|
||||
let actor = await super.create(data, options)
|
||||
|
||||
//Add BRPID based on actor name if the game setting is flagged.
|
||||
if (game.settings.get('sla-industries-brp', "actorBRPID")) {
|
||||
let tempID = await BRPID.guessId(actor)
|
||||
if (tempID) {
|
||||
await actor.update({ 'flags.brp.brpidFlag.id': tempID })
|
||||
const html = $(actor.sheet.element).find('header.window-header a.header-button.edit-brpid-warning,header.window-header a.header-button.edit-brpid-exisiting')
|
||||
if (html.length) {
|
||||
html.css({
|
||||
color: (tempID ? 'orange' : 'red')
|
||||
})
|
||||
}
|
||||
actor.render()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//Set up a general hit location for a new actor if using HPL
|
||||
if (actor.type === 'character' && game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
let brpid = "i.hit-location.general"
|
||||
|
||||
const itemData = [{
|
||||
name: game.i18n.localize('BRP.general'),
|
||||
type: 'hit-location',
|
||||
img: 'systems/sla-industries-brp/assets/Icons/arm-bandage.svg',
|
||||
flags: { brp: { brpidFlag: { id: "i.hit-location.general" } } },
|
||||
system: {
|
||||
"fractionHP": 1,
|
||||
"fractionENC": 1,
|
||||
"lowRoll": 0,
|
||||
"highRoll": 0,
|
||||
"locType": "general"
|
||||
}
|
||||
}];
|
||||
const newItem = await Item.createDocuments(itemData, { parent: actor });
|
||||
}
|
||||
|
||||
//Add Starter Traits if toggled on
|
||||
if (actor.type === 'character' && game.settings.get('sla-industries-brp', 'starterTraits') && game.settings.get('sla-industries-brp', 'usePersTrait')) {
|
||||
let newTraits = []
|
||||
let traitList = (await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.persTrait'), type: 'i' })).filter(itm => itm.system.basic)
|
||||
for (let itm of traitList) {
|
||||
if (actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === itm.flags.brp.brpidFlag.id).length < 1) {
|
||||
newTraits.push(itm);
|
||||
}
|
||||
}
|
||||
await Item.createDocuments(newTraits, { parent: actor });
|
||||
}
|
||||
|
||||
|
||||
|
||||
//Add Skill Categories
|
||||
if (actor.type === 'character') {
|
||||
let newSkillCats = []
|
||||
let skillCatList = (await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skillcat'), type: 'i' }))
|
||||
for (let itm of skillCatList) {
|
||||
if (actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === itm.flags.brp.brpidFlag.id).length < 1) {
|
||||
newSkillCats.push(itm)
|
||||
}
|
||||
}
|
||||
await Item.createDocuments(newSkillCats, { parent: actor })
|
||||
|
||||
// SLA baseline: every new character gets full SLA skill roster.
|
||||
await BRPActor.ensureSLASkillRoster(actor, {
|
||||
includeEbb: SLAEbbSystem.isEbbSpecies(actor),
|
||||
pruneEbbForNonEligible: true
|
||||
})
|
||||
await SLASkillPoints.initializeForActor({
|
||||
actor,
|
||||
trainingPackage: null,
|
||||
resetAllocations: false,
|
||||
creationMode: true
|
||||
})
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
static async ensureSLASkillRoster(actor, { includeEbb = null, pruneEbbForNonEligible = true } = {}) {
|
||||
if (!actor || actor.type !== "character") return { created: 0, removed: 0, missing: [] }
|
||||
if (includeEbb === null || includeEbb === undefined) {
|
||||
includeEbb = SLAEbbSystem.isEbbSpecies(actor)
|
||||
}
|
||||
|
||||
const seed = await SLASeedImporter.loadSeed("skills.json").catch(() => null)
|
||||
if (!seed?.skills?.length) {
|
||||
// Fallback for non-seeded worlds.
|
||||
if (game.settings.get('sla-industries-brp', 'starterSkills')) {
|
||||
let newSkills = []
|
||||
let skillList = (await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skill'), type: 'i' })).filter(itm => itm.system.basic)
|
||||
for (let itm of skillList) {
|
||||
if (actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === itm.flags.brp.brpidFlag.id).length < 1) {
|
||||
itm.system.base = await BRPactorItemDrop._calcBase(itm, actor)
|
||||
newSkills.push(itm)
|
||||
}
|
||||
}
|
||||
await Item.createDocuments(newSkills, { parent: actor })
|
||||
await BRPActor.charBaseSkillScores(actor)
|
||||
return { created: newSkills.length, removed: 0, missing: [] }
|
||||
}
|
||||
return { created: 0, removed: 0, missing: [] }
|
||||
}
|
||||
|
||||
const existing = new Map()
|
||||
for (const item of actor.items.filter((i) => i.type === "skill")) {
|
||||
const brpid = item.flags?.[game.system.id]?.brpidFlag?.id ?? item.flags?.brp?.brpidFlag?.id
|
||||
if (brpid) existing.set(brpid, item)
|
||||
}
|
||||
|
||||
const createData = []
|
||||
const missing = []
|
||||
const iconUpdates = []
|
||||
for (const skillRow of seed.skills) {
|
||||
const skillName = String(skillRow?.name ?? "").trim()
|
||||
if (!skillName) continue
|
||||
const isEbbSkill = BRPActor._isEbbSkillName(skillName)
|
||||
if (isEbbSkill && !includeEbb) continue
|
||||
|
||||
const brpid = await SLASeedImporter.resolveSkillBrpid(skillName)
|
||||
if (!brpid || brpid === "none") {
|
||||
missing.push(skillName)
|
||||
continue
|
||||
}
|
||||
if (existing.has(brpid)) continue
|
||||
|
||||
const sourceDoc = (await game.system.api.brpid.fromBRPIDBest({ brpid }))[0]
|
||||
if (!sourceDoc || sourceDoc.type !== "skill") {
|
||||
missing.push(skillName)
|
||||
continue
|
||||
}
|
||||
|
||||
const data = sourceDoc.toObject()
|
||||
delete data._id
|
||||
delete data.folder
|
||||
delete data.sort
|
||||
delete data.ownership
|
||||
delete data.pack
|
||||
data.system = data.system ?? {}
|
||||
|
||||
// Normalize specialty placeholders (e.g. Craft (Specialty)) even if source flags are missing.
|
||||
if (!data.system.specialism && /\(\s*specialty\s*\)/i.test(skillName)) {
|
||||
const mainName = String(data.system.mainName || skillName.replace(/\(\s*specialty\s*\)/i, "").trim()).trim()
|
||||
data.system.specialism = true
|
||||
data.system.mainName = mainName
|
||||
data.system.specName = String(data.system.specName || "Specialty").trim()
|
||||
data.system.chosen = true
|
||||
data.name = `${mainName} (${data.system.specName})`
|
||||
}
|
||||
|
||||
// Reserve one specialty slot when a skill uses specialties.
|
||||
if (data.system.specialism && !data.system.chosen) {
|
||||
const mainName = String(data.system.mainName || skillName).trim()
|
||||
const specName = String(data.system.specName || "Specialty").trim()
|
||||
data.system.mainName = mainName
|
||||
data.system.specName = specName
|
||||
data.system.chosen = true
|
||||
data.name = `${mainName} (${specName})`
|
||||
}
|
||||
|
||||
const skillIcon = await SLASeedImporter.getSkillIconPath(skillName)
|
||||
if (skillIcon) {
|
||||
data.img = skillIcon
|
||||
}
|
||||
|
||||
data.system.base = await BRPactorItemDrop._calcBase(data, actor)
|
||||
createData.push(data)
|
||||
}
|
||||
|
||||
if (createData.length) {
|
||||
await actor.createEmbeddedDocuments("Item", createData)
|
||||
}
|
||||
|
||||
for (const skillItem of actor.items.filter((item) => item.type === "skill")) {
|
||||
const iconName = String(skillItem.system?.mainName || skillItem.name || "").trim()
|
||||
if (!iconName) continue
|
||||
const iconPath = await SLASeedImporter.getSkillIconPath(iconName)
|
||||
if (!iconPath || skillItem.img === iconPath) continue
|
||||
iconUpdates.push({ _id: skillItem.id, img: iconPath })
|
||||
}
|
||||
if (iconUpdates.length) {
|
||||
await actor.updateEmbeddedDocuments("Item", iconUpdates)
|
||||
}
|
||||
|
||||
let removed = 0
|
||||
if (!includeEbb && pruneEbbForNonEligible) {
|
||||
const ebbSkillIds = actor.items
|
||||
.filter((item) => item.type === "skill")
|
||||
.filter((item) => BRPActor._isEbbSkillName(String(item.system?.mainName || item.name || "")))
|
||||
.map((item) => item.id)
|
||||
if (ebbSkillIds.length) {
|
||||
await actor.deleteEmbeddedDocuments("Item", ebbSkillIds)
|
||||
removed = ebbSkillIds.length
|
||||
}
|
||||
}
|
||||
|
||||
if (createData.length || removed > 0) {
|
||||
await BRPActor.charBaseSkillScores(actor)
|
||||
}
|
||||
return { created: createData.length, removed, missing }
|
||||
}
|
||||
|
||||
static async syncAllCharacterSkillRosters({ notify = true } = {}) {
|
||||
const actors = game.actors?.filter((a) => a.type === "character") ?? []
|
||||
let created = 0
|
||||
let removed = 0
|
||||
const missing = new Set()
|
||||
|
||||
for (const actor of actors) {
|
||||
const result = await BRPActor.ensureSLASkillRoster(actor, {
|
||||
includeEbb: SLAEbbSystem.isEbbSpecies(actor),
|
||||
pruneEbbForNonEligible: true
|
||||
})
|
||||
created += Number(result?.created ?? 0)
|
||||
removed += Number(result?.removed ?? 0)
|
||||
for (const row of result?.missing ?? []) missing.add(String(row))
|
||||
}
|
||||
|
||||
const summary = { actors: actors.length, created, removed, missing: [...missing] }
|
||||
if (notify && (created > 0 || removed > 0 || summary.missing.length > 0)) {
|
||||
const parts = []
|
||||
if (created > 0) parts.push(`${created} added`)
|
||||
if (removed > 0) parts.push(`${removed} removed`)
|
||||
if (summary.missing.length > 0) parts.push(`${summary.missing.length} unresolved refs`)
|
||||
ui.notifications.info(`SLA skill roster sync: ${parts.join(", ")}.`)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
static _isEbbSkillName(name = "") {
|
||||
const norm = String(name ?? "").trim().toLowerCase()
|
||||
return (
|
||||
norm === "biofeedback" ||
|
||||
norm === "formulate" ||
|
||||
norm === "ebb (core)" ||
|
||||
norm.startsWith("ebb ")
|
||||
)
|
||||
}
|
||||
|
||||
//Recalculate all skill base scores
|
||||
static async charBaseSkillScores(actor) {
|
||||
let change = []
|
||||
for (let itm of actor.items) {
|
||||
if (itm.type != 'skill') { continue }
|
||||
let baseScore = await BRPactorItemDrop._calcBase(itm, actor)
|
||||
change.push({ _id: itm._id, "system.base": baseScore })
|
||||
}
|
||||
await Item.updateDocuments(change, { parent: actor })
|
||||
}
|
||||
|
||||
// Primary Skills Bonus
|
||||
_categoryprimary(statvalue) {
|
||||
var bonus = statvalue - 10;
|
||||
return bonus;
|
||||
}
|
||||
|
||||
// Secondary Skills Bonus
|
||||
_categorysecondary(statvalue) {
|
||||
var bonus = (statvalue - 10) / 2
|
||||
if (bonus < 0) {
|
||||
bonus = Math.ceil(bonus);
|
||||
} else {
|
||||
bonus = Math.floor(bonus);
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
// Negative Skills Bonus
|
||||
_categorynegative(statvalue) {
|
||||
var bonus = 10 - statvalue;
|
||||
return bonus;
|
||||
}
|
||||
|
||||
// Negative Secondary Skills Bonus
|
||||
_categorynegsec(statvalue) {
|
||||
var bonus = (10 - statvalue) / 2
|
||||
if (bonus < 0) {
|
||||
bonus = Math.ceil(bonus);
|
||||
} else {
|
||||
bonus = Math.floor(bonus);
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
//Damage Bonus
|
||||
_damageBonus(value) {
|
||||
let dmgBonus = {}
|
||||
if (value < 13) {
|
||||
dmgBonus.full = '-1D6'
|
||||
dmgBonus.half = '-1D3'
|
||||
} else if (value < 17) {
|
||||
dmgBonus.full = '-1D4'
|
||||
dmgBonus.half = '-1D2'
|
||||
} else if (value < 25) {
|
||||
dmgBonus.full = '+0'
|
||||
dmgBonus.half = '+0'
|
||||
} else if (value < 33) {
|
||||
dmgBonus.full = '+1D4'
|
||||
dmgBonus.half = '+1D2'
|
||||
} else if (value < 41) {
|
||||
dmgBonus.full = '+1D6'
|
||||
dmgBonus.half = '+1D3'
|
||||
} else {
|
||||
dmgBonus.full = "+" + (Math.ceil((value - 40) / 16) + 1) + "D6"
|
||||
dmgBonus.half = "+" + (Math.ceil((value - 40) / 16) + 1) + "D3"
|
||||
}
|
||||
return dmgBonus
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
import { BRPSelectLists } from "../../apps/select-lists.mjs";
|
||||
import { BRPRollType } from '../../apps/rollType.mjs';
|
||||
import { addBRPIDSheetHeaderButton } from '../../brpid/brpid-button.mjs'
|
||||
import { SLAAmmoTracker } from '../../apps/sla-ammo-tracker.mjs';
|
||||
|
||||
export class BRPNpcSheet extends foundry.appv1.sheets.ActorSheet {
|
||||
|
||||
//Add BRPID buttons to sheet
|
||||
_getHeaderButtons() {
|
||||
const headerButtons = super._getHeaderButtons()
|
||||
addBRPIDSheetHeaderButton(headerButtons, this)
|
||||
return headerButtons
|
||||
}
|
||||
|
||||
//Turn off App V1 deprecation warnings
|
||||
//TODO - move to V2
|
||||
static _warnedAppV1 = true
|
||||
|
||||
/** @override */
|
||||
static get defaultOptions() {
|
||||
|
||||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||||
classes: ["brp", "sheet", "actor"],
|
||||
template: "systems/sla-industries-brp/templates/actor/npc-sheet.html",
|
||||
width: 595,
|
||||
height: 770,
|
||||
});
|
||||
}
|
||||
|
||||
static confirmItemDelete(actor, itemId) {
|
||||
let item = actor.items.get(itemId);
|
||||
item.delete();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
async getData() {
|
||||
const context = super.getData();
|
||||
const actorData = this.actor.toObject(false);
|
||||
context.system = actorData.system;
|
||||
context.flags = actorData.flags;
|
||||
context.useEDU = game.settings.get('sla-industries-brp', 'useEDU');
|
||||
context.useFP = false;
|
||||
context.useSAN = game.settings.get('sla-industries-brp', 'useSAN');
|
||||
context.useRes5 = game.settings.get('sla-industries-brp', 'useRes5');
|
||||
context.useHPL = game.settings.get('sla-industries-brp', 'useHPL');
|
||||
context.useAlleg = game.settings.get('sla-industries-brp', 'useAlleg');
|
||||
context.usePassion = game.settings.get('sla-industries-brp', 'usePassion');
|
||||
context.useAVRand = game.settings.get('sla-industries-brp', 'useAVRand');
|
||||
context.useReputation = game.settings.get('sla-industries-brp', 'useReputation');
|
||||
context.useBeastiary = game.settings.get('sla-industries-brp', 'beastiary')
|
||||
context.statOptions = await BRPSelectLists.addStatOptions("");
|
||||
context.showArmour = false
|
||||
|
||||
context.resource = 2
|
||||
if (game.settings.get('sla-industries-brp', 'useSAN')) { context.resource++ }
|
||||
if (game.settings.get('sla-industries-brp', 'useRes5')) { context.resource++ }
|
||||
|
||||
if (game.settings.get('sla-industries-brp', 'beastiary')) { context.resource = 5 }
|
||||
|
||||
if (!context.useHPL || context.useBeastiary) { context.showArmour = true }
|
||||
|
||||
context.extDescValue = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
|
||||
context.system.extDesc,
|
||||
{
|
||||
async: true,
|
||||
secrets: context.editable
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
// Prepare character data and items.
|
||||
this._prepareItems(context);
|
||||
this._prepareNpcData(context);
|
||||
// Add roll data for TinyMCE editors.
|
||||
context.rollData = context.actor.getRollData();
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
//
|
||||
_prepareNpcData(context) {
|
||||
}
|
||||
|
||||
//
|
||||
_prepareItems(context) {
|
||||
const skills = [];
|
||||
const weapons = [];
|
||||
const hitlocs = [];
|
||||
const gears = [];
|
||||
const powers = [];
|
||||
const passions = [];
|
||||
const allegiances = [];
|
||||
const persTraits = [];
|
||||
const reputations = [];
|
||||
|
||||
|
||||
// Sort items by name - saves sorting all containers by name separately
|
||||
context.items.sort(function (a, b) {
|
||||
let x = a.name;
|
||||
let y = b.name;
|
||||
if (x < y) { return -1 };
|
||||
if (x > y) { return 1 };
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (let itm of context.items) {
|
||||
itm.img = itm.img || DEFAULT_TOKEN;
|
||||
if (itm.type === 'skill') {
|
||||
skills.push(itm);
|
||||
} else if (itm.type === 'passion') {
|
||||
passions.push(itm);
|
||||
} else if (itm.type === 'allegiance') {
|
||||
allegiances.push(itm);
|
||||
} else if (itm.type === 'reputation') {
|
||||
reputations.push(itm);
|
||||
} else if (itm.type === 'persTrait') {
|
||||
persTraits.push(itm);
|
||||
} else if (itm.type === 'weapon') {
|
||||
itm.system.ammoLoadedType = String(itm.system.ammoLoadedType ?? itm.system.ammoTag ?? "STD").toUpperCase()
|
||||
SLAAmmoTracker.applyDisplayReserves(this.actor, itm);
|
||||
if (itm.system.range3 != "") {
|
||||
itm.system.rangeName = itm.system.range1 + "/" + itm.system.range2 + "/" + itm.system.range3
|
||||
} else if (itm.system.range2 != "") {
|
||||
itm.system.rangeName = itm.system.range1 + "/" + itm.system.range2
|
||||
} else {
|
||||
itm.system.rangeName = itm.system.range1
|
||||
}
|
||||
|
||||
if (itm.system.specialDmg) {
|
||||
itm.system.dmgName = game.i18n.localize('BRP.special')
|
||||
} else if (itm.system.dmg3 != "") {
|
||||
itm.system.dmgName = itm.system.dmg1 + "/" + itm.system.dmg2 + "/" + itm.system.dmg3
|
||||
} else if (itm.system.dmg2 != "") {
|
||||
itm.system.dmgName = itm.system.dmg1 + "/" + itm.system.dmg2
|
||||
} else {
|
||||
itm.system.dmgName = itm.system.dmg1
|
||||
}
|
||||
weapons.push(itm);
|
||||
} else if (itm.type === 'hit-location') {
|
||||
hitlocs.push(itm);
|
||||
} else if (itm.type === 'gear') {
|
||||
gears.push(itm);
|
||||
} else if (["power", "magic", "mutation", "psychic", "sorcery", "super"].includes(itm.type)) {
|
||||
if (itm.type === 'super') {
|
||||
itm.system.impactHint = game.i18n.localize('BRP.level')
|
||||
itm.system.impactName = itm.system.level
|
||||
itm.system.impactDice = false
|
||||
} else if (itm.type === 'sorcery') {
|
||||
itm.system.impactHint = game.i18n.localize('BRP.level')
|
||||
itm.system.impactName = itm.system.currLvl
|
||||
itm.system.impactDice = false
|
||||
} if (itm.type === 'mutation') {
|
||||
itm.system.impactHint = game.i18n.localize('BRP.' + itm.system.impact)
|
||||
itm.system.impactDice = false
|
||||
if (itm.system.minor) {
|
||||
itm.system.impactName = game.i18n.localize('BRP.' + itm.system.impact + 'Abbr') + "(" + game.i18n.localize('BRP.minor') + ")"
|
||||
} else {
|
||||
itm.system.impactName = game.i18n.localize('BRP.' + itm.system.impact + 'Abbr') + "(" + game.i18n.localize('BRP.major') + ")"
|
||||
|
||||
}
|
||||
} else if (['psychic', 'magic'].includes(itm.type)) {
|
||||
if (itm.system.impact === 'damage') {
|
||||
itm.system.impactName = game.i18n.localize('BRP.damageAbbr') + ":" + itm.system.damage
|
||||
itm.system.impactHint = game.i18n.localize('BRP.damage')
|
||||
itm.system.impactDice = true
|
||||
} else if (itm.system.impact === 'healing') {
|
||||
itm.system.impactName = game.i18n.localize('BRP.healingAbbr') + ":" + itm.system.damage
|
||||
itm.system.impactHint = game.i18n.localize('BRP.healing')
|
||||
itm.system.impactDice = true
|
||||
} else if (itm.system.impact === 'other') {
|
||||
itm.system.impactName = game.i18n.localize('BRP.other')
|
||||
itm.system.impactHint = ""
|
||||
itm.system.impactDice = false
|
||||
}
|
||||
}
|
||||
powers.push(itm)
|
||||
}
|
||||
}
|
||||
|
||||
//Sort hitlocs by dice range
|
||||
hitlocs.sort(function (a, b) {
|
||||
let x = a.system.lowRoll;
|
||||
let y = b.system.highRoll;
|
||||
if (x < y) { return 1 };
|
||||
if (x > y) { return -1 };
|
||||
return 0;
|
||||
});
|
||||
|
||||
context.skills = skills
|
||||
context.weapons = weapons
|
||||
context.hitlocs = hitlocs
|
||||
context.gears = gears
|
||||
context.powers = powers
|
||||
context.passions = passions;
|
||||
context.allegiances = allegiances;
|
||||
context.persTraits = persTraits;
|
||||
context.reputations = reputations
|
||||
|
||||
context.usePowers = !this.actor.system.lock
|
||||
context.useAllegiances = !this.actor.system.lock
|
||||
context.usePassions = !this.actor.system.lock
|
||||
context.usePerTraits = !this.actor.system.lock
|
||||
context.useReputations = !this.actor.system.lock
|
||||
|
||||
if (context.passions.length > 0) { context.usePassions = true }
|
||||
if (context.allegiances.length > 0) { context.useAllegiances = true }
|
||||
if (context.powers.length > 0) { context.usePowers = true }
|
||||
if (context.persTraits.length > 0) { context.usePerTraits = true }
|
||||
if (context.reputations.length > 0) { context.useReputations = true }
|
||||
return context
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html);
|
||||
|
||||
html.find('.item-edit').click(ev => {
|
||||
const li = $(ev.currentTarget).closest(".item");
|
||||
const item = this.actor.items.get(li.data("itemId"));
|
||||
item.sheet.render(true);
|
||||
});
|
||||
|
||||
if (!this.isEditable) return;
|
||||
html.find(".actor-toggle").click(this._onActorToggle.bind(this)); // Actor Toggle
|
||||
html.find(".inline-edit").change(this._onSkillEdit.bind(this)); //Inline Skill Edit
|
||||
html.find('.item-create').click(this._onItemCreate.bind(this)); // Add Inventory Item
|
||||
html.find('.addPower').click(this._onPowerCreate.bind(this)); // Add Power
|
||||
html.find('.rollStats').click(this._onRollStats.bind(this)); // Roll Stats
|
||||
html.find('.rollable.charac-name').click(BRPRollType._onStatRoll.bind(this)); // Rollable Characteristic
|
||||
html.find('.rollable.skill-name').click(BRPRollType._onSkillRoll.bind(this)); // Rollable Skill
|
||||
html.find('.rollable.allegiance-name').click(BRPRollType._onAllegianceRoll.bind(this)); // Rollable Allegiance
|
||||
html.find('.rollable.passion-name').click(BRPRollType._onPassionRoll.bind(this)); // Rollable Passion
|
||||
html.find('.rollable.persTrait-name').click(BRPRollType._onPersTraitRoll.bind(this)); // Rollable Personal Trait
|
||||
html.find('.rollable.reputation-name').click(BRPRollType._onReputationRoll.bind(this)); // Rollable Personal Trait
|
||||
html.find('.rollable.weapon-name').click(BRPRollType._onWeaponRoll.bind(this)); // Weapon Skill Roll
|
||||
html.find('.rollable.damage-name').click(BRPRollType._onDamageRoll.bind(this)); // Damage Roll
|
||||
html.find('.rollable.ap-name').click(BRPRollType._onArmour.bind(this)); // Armour roll
|
||||
html.find('.hplHeal').click(this._resetHPL.bind(this)); // Reset Hit Loc HP to max.
|
||||
html.find('.rollable.impact').click(BRPRollType._onImpactRoll.bind(this)); // Magic Spell Impact Roll
|
||||
|
||||
// Drag events
|
||||
if (this.actor.isOwner) {
|
||||
let handler = ev => this._onDragStart(ev);
|
||||
html.find('li.item').each((i, li) => {
|
||||
if (li.classList.contains("inventory-header")) return;
|
||||
li.setAttribute("draggable", true);
|
||||
li.addEventListener("dragstart", handler, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete Inventory Item
|
||||
html.find('.item-delete').click(ev => {
|
||||
const li = $(ev.currentTarget).closest(".item");
|
||||
const item = this.actor.items.get(li.data("itemId"));
|
||||
item.delete();
|
||||
li.slideUp(200, () => this.render(false));
|
||||
});
|
||||
}
|
||||
|
||||
//Toggle Actor
|
||||
async _onActorToggle(event) {
|
||||
const prop = event.currentTarget.dataset.property;
|
||||
let checkProp = {};
|
||||
let checkVal = ""
|
||||
|
||||
if (['lock'].includes(prop)) {
|
||||
checkProp = { [`system.${prop}`]: !this.actor.system[prop] }
|
||||
} else if (['viewTab'].includes(prop)) {
|
||||
checkVal = event.currentTarget.dataset.tabval;
|
||||
checkProp = { [`system.${prop}`]: checkVal }
|
||||
} else {
|
||||
return
|
||||
}
|
||||
await this.actor.update(checkProp);
|
||||
return
|
||||
}
|
||||
|
||||
// Update skills etc without opening the item sheet
|
||||
async _onSkillEdit(event) {
|
||||
event.preventDefault();
|
||||
const element = event.currentTarget;
|
||||
const li = $(event.currentTarget).closest(".item");
|
||||
const item = this.actor.items.get(li.data("itemId"));
|
||||
let field = element.dataset.field;
|
||||
let newScore = element.value;
|
||||
if (['base', 'currHP', 'quantity', 'npcVal', 'allegPoints', 'hpCurr', 'ammoCurr', 'ammoReserve'].includes(field)) {
|
||||
newScore = Number(newScore)
|
||||
field = 'system.' + field;
|
||||
} else if (['displayName', 'ap', 'bap', 'apRnd', 'bapRnd', 'att'].includes(field)) {
|
||||
field = 'system.' + field;
|
||||
} else if (field === 'name') {
|
||||
} else { return }
|
||||
await item.update({ [field]: newScore });
|
||||
this.actor.render(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async _onAmmoAction(event) {
|
||||
event.preventDefault();
|
||||
const itemId = event.currentTarget.dataset.itemId;
|
||||
const action = event.currentTarget.dataset.ammoAction;
|
||||
if (!itemId || !action) return;
|
||||
|
||||
await SLAAmmoTracker.handleSheetAction({
|
||||
actor: this.actor,
|
||||
itemId,
|
||||
action
|
||||
});
|
||||
this.actor.render(false);
|
||||
}
|
||||
|
||||
// Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset
|
||||
async _onItemCreate(event) {
|
||||
event.preventDefault();
|
||||
const header = event.currentTarget;
|
||||
// Get the type of item to create.
|
||||
const type = header.dataset.type;
|
||||
// Grab any data associated with this control.
|
||||
const data = foundry.utils.duplicate(header.dataset);
|
||||
// Initialize a default name.
|
||||
const name = `zz-${type.capitalize()}`;
|
||||
// Prepare the item object.
|
||||
const itemData = {
|
||||
name: name,
|
||||
type: type,
|
||||
system: data
|
||||
};
|
||||
|
||||
if (type === 'hit-location') {
|
||||
itemData.system.displayName = name
|
||||
}
|
||||
|
||||
// Remove the type from the dataset since it's in the itemData.type prop.
|
||||
delete itemData.system["type"];
|
||||
|
||||
// Create the item!
|
||||
const newItem = await Item.create(itemData, { parent: this.actor });
|
||||
let key = await game.system.api.brpid.guessId(newItem)
|
||||
await newItem.update({
|
||||
'flags.brp.brpidFlag.id': key,
|
||||
'flags.brp.brpidFlag.lang': game.i18n.lang,
|
||||
'flags.brp.brpidFlag.priority': 0
|
||||
})
|
||||
|
||||
//And in certain circumstances render the new item sheet
|
||||
if (itemData.type === 'hit-location') {
|
||||
newItem.sheet.render(true);
|
||||
}
|
||||
}
|
||||
|
||||
//Add a power to NPC
|
||||
async _onPowerCreate(event) {
|
||||
let usage = await this._selectPower()
|
||||
let powerType = ""
|
||||
if (usage) {
|
||||
powerType = usage.get('selectPower')
|
||||
} else { return }
|
||||
const name = `zz-${powerType.capitalize()}`;
|
||||
// Prepare the item object.
|
||||
const itemData = {
|
||||
name: name,
|
||||
type: powerType,
|
||||
};
|
||||
// Create the item!
|
||||
const newItem = await Item.create(itemData, { parent: this.actor });
|
||||
newItem.sheet.render(true);
|
||||
}
|
||||
|
||||
//Get Power Type
|
||||
async _selectPower() {
|
||||
let data = ""
|
||||
let catOptions = await BRPSelectLists.getPowerCatOptions();
|
||||
data = {
|
||||
catOptions,
|
||||
}
|
||||
|
||||
const html = await foundry.applications.handlebars.renderTemplate('systems/sla-industries-brp/templates/dialog/selectPower.html', data)
|
||||
return new Promise(resolve => {
|
||||
let formData = null
|
||||
const dlg = new Dialog({
|
||||
title: "",
|
||||
content: html,
|
||||
buttons: {
|
||||
roll: {
|
||||
label: game.i18n.localize("BRP.proceed"),
|
||||
callback: html => {
|
||||
formData = new FormData(html[0].querySelector('#select-power-form'))
|
||||
return resolve(formData)
|
||||
}
|
||||
}
|
||||
},
|
||||
default: 'roll',
|
||||
close: () => { }
|
||||
})
|
||||
dlg.render(true)
|
||||
})
|
||||
}
|
||||
|
||||
//Roll Stats for NPC
|
||||
async _onRollStats(event) {
|
||||
//Roll Stats
|
||||
const element = event.currentTarget;
|
||||
let type = element.dataset.property;
|
||||
for (let [key, stat] of Object.entries(this.actor.system.baseStats)) {
|
||||
let rollForm = stat.average
|
||||
if (type === 'random') {
|
||||
rollForm = stat.random
|
||||
}
|
||||
let checkProp = ""
|
||||
if (rollForm === "") {
|
||||
checkProp = { [`system.stats.${key}.base`]: 0 }
|
||||
} else {
|
||||
let roll = new Roll(rollForm)
|
||||
await roll.evaluate()
|
||||
let newVal = Math.round(Number(roll.total))
|
||||
checkProp = { [`system.stats.${key}.base`]: newVal }
|
||||
}
|
||||
await this.actor.update(checkProp)
|
||||
//Update Current HP etc to max
|
||||
checkProp = {
|
||||
'system.health.value': this.actor.system.health.max,
|
||||
'system.power.value': this.actor.system.power.max,
|
||||
'system.fatigue.value': this.actor.system.fatigue.max,
|
||||
'system.sanity.value': this.actor.system.sanity.max
|
||||
}
|
||||
await this.actor.update(checkProp)
|
||||
}
|
||||
|
||||
//If using HPL set location Current HP to Max HP
|
||||
await this._resetHPL(event)
|
||||
}
|
||||
|
||||
async _resetHPL(event) {
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL')) {
|
||||
let hitLocs = this.actor.items.filter(itm => itm.type === 'hit-location').map(itm => {
|
||||
return { _id: itm.id, 'system.currHP': itm.system.maxHP }
|
||||
})
|
||||
await Item.updateDocuments(hitLocs, { parent: this.actor })
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export class BRPActiveEffect extends ActiveEffect {
|
||||
get active() {
|
||||
if (typeof this.origin === 'string') {
|
||||
let item
|
||||
if (this.parent.isToken && !this.parent.token.actorLink) {
|
||||
const match = this.origin.match(/\.([^\.]+)$/)
|
||||
if (match) {
|
||||
item = this.parent.items.get(match[1])
|
||||
}
|
||||
} else {
|
||||
item = fromUuidSync(this.origin)
|
||||
}
|
||||
if (item instanceof Item) {
|
||||
if (item.type === 'wound') {
|
||||
return super.active
|
||||
}
|
||||
if (item.type === 'armour' ? item.system.equipStatus !== 'worn' : item.system.equipStatus !== 'carried') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export class BRPactorDetails {
|
||||
|
||||
// Get token/actor ID and type
|
||||
static async _getParticipantId(token, actor) {
|
||||
let particId = ""
|
||||
let particType = ""
|
||||
let particName = ""
|
||||
if (!token) {
|
||||
particId = actor.id
|
||||
particType = "actor"
|
||||
particName = actor.name
|
||||
} else {
|
||||
let tokenId = token.uuid.split(".")[3]
|
||||
if (game.actors.tokens[tokenId]) {
|
||||
particId = tokenId
|
||||
particType = "token"
|
||||
particName = token.name
|
||||
} else {
|
||||
particId = token.actor.id
|
||||
particType = "actor"
|
||||
particName = token.actor.name
|
||||
}
|
||||
}
|
||||
|
||||
let partic = ({ particId, particType, particName })
|
||||
return partic;
|
||||
}
|
||||
|
||||
// Get token/actor - determine precedence
|
||||
static async _getParticipantPriority(token, actor) {
|
||||
let target = await this._getParticipantId(token, actor);
|
||||
let partic = await this._getParticipant(target.particId, target.particType);
|
||||
return partic;
|
||||
}
|
||||
|
||||
//Get actor from ID & type
|
||||
static async _getParticipant(particId, particType) {
|
||||
let actor = "";
|
||||
if (particType === "token") {
|
||||
actor = game.actors.tokens[particId];
|
||||
} else {
|
||||
actor = game.actors.get(particId)
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
//Get Id of target of attack etc
|
||||
static async _getTargetId() {
|
||||
let targetId = "";
|
||||
let targetType = "none";
|
||||
let targetName = "Dummy";
|
||||
if (game.user.targets.size > 0) {
|
||||
let target = Array.from(game.user.targets)
|
||||
targetName = target[0].document.name
|
||||
if (target[0].document.actorLink) {
|
||||
targetId = target[0].document.actorId;
|
||||
targetType = "actor";
|
||||
} else {
|
||||
targetId = target[0].id;
|
||||
targetType = "token";
|
||||
}
|
||||
}
|
||||
let result = ({ targetId, targetType, targetName });
|
||||
return result;
|
||||
}
|
||||
|
||||
//Get Actor Image for Id
|
||||
static async getParticImg(particId, particType) {
|
||||
if (!particId || !particType) return null
|
||||
const actor = await BRPactorDetails._getParticipant(particId, particType)
|
||||
let image = actor.img
|
||||
//If wildcard image then get that
|
||||
if (actor.prototypeToken.randomImg) {
|
||||
image = actor.token.texture.src
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* global foundry fromUuid game */
|
||||
import ChaosiumCanvasInterface from './chaosium-canvas-interface.mjs'
|
||||
|
||||
export default class ChaosiumCanvasInterfaceAmbientLightToggle extends ChaosiumCanvasInterface {
|
||||
static get actionToggles () {
|
||||
const buttons = super.actionToggles
|
||||
buttons[ChaosiumCanvasInterface.actionToggle.On] = 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Action.On'
|
||||
buttons[ChaosiumCanvasInterface.actionToggle.Off] = 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Action.Off'
|
||||
return buttons
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-regular fa-lightbulb'
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Button.Hint'
|
||||
}),
|
||||
action: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterfaceAmbientLightToggle.actionToggles,
|
||||
initial: ChaosiumCanvasInterface.actionToggle.Off,
|
||||
label: 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Action.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Action.Hint',
|
||||
required: true
|
||||
}),
|
||||
lightUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'AmbientLight'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Light.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.AmbientLightToggle.Light.Hint'
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
return game.user.isGM
|
||||
}
|
||||
|
||||
async #handleClickEvent () {
|
||||
let toggle = false
|
||||
switch (this.action) {
|
||||
case ChaosiumCanvasInterface.actionToggle.On:
|
||||
toggle = true
|
||||
break
|
||||
case ChaosiumCanvasInterface.actionToggle.Toggle:
|
||||
{
|
||||
const firstUuid = this.lightUuids.first()
|
||||
if (firstUuid) {
|
||||
const doc = await fromUuid(firstUuid)
|
||||
toggle = doc.hidden
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for (const uuid of this.lightUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ hidden: !toggle })
|
||||
} else {
|
||||
console.error('Ambient Light ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent () {
|
||||
if (this.triggerButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent () {
|
||||
if (this.triggerButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfaceDrawingToggle extends ChaosiumCanvasInterface {
|
||||
static get PERMISSIONS () {
|
||||
return {
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT]: 'OWNERSHIP.INHERIT',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE]: 'OWNERSHIP.NONE',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED]: 'OWNERSHIP.LIMITED',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: 'OWNERSHIP.OBSERVER',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER]: 'OWNERSHIP.OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-solid fa-pencil'
|
||||
}
|
||||
|
||||
static get triggerButtons () {
|
||||
const buttons = super.triggerButtons
|
||||
buttons[ChaosiumCanvasInterfaceDrawingToggle.triggerButton.Both] = 'BRP.ChaosiumCanvasInterface.Buttons.Both'
|
||||
return buttons
|
||||
}
|
||||
|
||||
static get triggerButton () {
|
||||
const button = super.triggerButton
|
||||
button.Both = 20
|
||||
return button
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterfaceDrawingToggle.triggerButtons,
|
||||
initial: ChaosiumCanvasInterfaceDrawingToggle.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Button.Hint'
|
||||
}),
|
||||
action: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.actionToggles,
|
||||
initial: ChaosiumCanvasInterface.actionToggle.Off,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Action.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Action.Hint',
|
||||
required: true
|
||||
}),
|
||||
drawingUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'Drawing'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Drawing.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Drawing.Hint'
|
||||
}
|
||||
),
|
||||
journalEntryUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'JournalEntry'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.JournalEntry.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.JournalEntry.Hint'
|
||||
}
|
||||
),
|
||||
permissionDocument: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionDocument.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionDocument.Hint',
|
||||
required: true
|
||||
}),
|
||||
permissionDocumentHide: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionDocumentHide.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionDocumentHide.Hint',
|
||||
required: true
|
||||
}),
|
||||
journalEntryPageUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'JournalEntryPage'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.JournalEntryPage.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.JournalEntryPage.Hint'
|
||||
}
|
||||
),
|
||||
permissionPage: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionPage.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionPage.Hint',
|
||||
required: true
|
||||
}),
|
||||
permissionPageHide: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceDrawingToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionPageHide.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.PermissionPageHide.Hint',
|
||||
required: true
|
||||
}),
|
||||
regionBehaviorUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'RegionBehavior'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.RegionBehavior.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.RegionBehavior.Hint'
|
||||
}
|
||||
),
|
||||
regionButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Right,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.TriggerButton.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.TriggerButton.Hint'
|
||||
}),
|
||||
regionUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'Region'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.TriggerRegionUuids.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.TriggerRegionUuids.Hint'
|
||||
}
|
||||
),
|
||||
triggerAsButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.TriggerAsButton.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.TriggerAsButton.Hint'
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
static migrateData (source) {
|
||||
if (typeof source.triggerButton === 'undefined' && source.regionUuids?.length) {
|
||||
source.triggerButton = ChaosiumCanvasInterfaceDrawingToggle.triggerButton.Both
|
||||
}
|
||||
if (typeof source.toggle !== 'undefined' && typeof source.action === 'undefined') {
|
||||
source.action = (source.toggle ? ChaosiumCanvasInterface.actionToggle.On : ChaosiumCanvasInterface.actionToggle.Off)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
return game.user.isGM
|
||||
}
|
||||
|
||||
async #handleClickEvent (button) {
|
||||
let toggle = false
|
||||
switch (this.action) {
|
||||
case ChaosiumCanvasInterface.actionToggle.On:
|
||||
toggle = true
|
||||
break
|
||||
case ChaosiumCanvasInterface.actionToggle.Toggle:
|
||||
{
|
||||
const firstUuid = this.drawingUuids.first()
|
||||
if (firstUuid) {
|
||||
const doc = await fromUuid(firstUuid)
|
||||
toggle = doc.hidden
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for (const uuid of this.drawingUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ hidden: !toggle })
|
||||
} else {
|
||||
console.error('Drawing ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
const permissionDocument = (!toggle ? this.permissionDocumentHide : this.permissionDocument)
|
||||
const permissionPage = (!toggle ? this.permissionPageHide : this.permissionPage)
|
||||
for (const uuid of this.journalEntryUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ 'ownership.default': permissionDocument })
|
||||
} else {
|
||||
console.error('Journal Entry ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
for (const uuid of this.journalEntryPageUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ 'ownership.default': permissionPage })
|
||||
} else {
|
||||
console.error('Journal Entry Page ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
for (const uuid of this.regionBehaviorUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ disabled: !toggle })
|
||||
} else {
|
||||
console.error('Region Behavior ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
if (this.triggerButton === ChaosiumCanvasInterfaceDrawingToggle.triggerButton.Both) {
|
||||
for (const uuid of this.regionUuids) {
|
||||
setTimeout(() => {
|
||||
if (button === this.regionButton) {
|
||||
if (this.triggerAsButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
game.brp.ClickRegionRightUuid(uuid)
|
||||
} else if (this.triggerAsButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
game.brp.ClickRegionLeftUuid(uuid)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent () {
|
||||
if ([ChaosiumCanvasInterfaceDrawingToggle.triggerButton.Both, ChaosiumCanvasInterface.triggerButton.Left].includes(this.triggerButton)) {
|
||||
this.#handleClickEvent(ChaosiumCanvasInterface.triggerButton.Left)
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent () {
|
||||
if ([ChaosiumCanvasInterfaceDrawingToggle.triggerButton.Both, ChaosiumCanvasInterface.triggerButton.Right].includes(this.triggerButton)) {
|
||||
this.#handleClickEvent(ChaosiumCanvasInterface.triggerButton.Right)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import ChaosiumCanvasInterfaceAmbientLightToggle from "./chaosium-canvas-interface-ambient-light-toggle.mjs";
|
||||
import ChaosiumCanvasInterfaceDrawingToggle from "./chaosium-canvas-interface-drawing-toggle.mjs";
|
||||
import ChaosiumCanvasInterfaceMapPinToggle from "./chaosium-canvas-interface-map-pin-toggle.mjs";
|
||||
import ChaosiumCanvasInterfaceOpenDocument from "./chaosium-canvas-interface-open-document.mjs";
|
||||
import ChaosiumCanvasInterfacePlaySound from "./chaosium-canvas-interface-play-sound.mjs";
|
||||
import ChaosiumCanvasInterfaceToScene from "./chaosium-canvas-interface-to-scene.mjs";
|
||||
import ChaosiumCanvasInterfaceTileToggle from "./chaosium-canvas-interface-tile-toggle.mjs";
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfaceInit extends ChaosiumCanvasInterface {
|
||||
static initSelf () {
|
||||
const known = [
|
||||
ChaosiumCanvasInterfaceAmbientLightToggle,
|
||||
ChaosiumCanvasInterfaceDrawingToggle,
|
||||
ChaosiumCanvasInterfaceMapPinToggle,
|
||||
ChaosiumCanvasInterfaceOpenDocument,
|
||||
ChaosiumCanvasInterfacePlaySound,
|
||||
ChaosiumCanvasInterfaceToScene,
|
||||
ChaosiumCanvasInterfaceTileToggle
|
||||
]
|
||||
|
||||
super.initSelf()
|
||||
const dataModels = {}
|
||||
const typeIcons = {}
|
||||
const types = []
|
||||
for (const cci of known) {
|
||||
const name = (new cci).constructor.name
|
||||
dataModels[name] = cci
|
||||
typeIcons[name] = cci.icon
|
||||
types.push(name)
|
||||
}
|
||||
|
||||
Object.assign(CONFIG.RegionBehavior.dataModels, dataModels)
|
||||
|
||||
Object.assign(CONFIG.RegionBehavior.typeIcons, typeIcons)
|
||||
|
||||
foundry.applications.apps.DocumentSheetConfig.registerSheet(
|
||||
RegionBehavior,
|
||||
'brp',
|
||||
foundry.applications.sheets.RegionBehaviorConfig,
|
||||
{
|
||||
types: types,
|
||||
makeDefault: true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfaceMapPinToggle extends ChaosiumCanvasInterface {
|
||||
static get PERMISSIONS () {
|
||||
return {
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT]: 'OWNERSHIP.INHERIT',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE]: 'OWNERSHIP.NONE',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED]: 'OWNERSHIP.LIMITED',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: 'OWNERSHIP.OBSERVER',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER]: 'OWNERSHIP.OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-solid fa-map-pin'
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Button.Hint'
|
||||
}),
|
||||
action: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.actionToggles,
|
||||
initial: ChaosiumCanvasInterface.actionToggle.Off,
|
||||
label: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Action.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Action.Hint',
|
||||
required: true
|
||||
}),
|
||||
noteUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'Note'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Note.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Note.Hint'
|
||||
}
|
||||
),
|
||||
documentUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Document.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.MapPinToggle.Document.Hint'
|
||||
}
|
||||
),
|
||||
permissionShow: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceMapPinToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceMapPinToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER,
|
||||
label: 'BRP.ChaosiumCanvasInterface.MapPinToggle.PermissionShow.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.MapPinToggle.PermissionShow.Hint',
|
||||
required: true
|
||||
}),
|
||||
permissionHide: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceMapPinToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceMapPinToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
label: 'BRP.ChaosiumCanvasInterface.MapPinToggle.PermissionHide.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.MapPinToggle.PermissionHide.Hint',
|
||||
required: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static migrateData (source) {
|
||||
if (typeof source.toggle !== 'undefined' && typeof source.action === 'undefined') {
|
||||
source.action = (source.toggle ? ChaosiumCanvasInterface.actionToggle.On : ChaosiumCanvasInterface.actionToggle.Off)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
return game.user.isGM
|
||||
}
|
||||
|
||||
async #handleClickEvent () {
|
||||
game.socket.emit('system.sla-industries-brp', { type: 'toggleMapNotes', toggle: true })
|
||||
game.settings.set('core', foundry.canvas.layers.NotesLayer.TOGGLE_SETTING, true)
|
||||
let toggle = false
|
||||
switch (this.action) {
|
||||
case ChaosiumCanvasInterface.actionToggle.On:
|
||||
toggle = true
|
||||
break
|
||||
case ChaosiumCanvasInterface.actionToggle.Toggle:
|
||||
{
|
||||
const firstUuid = this.documentUuids.first()
|
||||
if (firstUuid) {
|
||||
const doc = await fromUuid(firstUuid)
|
||||
toggle = doc.ownership.default === this.permissionHide
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for (const uuid of this.documentUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
const permission = (toggle ? this.permissionShow : this.permissionHide)
|
||||
await doc.update({ 'ownership.default': permission })
|
||||
} else {
|
||||
console.error('Document ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
for (const uuid of this.noteUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
const texture = (toggle ? 'systems/sla-industries-brp/assets/map-pin.svg' : 'systems/sla-industries-brp/assets/map-pin-dark.svg')
|
||||
await doc.update({ 'texture.src': texture })
|
||||
} else {
|
||||
console.error('Note ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent () {
|
||||
if (this.triggerButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent () {
|
||||
if (this.triggerButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfaceOpenDocument extends ChaosiumCanvasInterface {
|
||||
static get PERMISSIONS () {
|
||||
return {
|
||||
ALWAYS: 'BRP.ChaosiumCanvasInterface.Permission.Always',
|
||||
DOCUMENT: 'BRP.ChaosiumCanvasInterface.Permission.Document',
|
||||
SEE_TILE: 'BRP.ChaosiumCanvasInterface.Permission.SeeTile',
|
||||
GM: 'BRP.ChaosiumCanvasInterface.Permission.GM'
|
||||
}
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-solid fa-book-atlas'
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.OpenDocument.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.OpenDocument.Button.Hint'
|
||||
}),
|
||||
permission: new fields.StringField({
|
||||
blank: false,
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceOpenDocument.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceOpenDocument.PERMISSIONS[k]); return c }, {}),
|
||||
initial: 'GM',
|
||||
label: 'BRP.ChaosiumCanvasInterface.OpenDocument.Permission.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.OpenDocument.Permission.Hint',
|
||||
required: true
|
||||
}),
|
||||
documentUuid: new fields.DocumentUUIDField({
|
||||
label: 'BRP.ChaosiumCanvasInterface.OpenDocument.Document.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.OpenDocument.Document.Hint'
|
||||
}),
|
||||
anchor: new fields.StringField({
|
||||
initial: '',
|
||||
label: 'BRP.ChaosiumCanvasInterface.OpenDocument.Anchor.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.OpenDocument.Anchor.Hint'
|
||||
}),
|
||||
tileUuid: new fields.DocumentUUIDField({
|
||||
label: 'BRP.ChaosiumCanvasInterface.OpenDocument.Tile.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.OpenDocument.Tile.Hint',
|
||||
type: 'Tile'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
switch (this.permission) {
|
||||
case 'ALWAYS':
|
||||
return true
|
||||
case 'GM':
|
||||
return game.user.isGM
|
||||
case 'DOCUMENT':
|
||||
if (game.user.isGM) {
|
||||
return true
|
||||
}
|
||||
if (this.documentUuid) {
|
||||
return (await fromUuid(this.documentUuid)).testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED) ?? false
|
||||
}
|
||||
break
|
||||
case 'SEE_TILE':
|
||||
if (game.user.isGM) {
|
||||
return true
|
||||
}
|
||||
if (this.tileUuid && this.documentUuid) {
|
||||
return !(await fromUuid(this.tileUuid)).hidden && ((await fromUuid(this.documentUuid)).testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED) ?? false)
|
||||
}
|
||||
break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async #handleClickEvent () {
|
||||
const doc = await fromUuid(this.documentUuid)
|
||||
if (doc?.testUserPermission(game.user, CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED)) {
|
||||
if (doc instanceof JournalEntryPage) {
|
||||
doc.parent.sheet.render(true, { pageId: doc.id, anchor: this.anchor })
|
||||
} else {
|
||||
doc.sheet.render(true)
|
||||
}
|
||||
} else {
|
||||
console.error('Document ' + this.documentUuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent () {
|
||||
if (this.documentUuid && this.triggerButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent () {
|
||||
if (this.documentUuid && this.triggerButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfacePlaySound extends ChaosiumCanvasInterface {
|
||||
static get actionToggles () {
|
||||
const buttons = super.actionToggles
|
||||
buttons[ChaosiumCanvasInterface.actionToggle.On] = 'BRP.ChaosiumCanvasInterface.PlaySound.Action.Play'
|
||||
buttons[ChaosiumCanvasInterface.actionToggle.Off] = 'BRP.ChaosiumCanvasInterface.PlaySound.Action.Stop'
|
||||
return buttons
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-solid fa-music'
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.PlaySound.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.PlaySound.Button.Hint'
|
||||
}),
|
||||
action: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterfacePlaySound.actionToggles,
|
||||
initial: ChaosiumCanvasInterface.actionToggle.Off,
|
||||
label: 'BRP.ChaosiumCanvasInterface.PlaySound.Action.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.PlaySound.Action.Hint',
|
||||
required: true
|
||||
}),
|
||||
playlistUuid: new fields.DocumentUUIDField({
|
||||
label: 'BRP.ChaosiumCanvasInterface.PlaySound.Playlist.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.PlaySound.Playlist.Hint',
|
||||
type: 'Playlist'
|
||||
}),
|
||||
soundUuid: new fields.DocumentUUIDField({
|
||||
label: 'BRP.ChaosiumCanvasInterface.PlaySound.Sound.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.PlaySound.Sound.Hint',
|
||||
type: 'PlaylistSound'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
static migrateData (source) {
|
||||
if (typeof source.toggle !== 'undefined' && typeof source.action === 'undefined') {
|
||||
source.action = (source.toggle ? ChaosiumCanvasInterface.actionToggle.On : ChaosiumCanvasInterface.actionToggle.Off)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
return game.user.isGM
|
||||
}
|
||||
|
||||
async #handleClickEvent () {
|
||||
let toggle = false
|
||||
switch (this.action) {
|
||||
case ChaosiumCanvasInterface.actionToggle.On:
|
||||
toggle = true
|
||||
break
|
||||
case ChaosiumCanvasInterface.actionToggle.Toggle:
|
||||
{
|
||||
const firstUuid = this.playlistUuid ?? this.soundUuid
|
||||
if (firstUuid) {
|
||||
const doc = await fromUuid(firstUuid)
|
||||
toggle = !doc.playing
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
if (this.playlistUuid) {
|
||||
const playList = await fromUuid(this.playlistUuid)
|
||||
if (playList) {
|
||||
if (toggle) {
|
||||
playList.playAll()
|
||||
} else {
|
||||
playList.stopAll()
|
||||
}
|
||||
} else {
|
||||
console.error('Playlist ' + this.sceneUuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
if (this.soundUuid) {
|
||||
const sound = await fromUuid(this.soundUuid)
|
||||
if (sound) {
|
||||
if (toggle) {
|
||||
sound.parent.playSound(sound)
|
||||
} else {
|
||||
sound.parent.stopSound(sound)
|
||||
}
|
||||
} else {
|
||||
console.error('Sound ' + this.sceneUuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent () {
|
||||
if ((this.playlistUuid || this.soundUuid) && this.triggerButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent () {
|
||||
if ((this.playlistUuid || this.soundUuid) && this.triggerButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfaceTileToggle extends ChaosiumCanvasInterface {
|
||||
static get PERMISSIONS () {
|
||||
return {
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.INHERIT]: 'OWNERSHIP.INHERIT',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE]: 'OWNERSHIP.NONE',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.LIMITED]: 'OWNERSHIP.LIMITED',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER]: 'OWNERSHIP.OBSERVER',
|
||||
[CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER]: 'OWNERSHIP.OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-solid fa-cubes'
|
||||
}
|
||||
|
||||
static get triggerButtons () {
|
||||
const buttons = super.triggerButtons
|
||||
buttons[ChaosiumCanvasInterfaceTileToggle.triggerButton.Both] = 'BRP.ChaosiumCanvasInterface.Buttons.Both'
|
||||
return buttons
|
||||
}
|
||||
|
||||
static get triggerButton () {
|
||||
const button = super.triggerButton
|
||||
button.Both = 20
|
||||
return button
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterfaceTileToggle.triggerButtons,
|
||||
initial: ChaosiumCanvasInterfaceTileToggle.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.Button.Hint'
|
||||
}),
|
||||
action: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.actionToggles,
|
||||
initial: ChaosiumCanvasInterface.actionToggle.Off,
|
||||
label: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Action.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.DrawingToggle.Action.Hint',
|
||||
required: true
|
||||
}),
|
||||
tileUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'Tile'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.Tile.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.Tile.Hint'
|
||||
}
|
||||
),
|
||||
journalEntryUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'JournalEntry'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.JournalEntry.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.JournalEntry.Hint'
|
||||
}
|
||||
),
|
||||
permissionDocument: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionDocument.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionDocument.Hint',
|
||||
required: true
|
||||
}),
|
||||
permissionDocumentHide: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionDocumentHide.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionDocumentHide.Hint',
|
||||
required: true
|
||||
}),
|
||||
journalEntryPageUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'JournalEntryPage'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.JournalEntryPage.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.JournalEntryPage.Hint'
|
||||
}
|
||||
),
|
||||
permissionPage: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionPage.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionPage.Hint',
|
||||
required: true
|
||||
}),
|
||||
permissionPageHide: new fields.NumberField({
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceTileToggle.PERMISSIONS[k]); return c }, {}),
|
||||
initial: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionPageHide.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.PermissionPageHide.Hint',
|
||||
required: true
|
||||
}),
|
||||
regionBehaviorUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'RegionBehavior'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.RegionBehavior.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.RegionBehavior.Hint'
|
||||
}
|
||||
),
|
||||
regionButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Right,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.TriggerButton.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.TriggerButton.Hint'
|
||||
}),
|
||||
regionUuids: new fields.SetField(
|
||||
new fields.DocumentUUIDField({
|
||||
type: 'Region'
|
||||
}),
|
||||
{
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.TriggerRegionUuids.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.TriggerRegionUuids.Hint'
|
||||
}
|
||||
),
|
||||
triggerAsButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.TileToggle.TriggerAsButton.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.TileToggle.TriggerAsButton.Hint'
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
static migrateData (source) {
|
||||
if (typeof source.triggerButton === 'undefined' && source.regionUuids?.length) {
|
||||
source.triggerButton = ChaosiumCanvasInterfaceTileToggle.triggerButton.Both
|
||||
}
|
||||
if (typeof source.toggle !== 'undefined' && typeof source.action === 'undefined') {
|
||||
source.action = (source.toggle ? ChaosiumCanvasInterface.actionToggle.On : ChaosiumCanvasInterface.actionToggle.Off)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
return game.user.isGM
|
||||
}
|
||||
|
||||
async #handleClickEvent (button) {
|
||||
let toggle = false
|
||||
switch (this.action) {
|
||||
case ChaosiumCanvasInterface.actionToggle.On:
|
||||
toggle = true
|
||||
break
|
||||
case ChaosiumCanvasInterface.actionToggle.Toggle:
|
||||
{
|
||||
const firstUuid = this.tileUuids.first()
|
||||
if (firstUuid) {
|
||||
const doc = await fromUuid(firstUuid)
|
||||
toggle = doc.hidden
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for (const uuid of this.tileUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ hidden: !toggle })
|
||||
} else {
|
||||
console.error('Tile ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
const permissionDocument = (!toggle ? this.permissionDocumentHide : this.permissionDocument)
|
||||
const permissionPage = (!toggle ? this.permissionPageHide : this.permissionPage)
|
||||
for (const uuid of this.journalEntryUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ 'ownership.default': permissionDocument })
|
||||
} else {
|
||||
console.error('Journal Entry ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
for (const uuid of this.journalEntryPageUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ 'ownership.default': permissionPage })
|
||||
} else {
|
||||
console.error('Journal Entry Page ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
for (const uuid of this.regionBehaviorUuids) {
|
||||
const doc = await fromUuid(uuid)
|
||||
if (doc) {
|
||||
await doc.update({ disabled: !toggle })
|
||||
} else {
|
||||
console.error('Region Behavior ' + uuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
if (this.triggerButton === ChaosiumCanvasInterfaceTileToggle.triggerButton.Both) {
|
||||
for (const uuid of this.regionUuids) {
|
||||
setTimeout(() => {
|
||||
if (button === this.regionButton) {
|
||||
if (this.triggerAsButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
game.brp.ClickRegionRightUuid(uuid)
|
||||
} else if (this.triggerAsButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
game.brp.ClickRegionLeftUuid(uuid)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent() {
|
||||
if ([ChaosiumCanvasInterfaceTileToggle.triggerButton.Both, ChaosiumCanvasInterface.triggerButton.Left].includes(this.triggerButton)) {
|
||||
this.#handleClickEvent(ChaosiumCanvasInterface.triggerButton.Left)
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent() {
|
||||
if ([ChaosiumCanvasInterfaceTileToggle.triggerButton.Both, ChaosiumCanvasInterface.triggerButton.Right].includes(this.triggerButton)) {
|
||||
this.#handleClickEvent(ChaosiumCanvasInterface.triggerButton.Right)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import ChaosiumCanvasInterface from "./chaosium-canvas-interface.mjs";
|
||||
|
||||
export default class ChaosiumCanvasInterfaceToScene extends ChaosiumCanvasInterface {
|
||||
static get PERMISSIONS () {
|
||||
return {
|
||||
ALWAYS: 'BRP.ChaosiumCanvasInterface.Permission.Always',
|
||||
GM: 'BRP.ChaosiumCanvasInterface.Permission.GM',
|
||||
SEE_TILE: 'BRP.ChaosiumCanvasInterface.Permission.SeeTile'
|
||||
}
|
||||
}
|
||||
|
||||
static get icon () {
|
||||
return 'fa-solid fa-map'
|
||||
}
|
||||
|
||||
static defineSchema () {
|
||||
const fields = foundry.data.fields
|
||||
return {
|
||||
triggerButton: new fields.NumberField({
|
||||
choices: ChaosiumCanvasInterface.triggerButtons,
|
||||
initial: ChaosiumCanvasInterface.triggerButton.Left,
|
||||
label: 'BRP.ChaosiumCanvasInterface.ToScene.Button.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.ToScene.Button.Hint'
|
||||
}),
|
||||
permission: new fields.StringField({
|
||||
blank: false,
|
||||
choices: Object.keys(ChaosiumCanvasInterfaceToScene.PERMISSIONS).reduce((c, k) => { c[k] = game.i18n.localize(ChaosiumCanvasInterfaceToScene.PERMISSIONS[k]); return c }, {}),
|
||||
initial: 'GM',
|
||||
label: 'BRP.ChaosiumCanvasInterface.ToScene.Permission.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.ToScene.Permission.Hint',
|
||||
required: true
|
||||
}),
|
||||
sceneUuid: new fields.DocumentUUIDField({
|
||||
label: 'BRP.ChaosiumCanvasInterface.ToScene.Scene.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.ToScene.Scene.Hint',
|
||||
type: 'Scene'
|
||||
}),
|
||||
tileUuid: new fields.DocumentUUIDField({
|
||||
label: 'BRP.ChaosiumCanvasInterface.ToScene.Tile.Title',
|
||||
hint: 'BRP.ChaosiumCanvasInterface.ToScene.Tile.Hint',
|
||||
type: 'Tile'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async _handleMouseOverEvent () {
|
||||
switch (this.permission) {
|
||||
case 'ALWAYS':
|
||||
return true
|
||||
case 'GM':
|
||||
return game.user.isGM
|
||||
case 'SEE_TILE':
|
||||
if (game.user.isGM) {
|
||||
return true
|
||||
}
|
||||
if (this.tileUuid) {
|
||||
return !(await fromUuid(this.tileUuid)).hidden
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async #handleClickEvent () {
|
||||
const doc = await fromUuid(this.sceneUuid)
|
||||
if (doc) {
|
||||
setTimeout(() => {
|
||||
doc.view()
|
||||
}, 100)
|
||||
} else {
|
||||
console.error('Scene ' + this.sceneUuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
|
||||
async _handleLeftClickEvent() {
|
||||
if (this.sceneUuid && this.triggerButton === ChaosiumCanvasInterface.triggerButton.Left) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRightClickEvent() {
|
||||
if (this.sceneUuid && this.triggerButton === ChaosiumCanvasInterface.triggerButton.Right) {
|
||||
this.#handleClickEvent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export default class ChaosiumCanvasInterface extends foundry.data.regionBehaviors.RegionBehaviorType {
|
||||
static get actionToggles () {
|
||||
return {
|
||||
[ChaosiumCanvasInterface.actionToggle.On]: 'BRP.ChaosiumCanvasInterface.Actions.Show',
|
||||
[ChaosiumCanvasInterface.actionToggle.Off]: 'BRP.ChaosiumCanvasInterface.Actions.Hide',
|
||||
[ChaosiumCanvasInterface.actionToggle.Toggle]: 'BRP.ChaosiumCanvasInterface.Actions.Toggle'
|
||||
}
|
||||
}
|
||||
|
||||
static get actionToggle () {
|
||||
return {
|
||||
Off: 0,
|
||||
On: 1,
|
||||
Toggle: 2
|
||||
}
|
||||
}
|
||||
|
||||
static get triggerButtons () {
|
||||
return {
|
||||
[ChaosiumCanvasInterface.triggerButton.Left]: 'BRP.ChaosiumCanvasInterface.Buttons.Left',
|
||||
[ChaosiumCanvasInterface.triggerButton.Right]: 'BRP.ChaosiumCanvasInterface.Buttons.Right'
|
||||
}
|
||||
}
|
||||
|
||||
static get triggerButton () {
|
||||
return {
|
||||
Left: 0,
|
||||
Right: 2
|
||||
}
|
||||
}
|
||||
|
||||
static initSelf () {
|
||||
const oldOnClickLeft = foundry.canvas.layers.TokenLayer.prototype._onClickLeft
|
||||
foundry.canvas.layers.TokenLayer.prototype._onClickLeft = function (event) {
|
||||
oldOnClickLeft.call(this, event)
|
||||
if (canvas.activeLayer instanceof foundry.canvas.layers.TokenLayer) {
|
||||
const destination = canvas.activeLayer.toLocal(event)
|
||||
for (const region of canvas.scene.regions.contents) {
|
||||
if (region.behaviors.filter(b => !b.disabled).find(b => b.system instanceof ChaosiumCanvasInterface) && region.object.document.polygonTree.testPoint(destination)) {
|
||||
region.behaviors.filter(b => !b.disabled).map(async (b) => { if (await b.system._handleMouseOverEvent() === true && typeof b.system._handleLeftClickEvent === 'function') { await b.system._handleLeftClickEvent() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldOnClickRight = foundry.canvas.layers.TokenLayer.prototype._onClickRight
|
||||
foundry.canvas.layers.TokenLayer.prototype._onClickRight = function (event) {
|
||||
oldOnClickRight.call(this, event)
|
||||
if (canvas.activeLayer instanceof foundry.canvas.layers.TokenLayer) {
|
||||
const destination = canvas.activeLayer.toLocal(event)
|
||||
for (const region of canvas.scene.regions.contents) {
|
||||
if (region.behaviors.filter(b => !b.disabled).find(b => b.system instanceof ChaosiumCanvasInterface) && region.object.document.polygonTree.testPoint(destination)) {
|
||||
region.behaviors.filter(b => !b.disabled).map(async (b) => { if (await b.system._handleMouseOverEvent() === true && typeof b.system._handleRightClickEvent === 'function') { await b.system._handleRightClickEvent() } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener('mousemove', async function (event) {
|
||||
if (canvas.activeLayer instanceof foundry.canvas.layers.TokenLayer) {
|
||||
const pointer = canvas?.app?.renderer?.events?.pointer
|
||||
if (!pointer) {
|
||||
return
|
||||
}
|
||||
const destination = canvas.activeLayer.toLocal(event)
|
||||
let setPointer = false
|
||||
for (const region of canvas.scene.regions.contents) {
|
||||
if (region.behaviors.filter(b => !b.disabled).find(b => b.system instanceof ChaosiumCanvasInterface) && region.object.document.polygonTree.testPoint(destination)) {
|
||||
setPointer = await region.behaviors.filter(b => !b.disabled).reduce(async (c, b) => {
|
||||
const r = await b.system._handleMouseOverEvent()
|
||||
if (r !== false && r !== true) {
|
||||
console.error(b.uuid + ' did not return a boolean')
|
||||
}
|
||||
c = c || r
|
||||
return c
|
||||
}, false)
|
||||
}
|
||||
}
|
||||
if (setPointer) {
|
||||
document.getElementById('board').style.cursor = 'pointer'
|
||||
} else {
|
||||
document.getElementById('board').style.cursor = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async ClickRegionLeftUuid (docUuid) {
|
||||
const doc = await fromUuid(docUuid)
|
||||
if (doc) {
|
||||
doc.behaviors.filter(b => !b.disabled).filter(b => b.system instanceof ChaosiumCanvasInterface).map(async (b) => { if (await b.system._handleMouseOverEvent() === true && typeof b.system._handleLeftClickEvent === 'function') { await b.system._handleLeftClickEvent() } })
|
||||
} else {
|
||||
console.error('RegionUuid ' + docUuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
|
||||
static async ClickRegionRightUuid (docUuid) {
|
||||
const doc = await fromUuid(docUuid)
|
||||
if (doc) {
|
||||
doc.behaviors.filter(b => !b.disabled).filter(b => b.system instanceof ChaosiumCanvasInterface).map(async (b) => { if (await b.system._handleMouseOverEvent() === true && typeof b.system._handleRightClickEvent === 'function') { await b.system._handleRightClickEvent() } })
|
||||
} else {
|
||||
console.error('RegionUuid ' + docUuid + ' not loaded')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { BRPactorDetails } from './actorDetails.mjs'
|
||||
import { OPCard } from "../cards/opposed-card.mjs"
|
||||
|
||||
export class BRPCharDev {
|
||||
|
||||
//Toggle GM Devevlopment Phase off and on
|
||||
static async developmentPhase(toggle) {
|
||||
let state = await game.settings.get('sla-industries-brp', 'development')
|
||||
await game.settings.set('sla-industries-brp', 'development', !state)
|
||||
ui.notifications.info(
|
||||
state
|
||||
? game.i18n.localize('BRP.devPhaseOff')
|
||||
: game.i18n.localize('BRP.devPhaseOn')
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
//Do XP improve for all items in actor
|
||||
static async onXPGainAll(actor, token, rollType) {
|
||||
let success = []
|
||||
let results = {}
|
||||
let partic = await BRPactorDetails._getParticipantPriority(token, actor);
|
||||
for (let i of partic.items) {
|
||||
if (i.system.improve) {
|
||||
results = await BRPCharDev.xpCheck(actor, token, i._id, rollType, 'false');
|
||||
success.push(results)
|
||||
}
|
||||
if (i.type === "persTrait") {
|
||||
if (i.system.oppimprove) {
|
||||
results = await BRPCharDev.xpCheck(actor, token, i._id, rollType, 'true');
|
||||
success.push(results)
|
||||
}
|
||||
}
|
||||
}
|
||||
await BRPCharDev.xpOutput(success, partic)
|
||||
return;
|
||||
}
|
||||
|
||||
//Prep a single XP check
|
||||
static async onXPGainSingle(itemId, actor, token, rollType, opp) {
|
||||
let success = []
|
||||
let results = {}
|
||||
let partic = await BRPactorDetails._getParticipantPriority(token, actor);
|
||||
let item = partic.items.get(itemId)
|
||||
if (opp === 'false' && !item.system.improve) { return }
|
||||
if (opp === 'true' && !item.system.oppimprove) { return }
|
||||
results = await BRPCharDev.xpCheck(actor, token, itemId, rollType, opp);
|
||||
success.push(results)
|
||||
await BRPCharDev.xpOutput(success, partic)
|
||||
return;
|
||||
}
|
||||
|
||||
//Make XP check and if appropriate calc/roll XP gain
|
||||
static async xpCheck(actor, token, itemId, type, opp) {
|
||||
let partic = await BRPactorDetails._getParticipantPriority(token, actor);
|
||||
let improvVal = 0
|
||||
let item = partic.items.get(itemId)
|
||||
let name = item.name
|
||||
let score = item.system.total
|
||||
//Exclude system.effects from the improvement score
|
||||
if (['skill', 'magic', 'psychic'].includes(item.type)) {
|
||||
score = score - item.system.effects
|
||||
}
|
||||
|
||||
if (opp === 'true') {
|
||||
score = item.system.opptotal
|
||||
name = item.system.oppName
|
||||
}
|
||||
|
||||
if (opp === 'false' && !item.system.improve) { return }
|
||||
if (opp === 'true' && !item.system.oppimprove) { return }
|
||||
let result = await BRPCharDev.xpRoll(score, actor.system.xpBonus)
|
||||
if (result.level === 1) {
|
||||
if (type === 'fixed') {
|
||||
improvVal = game.settings.get('sla-industries-brp', 'xpFixed')
|
||||
} else {
|
||||
let roll = new Roll(game.settings.get('sla-industries-brp', 'xpFormula'))
|
||||
await roll.evaluate();
|
||||
improvVal = roll.total
|
||||
}
|
||||
}
|
||||
if (opp === 'false') {
|
||||
await item.update({
|
||||
'system.improve': false,
|
||||
'system.xp': item.system.xp + improvVal
|
||||
})
|
||||
} else {
|
||||
await item.update({
|
||||
'system.oppimprove': false,
|
||||
'system.xp': item.system.xp - improvVal
|
||||
})
|
||||
|
||||
}
|
||||
return ({
|
||||
name: name,
|
||||
level: result.level,
|
||||
diceRoll: result.diceRoll,
|
||||
rollResult: result.rollResult,
|
||||
score: score,
|
||||
improvVal,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
//XP Check Dice Roll & return success level
|
||||
static async xpRoll(target, bonus) {
|
||||
let resultLevel = 0
|
||||
let roll = new Roll('1D100')
|
||||
await roll.evaluate();
|
||||
target = Math.min(target, 100)
|
||||
if (Number(roll.total) + bonus > target) {
|
||||
resultLevel = 1
|
||||
}
|
||||
//If Game Settings and Dice So Nice then show dice roll
|
||||
if (game.settings.get('sla-industries-brp', 'xpRollDice') && game.modules.get('dice-so-nice')?.active) {
|
||||
game.dice3d.showForRoll(roll, game.user, true, null, false) //Roll,user,sync,whispher,blind
|
||||
}
|
||||
|
||||
return ({
|
||||
'level': resultLevel,
|
||||
'diceRoll': Number(roll.total) + bonus,
|
||||
'rollResult': Number(roll.total)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
//Get ready to produce the XP check Output
|
||||
static async xpOutput(success, partic) {
|
||||
const html = await BRPCharDev.xpChatCard(success, partic.name, partic.img);
|
||||
let msg = await BRPCharDev.showXPChat(html, partic)
|
||||
}
|
||||
|
||||
|
||||
//Prepare XP Roll Chat Card
|
||||
static async xpChatCard(success, actorName, actrImg) {
|
||||
let messageData = {
|
||||
speaker: ChatMessage.getSpeaker({ actor: actorName }),
|
||||
actrImage: actrImg,
|
||||
success: success
|
||||
}
|
||||
const messageTemplate = 'systems/sla-industries-brp/templates/chat/XP-result.html'
|
||||
let html = await foundry.applications.handlebars.renderTemplate(messageTemplate, messageData);
|
||||
return html;
|
||||
}
|
||||
|
||||
|
||||
// Display the XP chat card
|
||||
static async showXPChat(html, actor) {
|
||||
let chatData = {};
|
||||
let chatType = ""
|
||||
if (!foundry.utils.isNewerVersion(game.version, '11')) {
|
||||
chatType = CONST.CHAT_MESSAGE_STYLES.OTHER
|
||||
} else {
|
||||
chatType = CONST.CHAT_MESSAGE_OTHER
|
||||
}
|
||||
|
||||
|
||||
|
||||
chatData = {
|
||||
user: game.user.id,
|
||||
type: chatType,
|
||||
content: html,
|
||||
speaker: {
|
||||
actor: actor._id,
|
||||
alias: actor.name,
|
||||
},
|
||||
}
|
||||
let msg = await ChatMessage.create(chatData);
|
||||
foundry.audio.AudioHelper.play({ src: CONFIG.sounds.dice }, true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
//POW Improvement Check
|
||||
static async powImprov(actor, token, type) {
|
||||
let partic = await BRPactorDetails._getParticipantPriority(token, actor);
|
||||
let max = 21 //TODO - replace this with variable based on culture
|
||||
let score = partic.system.stats.pow.total
|
||||
let chance = (max - score) * 5
|
||||
let resultLevel = 0
|
||||
let roll = new Roll('1D100')
|
||||
let improvVal = 0
|
||||
await roll.evaluate();
|
||||
//If Game Settings and Dice So Nice then show dice roll
|
||||
if (game.settings.get('sla-industries-brp', 'xpRollDice') && game.modules.get('dice-so-nice')?.active) {
|
||||
game.dice3d.showForRoll(roll, game.user, true, null, false) //Roll,user,sync,whispher,blind
|
||||
}
|
||||
|
||||
if (roll.total <= chance) {
|
||||
resultLevel = 1
|
||||
if (type === "fixed") {
|
||||
improvVal = 1
|
||||
} else if (type != "fixed") {
|
||||
let impRoll = new Roll('1D3-1')
|
||||
await impRoll.evaluate()
|
||||
improvVal = impRoll.total
|
||||
//If Game Settings and Dice So Nice then show dice roll
|
||||
if (game.settings.get('sla-industries-brp', 'xpRollDice') && game.modules.get('dice-so-nice')?.active) {
|
||||
game.dice3d.showForRoll(impRoll, game.user, true, null, false) //Roll,user,sync,whispher,blind
|
||||
}
|
||||
}
|
||||
}
|
||||
let success = []
|
||||
success.push({
|
||||
name: game.i18n.localize('BRP.StatsPowAbbr'),
|
||||
level: resultLevel,
|
||||
diceRoll: roll.total,
|
||||
rollResult: roll.total,
|
||||
score: chance,
|
||||
improvVal,
|
||||
})
|
||||
await BRPCharDev.xpOutput(success, partic)
|
||||
|
||||
await partic.update({
|
||||
'system.stats.pow.exp': partic.system.stats.pow.exp + improvVal,
|
||||
'system.stats.pow.improve': false
|
||||
})
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BRPCheck } from '../apps/check.mjs';
|
||||
import { BRPactorDetails } from '../apps/actorDetails.mjs';
|
||||
|
||||
export function addChatListeners(html) {
|
||||
html.on('click', '.cardbutton', BRPCheck.triggerChatButton)
|
||||
//return
|
||||
}
|
||||
|
||||
|
||||
export class BRPChat {
|
||||
static async renderMessageHook(message, html) {
|
||||
ui.chat.scrollBottom()
|
||||
html.querySelectorAll(".cardbutton").forEach(b => b.addEventListener('click', BRPCheck.triggerChatButton));
|
||||
if (!game.user.isGM) {
|
||||
const ownerOnly = html.querySelectorAll('.owner-only')
|
||||
for (const zone of ownerOnly) {
|
||||
let actor = await BRPactorDetails._getParticipant(zone.dataset.particId, zone.dataset.particType)
|
||||
if ((actor && !actor.isOwner) || (!actor && !game.user.isGM)) {
|
||||
zone.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gmVisibleOnly = html.querySelectorAll('.gm-visible-only')
|
||||
for (const elem of gmVisibleOnly) {
|
||||
if (!(game.user.isGM)) elem.style.display = 'none'
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Return true is CTRL key is pressed
|
||||
* Used for MAC compat.
|
||||
* @param {S.Event} event
|
||||
* @returns
|
||||
*/
|
||||
export function isCtrlKey(event) {
|
||||
if (event === false) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.keyCode === 91 ||
|
||||
event.keyCode === 224
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { BRPCheck } from './check.mjs';
|
||||
import { isCtrlKey } from './helper.mjs'
|
||||
|
||||
export class BRPRollType {
|
||||
|
||||
//Start Characteristic Roll
|
||||
static async _onStatRoll(event) {
|
||||
let altKey = event.altKey;
|
||||
let ctrlKey = isCtrlKey(event ?? false);
|
||||
let cardType = 'NO';
|
||||
const dataset = event.currentTarget?.dataset ?? {};
|
||||
let characteristic = dataset.characteristic;
|
||||
const derivedTarget = Number(dataset.target ?? NaN);
|
||||
const isDerived = String(dataset.type ?? "").toLowerCase() === "derived" && Number.isFinite(derivedTarget);
|
||||
const derivedLabel = String(dataset.label ?? "").trim();
|
||||
let label = derivedLabel || "";
|
||||
let targetScore = undefined;
|
||||
let rawScore = undefined;
|
||||
if (isDerived) {
|
||||
targetScore = derivedTarget;
|
||||
rawScore = Math.round(derivedTarget / 5);
|
||||
}
|
||||
if (ctrlKey) { cardType = 'RE' }
|
||||
if (altKey) {
|
||||
cardType = 'PP';
|
||||
characteristic = 'pow'
|
||||
label = "";
|
||||
targetScore = undefined;
|
||||
rawScore = undefined;
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType: 'CH',
|
||||
cardType,
|
||||
characteristic,
|
||||
label,
|
||||
targetScore,
|
||||
rawScore,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
//Start Skill Roll
|
||||
static async _onSkillRoll(event) {
|
||||
let altKey = event.altKey;
|
||||
let ctrlKey = isCtrlKey(event ?? false);
|
||||
let cardType = 'NO';
|
||||
let skillId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
if (ctrlKey) { cardType = 'OP' }
|
||||
if (altKey) { cardType = 'GR' }
|
||||
if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType: 'SK',
|
||||
cardType,
|
||||
skillId,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
//Start Allegiance Roll
|
||||
static async _onAllegianceRoll(event) {
|
||||
let cardType = 'NO';
|
||||
let skillId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType: 'AL',
|
||||
cardType,
|
||||
skillId,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
//Start Passion Roll
|
||||
static async _onPassionRoll(event) {
|
||||
let ctrlKey = isCtrlKey(event ?? false);
|
||||
let cardType = 'NO';
|
||||
let skillId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
if (ctrlKey) { cardType = 'OP' }
|
||||
if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType: 'PA',
|
||||
cardType,
|
||||
skillId,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
//Start Reputation Roll
|
||||
static async _onReputationRoll(event) {
|
||||
let ctrlKey = isCtrlKey(event ?? false);
|
||||
let altKey = event.altKey;
|
||||
let cardType = 'NO';
|
||||
let skillId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
if (ctrlKey) { cardType = 'OP' }
|
||||
if (altKey) { cardType = 'GR' }
|
||||
if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType: 'PA',
|
||||
cardType,
|
||||
skillId,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
//Start Personality Trait Roll
|
||||
static async _onPersTraitRoll(event) {
|
||||
let ctrlKey = isCtrlKey(event ?? false);
|
||||
let cardType = 'NO';
|
||||
let skillId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
let opp = event.currentTarget.dataset.opp;
|
||||
if (ctrlKey) { cardType = 'OP' }
|
||||
if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType: 'PT',
|
||||
cardType,
|
||||
skillId,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token,
|
||||
opp
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
//Start Damage Roll
|
||||
static async _onDamageRoll(event) {
|
||||
let itemId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
let cardType = 'NO'
|
||||
BRPCheck._trigger({
|
||||
rollType: 'DM',
|
||||
cardType,
|
||||
itemId,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
//Magic Spell Impact Roll
|
||||
static async _onImpactRoll(event) {
|
||||
let itemId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
let cardType = 'NO'
|
||||
BRPCheck._trigger({
|
||||
rollType: 'IM',
|
||||
cardType,
|
||||
itemId,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
//Start Weapon Skill Roll
|
||||
static async _onWeaponRoll(event) {
|
||||
let itemId = event.currentTarget.closest('.item').dataset.itemId;
|
||||
let skillId = event.currentTarget.closest('.item').dataset.skillId;
|
||||
let rollType = 'CM'
|
||||
let cardType = 'NO'
|
||||
let fireMode = "single"
|
||||
if (event.altKey) fireMode = "burst"
|
||||
if (event.ctrlKey || event.metaKey) fireMode = "auto"
|
||||
if (game.settings.get('sla-industries-brp', 'quickCombat') && event.shiftKey) {
|
||||
rollType = 'QC'
|
||||
cardType = "NO"
|
||||
event.shiftKey = false
|
||||
} else if (game.settings.get('sla-industries-brp', 'switchShift')) {
|
||||
event.shiftKey = !event.shiftKey
|
||||
}
|
||||
BRPCheck._trigger({
|
||||
rollType,
|
||||
cardType,
|
||||
itemId,
|
||||
skillId,
|
||||
fireMode,
|
||||
shiftKey: event.shiftKey,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
//Armour Rolling when using variable armour
|
||||
static async _onArmour(event) {
|
||||
let prop = event.currentTarget.closest('.ap-name').dataset.property
|
||||
let AVform = ""
|
||||
let label = ""
|
||||
switch (prop) {
|
||||
case "cap":
|
||||
AVform = this.actor.system.avr1
|
||||
label = game.i18n.localize('BRP.armour')
|
||||
break
|
||||
case "cbap":
|
||||
AVform = this.actor.system.avr2
|
||||
label = game.i18n.localize('BRP.ballistic')
|
||||
break
|
||||
case "ap":
|
||||
let item = this.actor.items.get(event.currentTarget.closest('.ap-name').dataset.itemId)
|
||||
if (event.shiftKey) {
|
||||
AVform = item.system.avr2
|
||||
label = item.name + ": " + game.i18n.localize('BRP.ballistic')
|
||||
} else {
|
||||
AVform = item.system.avr1
|
||||
label = item.name + ": " + game.i18n.localize('BRP.armour')
|
||||
}
|
||||
break
|
||||
case "nap":
|
||||
case "nbap":
|
||||
let hitLoc = this.actor.items.get(event.currentTarget.closest('.item').dataset.itemId)
|
||||
if (prop === 'nap') {
|
||||
AVform = hitLoc.system.apRnd
|
||||
label = hitLoc.name + ": " + game.i18n.localize('BRP.armour')
|
||||
} else {
|
||||
AVform = hitLoc.system.bapRnd
|
||||
label = hitLoc.name + ": " + game.i18n.localize('BRP.ballistic')
|
||||
}
|
||||
break
|
||||
case "ncap":
|
||||
AVform = this.actor.system.apRnd
|
||||
label = game.i18n.localize('BRP.armour')
|
||||
break
|
||||
case "ncbap":
|
||||
AVform = this.actor.system.bapRnd
|
||||
label = game.i18n.localize('BRP.ballistic')
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
//If the Armour Value is blank then don't make the roll
|
||||
|
||||
if (AVform === "") { return }
|
||||
BRPCheck._trigger({
|
||||
rollType: 'AR',
|
||||
cardType: 'NO',
|
||||
label,
|
||||
AVform,
|
||||
actor: this.actor,
|
||||
token: this.token
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
export class BRPSelectLists {
|
||||
static _brpid(item) {
|
||||
return item?.flags?.[game.system.id]?.brpidFlag?.id ?? item?.flags?.brp?.brpidFlag?.id ?? "";
|
||||
}
|
||||
|
||||
//Attribute List
|
||||
static async getStatOptions() {
|
||||
let options = {
|
||||
"fixed": game.i18n.localize("BRP.fixed"),
|
||||
"str": game.i18n.localize("BRP.StatsStrAbbr"),
|
||||
"con": game.i18n.localize("BRP.StatsConAbbr"),
|
||||
"siz": game.i18n.localize("BRP.StatsSizAbbr"),
|
||||
"int": game.i18n.localize("BRP.StatsIntAbbr"),
|
||||
"pow": game.i18n.localize("BRP.StatsPowAbbr"),
|
||||
"dex": game.i18n.localize("BRP.StatsDexAbbr"),
|
||||
"cha": game.i18n.localize("BRP.StatsChaAbbr"),
|
||||
"edu": game.i18n.localize("BRP.StatsEduAbbr"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Additional Attribute List
|
||||
static async addStatOptions(characteristic) {
|
||||
let options = {
|
||||
"none": game.i18n.localize("BRP.none"),
|
||||
"str": game.i18n.localize("BRP.StatsStrAbbr"),
|
||||
"con": game.i18n.localize("BRP.StatsConAbbr"),
|
||||
"siz": game.i18n.localize("BRP.StatsSizAbbr"),
|
||||
"int": game.i18n.localize("BRP.StatsIntAbbr"),
|
||||
"pow": game.i18n.localize("BRP.StatsPowAbbr"),
|
||||
"dex": game.i18n.localize("BRP.StatsDexAbbr"),
|
||||
"cha": game.i18n.localize("BRP.StatsChaAbbr"),
|
||||
}
|
||||
if (game.settings.get('sla-industries-brp', 'useEDU')) {
|
||||
options = Object.assign(options, { 'edu': game.i18n.localize("BRP.StatsEduAbbr") })
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
//Skill Category List
|
||||
static async getCategoryOptions() {
|
||||
let skillCatList = await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skillcat'), type: 'i' })
|
||||
skillCatList.sort(function (a, b) {
|
||||
let x = a.name;
|
||||
let y = b.name;
|
||||
if (x < y) { return -1 };
|
||||
if (x > y) { return 1 };
|
||||
return 0;
|
||||
});
|
||||
let options = {}
|
||||
for (let itm of skillCatList) {
|
||||
const brpid = this._brpid(itm);
|
||||
if (brpid) {
|
||||
options = Object.assign(options, { [brpid]: itm.name })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
//Weapon Category List
|
||||
static async getWpnCategoryOptions() {
|
||||
let options = {
|
||||
"artillery": game.i18n.localize("BRP.artillery"),
|
||||
"energy": game.i18n.localize("BRP.energy"),
|
||||
"explosive": game.i18n.localize("BRP.explosive"),
|
||||
"firearm": game.i18n.localize("BRP.firearm"),
|
||||
"heavy": game.i18n.localize("BRP.heavy"),
|
||||
"melee": game.i18n.localize("BRP.melee"),
|
||||
"missile": game.i18n.localize("BRP.missile"),
|
||||
"shield": game.i18n.localize("BRP.shield"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Dam Bonus Category List
|
||||
static async getDamBonusOptions() {
|
||||
let options = {
|
||||
"none": game.i18n.localize("BRP.none"),
|
||||
"half": game.i18n.localize("BRP.half"),
|
||||
"full": game.i18n.localize("BRP.full"),
|
||||
"oneH": game.i18n.localize("BRP.oneH"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Special Success Category List
|
||||
static async getSpecialOptions() {
|
||||
let options = {
|
||||
"none": game.i18n.localize("BRP.none"),
|
||||
"bleed": game.i18n.localize("BRP.bleed"),
|
||||
"crush": game.i18n.localize("BRP.crush"),
|
||||
"entangle": game.i18n.localize("BRP.entangle"),
|
||||
"fire": game.i18n.localize("BRP.fire"),
|
||||
"impale": game.i18n.localize("BRP.impale"),
|
||||
"knockback": game.i18n.localize("BRP.knockback"),
|
||||
"impknock": game.i18n.localize("BRP.impknock"),
|
||||
"crushknock": game.i18n.localize("BRP.crushknock"),
|
||||
"stun": game.i18n.localize("BRP.stun"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Funcational Options List
|
||||
static async getFunctionalOptions() {
|
||||
let options = {
|
||||
"and": game.i18n.localize("BRP.and"),
|
||||
"or": game.i18n.localize("BRP.or"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Advanced Skill Category Options List
|
||||
static async getAdvSkillCatOptions() {
|
||||
let options = {
|
||||
"0": game.i18n.localize("BRP.advSkillCat.0"),
|
||||
"1": game.i18n.localize("BRP.advSkillCat.1"),
|
||||
"2": game.i18n.localize("BRP.advSkillCat.2"),
|
||||
"3": game.i18n.localize("BRP.advSkillCat.3"),
|
||||
"4": game.i18n.localize("BRP.advSkillCat.4"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Wealth Options
|
||||
static getWealthOptions(min, max) {
|
||||
let options = {}
|
||||
let newOption = {}
|
||||
for (let i = 0; i <= max; i++) {
|
||||
if (i >= min) {
|
||||
newOption = { [i]: game.i18n.localize("BRP.wealthLevel." + i), };
|
||||
options = Object.assign(options, newOption)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
//Powers Category List
|
||||
static async getPowerCatOptions() {
|
||||
let options = {
|
||||
"magic": game.i18n.localize("BRP.magic"),
|
||||
"mutation": game.i18n.localize("BRP.mutation"),
|
||||
"psychic": game.i18n.localize("BRP.psychic"),
|
||||
"sorcery": game.i18n.localize("BRP.sorcery"),
|
||||
"super": game.i18n.localize("BRP.super")
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Power Level List
|
||||
static async getPowerLvlOptions() {
|
||||
let options = {
|
||||
"normal": game.i18n.localize("BRP.normal"),
|
||||
"epic": game.i18n.localize("BRP.epic"),
|
||||
"heroic": game.i18n.localize("BRP.heroic"),
|
||||
"superhuman": game.i18n.localize("BRP.superhuman"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Spell Category List
|
||||
static async getSpellCatOptions() {
|
||||
let options = {
|
||||
"damage": game.i18n.localize("BRP.damage"),
|
||||
"healing": game.i18n.localize("BRP.healing"),
|
||||
"other": game.i18n.localize("BRP.other"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Mutation Category List
|
||||
static async getMutationCatOptions() {
|
||||
let options = {
|
||||
"adv": game.i18n.localize("BRP.adv"),
|
||||
"dis": game.i18n.localize("BRP.dis"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Armour Burden List
|
||||
static async getArmourBurdenOptions() {
|
||||
let options = {
|
||||
"none": game.i18n.localize("BRP.none"),
|
||||
"light": game.i18n.localize("BRP.light"),
|
||||
"moderate": game.i18n.localize("BRP.moderate"),
|
||||
"cumbersome": game.i18n.localize("BRP.cumbersome"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Price Categories List
|
||||
static async getPriceOptions() {
|
||||
let options = {
|
||||
"none": game.i18n.localize("BRP.none"),
|
||||
"cheap": game.i18n.localize("BRP.cheap"),
|
||||
"inexpensive": game.i18n.localize("BRP.inexpensive"),
|
||||
"average": game.i18n.localize("BRP.average"),
|
||||
"expensive": game.i18n.localize("BRP.expensive"),
|
||||
"priceless": game.i18n.localize("BRP.priceless"),
|
||||
"restricted": game.i18n.localize("BRP.restricted"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Hit Loc Options
|
||||
static getHitLocOptions(actor) {
|
||||
let options = {}
|
||||
let newOption = {}
|
||||
for (let i of actor.items) {
|
||||
if (i.type === 'hit-location') {
|
||||
if (game.settings.get('sla-industries-brp', 'useHPL') && i.system.locType != 'general') {
|
||||
newOption = { [i.id]: i.system.displayName, };
|
||||
options = Object.assign(options, newOption)
|
||||
} else if (!game.settings.get('sla-industries-brp', 'useHPL') && i.system.locType === 'general') {
|
||||
newOption = { [i.id]: i.name, };
|
||||
options = Object.assign(options, newOption)
|
||||
}
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
//Equipped List
|
||||
static async getEquippedOptions(type) {
|
||||
let options = {};
|
||||
if (type === 'armour') {
|
||||
options = {
|
||||
"carried": game.i18n.localize("BRP.carried"),
|
||||
"worn": game.i18n.localize("BRP.worn"),
|
||||
"packed": game.i18n.localize("BRP.packed"),
|
||||
"stored": game.i18n.localize("BRP.stored"),
|
||||
}
|
||||
} else {
|
||||
options = {
|
||||
"carried": game.i18n.localize("BRP.carried"),
|
||||
"packed": game.i18n.localize("BRP.packed"),
|
||||
"stored": game.i18n.localize("BRP.stored"),
|
||||
}
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Weapon Skill Options
|
||||
static async getWeaponSkillOptions(skillId) {
|
||||
let skillList = await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skill'), type: 'i' })
|
||||
let newList = skillList
|
||||
.filter(itm => (itm.system.combat || itm.system.category === 'i.skillcat.combat'))
|
||||
.map(itm => { return { brpid: this._brpid(itm), name: itm.name } })
|
||||
newList.sort(function (a, b) {
|
||||
let x = a.name;
|
||||
let y = b.name;
|
||||
if (x < y) { return -1 };
|
||||
if (x > y) { return 1 };
|
||||
return 0;
|
||||
});
|
||||
|
||||
let options = {}
|
||||
let newOption = {}
|
||||
if (skillId != "1") {
|
||||
newOption = { "none": game.i18n.localize("BRP.none") };
|
||||
options = Object.assign(options, { "none": game.i18n.localize("BRP.none") })
|
||||
}
|
||||
|
||||
for (let itm of newList) {
|
||||
if (itm.brpid) {
|
||||
options = Object.assign(options, { [itm.brpid]: itm.name })
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
//Handed List
|
||||
static async getHandedOptions() {
|
||||
let options = {
|
||||
"none": game.i18n.localize("BRP.none"),
|
||||
"1H": game.i18n.localize("BRP.1H"),
|
||||
"2H": game.i18n.localize("BRP.2H"),
|
||||
"1-2H": game.i18n.localize("BRP.1-2H"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Difficulty List
|
||||
static async getDifficultyOptions() {
|
||||
let options = {
|
||||
"advantage": game.i18n.localize("BRP.adv") + " (1D100 + tens re-roll, keep best)",
|
||||
"normal": game.i18n.localize("BRP.normal") + " (1D100)",
|
||||
"disadvantage": game.i18n.localize("BRP.dis") + " (1D100 + tens re-roll, keep worst)",
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Difficulty List
|
||||
static async getCHDifficultyOptions() {
|
||||
return BRPSelectLists.getDifficultyOptions();
|
||||
}
|
||||
|
||||
//Hit Location List
|
||||
static async getHitLocType() {
|
||||
let options = {
|
||||
"limb": game.i18n.localize("BRP.limb"),
|
||||
"abdomen": game.i18n.localize("BRP.abdomen"),
|
||||
"chest": game.i18n.localize("BRP.chest"),
|
||||
"head": game.i18n.localize("BRP.head"),
|
||||
"general": game.i18n.localize("BRP.general"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Success Level List
|
||||
static async getSuccessOptions() {
|
||||
let options = {
|
||||
"4": game.i18n.localize("BRP.resultLevel.4"),
|
||||
"3": game.i18n.localize("BRP.resultLevel.3"),
|
||||
"2": game.i18n.localize("BRP.resultLevel.2"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
|
||||
//Auto XP Options List
|
||||
static async getXPOptions() {
|
||||
let options = {
|
||||
"0": game.i18n.localize("BRP.none"),
|
||||
"1": game.i18n.localize("BRP.onSuccess"),
|
||||
"2": game.i18n.localize("BRP.onFailure"),
|
||||
};
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export class SkillsSelectDialog extends Dialog {
|
||||
activateListeners(html) {
|
||||
super.activateListeners(html)
|
||||
|
||||
html.find('.select.rollable').click(async event => this._onSelectClicked(event))
|
||||
html.find('.closecard').click(async event => this._shutdialog(event))
|
||||
}
|
||||
|
||||
async _onSelectClicked(event) {
|
||||
const chosen = event.currentTarget.closest('.mediumIcon')
|
||||
let choice = chosen.dataset.set
|
||||
let change = 1
|
||||
if (this.data.data.selectOptions[choice].selected) change = -1
|
||||
//Don't allow spend over picks
|
||||
if (this.data.data.added + change <= this.data.data.picks) {
|
||||
//Change the points spent
|
||||
this.data.data.added = this.data.data.added + change
|
||||
|
||||
//Update points spent and the stats value on the form
|
||||
const form = event.currentTarget.closest('.skill-select')
|
||||
const divCount = form.querySelector('.count')
|
||||
divCount.innerText = this.data.data.added
|
||||
const selected = form.querySelector('.item-' + choice)
|
||||
if (this.data.data.selectOptions[choice].selected) {
|
||||
this.data.data.selectOptions[choice].selected = false
|
||||
selected.innerHTML = '<a><i class="fa-regular fa-square"></i> </a>'
|
||||
} else {
|
||||
this.data.data.selectOptions[choice].selected = true
|
||||
selected.innerHTML = '<a><i class="fa-regular fa-square-check"></i> </a>'
|
||||
}
|
||||
const closecard = form.querySelector('.closecard')
|
||||
if (this.data.data.added >= this.data.data.picks) {
|
||||
closecard.innerHTML = "<button class='proceed cardbutton' type='button'>" + game.i18n.localize('BRP.confirm') + "</button>"
|
||||
} else {
|
||||
closecard.innerHTML = "<button class='cardbutton' type='button'>" + game.i18n.localize('BRP.makeSelection') + "</button>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _shutdialog(event) {
|
||||
if (this.data.data.added >= this.data.data.picks) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
static async create(selectOptions, picks, type) {
|
||||
let destination = 'systems/sla-industries-brp/templates/dialog/skillSelect.html';
|
||||
let winTitle = game.i18n.format("BRP.selectItem", { type: type });
|
||||
let data = {
|
||||
selectOptions,
|
||||
picks,
|
||||
added: 0
|
||||
}
|
||||
const html = await foundry.applications.handlebars.renderTemplate(destination, data);
|
||||
|
||||
return new Promise(resolve => {
|
||||
const dlg = new SkillsSelectDialog(
|
||||
{
|
||||
title: winTitle,
|
||||
content: html,
|
||||
data,
|
||||
buttons: {},
|
||||
close: () => {
|
||||
if (data.added < data.picks) return resolve(false)
|
||||
const selected = data.selectOptions.filter(option => (option.selected))
|
||||
return resolve(selected)
|
||||
}
|
||||
},
|
||||
{ classes: ['brp'] }
|
||||
)
|
||||
dlg.render(true)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
export class SLAAmmoCatalog {
|
||||
static AMMO_ICON_PATH = "assets/SLA_Assets/Ammo";
|
||||
|
||||
static BASE_CALIBRES = [
|
||||
{ key: "3mm", label: "3mm", cost: 1, group: "Handgun", typical: "FEN 603, machine pistols" },
|
||||
{ key: "5mm", label: "5mm", cost: 2, group: "Handgun", typical: "Compact pistols, light SMGs" },
|
||||
{ key: "8mm", label: "8mm", cost: 4, group: "Handgun", typical: "Heavy pistols (GA 50 class)" },
|
||||
{ key: "10mm-pistol", label: "10mm (pistol)", cost: 5, group: "Handgun", typical: "Heavy autos, SMG sidearms" },
|
||||
{ key: "12mm", label: "12mm", cost: 8, group: "Handgun", typical: "Magnum pistols, hand cannons" },
|
||||
{ key: "3mm-smg", label: "3mm SMG", cost: 1, group: "SMG / PDW", typical: "Micro-SMGs, machine pistols" },
|
||||
{ key: "5mm-smg", label: "5mm SMG", cost: 2, group: "SMG / PDW", typical: "FEN 204 light loads" },
|
||||
{ key: "10mm-smg", label: "10mm SMG", cost: 5, group: "SMG / PDW", typical: "Heavy SMGs" },
|
||||
{ key: "5mm-rifle", label: "5mm rifle", cost: 3, group: "Rifle / Carbine", typical: "Light carbines, PDWs" },
|
||||
{ key: "7mm-rifle", label: "7mm rifle", cost: 6, group: "Rifle / Carbine", typical: "FEN AR-class rifles" },
|
||||
{ key: "8mm-rifle", label: "8mm rifle", cost: 8, group: "Rifle / Carbine", typical: "Battle rifles, DMRs, sniper" },
|
||||
{ key: "10mm-rifle", label: "10mm rifle", cost: 12, group: "Rifle / Carbine", typical: "Heavy battle rifles" },
|
||||
{ key: "12-gauge", label: "12-gauge", cost: 4, group: "Shotgun", typical: "Combat buck/slug shells" },
|
||||
{ key: "10-gauge", label: "10-gauge", cost: 6, group: "Shotgun", typical: "Heavy buck/slug shells" },
|
||||
{ key: "12-gauge-he", label: "12-gauge HE", cost: 10, group: "Shotgun", typical: "HE shotgun shells" },
|
||||
{ key: "10-gauge-he", label: "10-gauge HE", cost: 14, group: "Shotgun", typical: "Heavy HE shells" },
|
||||
{ key: "20mm-hedp", label: "20mm HEDP", cost: 60, group: "Heavy / Launcher", typical: "Autocannons, heavy rifles" },
|
||||
{ key: "25mm-he", label: "25mm HE", cost: 80, group: "Heavy / Launcher", typical: "Autocannons, vehicle weapons" },
|
||||
{ key: "30mm-heat", label: "30mm HEAT", cost: 120, group: "Heavy / Launcher", typical: "Vehicle armour-killer rounds" },
|
||||
{ key: "40mm-he-grenade", label: "40mm HE grenade", cost: 40, group: "Heavy / Launcher", typical: "MILA / underslung HE" },
|
||||
{ key: "40mm-smoke-gas", label: "40mm smoke / gas", cost: 25, group: "Heavy / Launcher", typical: "Obscurant and riot control" }
|
||||
];
|
||||
|
||||
static TAG_RULES = {
|
||||
STD: { label: "STD", multiplier: 1, flatAdd: 0, rule: "Standard ammunition" },
|
||||
AP: { label: "AP", multiplier: 2, flatAdd: 0, rule: "Armour piercing" },
|
||||
HE: { label: "HE", multiplier: 3, flatAdd: 0, rule: "High explosive" },
|
||||
HEAP: { label: "HEAP", multiplier: 4, flatAdd: 0, rule: "High explosive armour piercing" },
|
||||
SUB: { label: "Subsonic", multiplier: 1.5, flatAdd: 0, rule: "Subsonic ammunition" },
|
||||
TRACER: { label: "Tracer", multiplier: 1, flatAdd: 1, rule: "Tracer rounds" }
|
||||
};
|
||||
|
||||
static MECHANICAL_TAGS = ["STD", "AP", "HE", "HEAP"];
|
||||
|
||||
static _calibreIndex = null;
|
||||
static _aliasIndex = null;
|
||||
|
||||
static normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
static normalizeTag(tag = "STD") {
|
||||
const key = String(tag ?? "").trim().toUpperCase();
|
||||
if (this.TAG_RULES[key]) return key;
|
||||
return "STD";
|
||||
}
|
||||
|
||||
static getCalibreIndex() {
|
||||
if (this._calibreIndex) return this._calibreIndex;
|
||||
this._calibreIndex = new Map();
|
||||
for (const calibre of this.BASE_CALIBRES) {
|
||||
this._calibreIndex.set(calibre.key, calibre);
|
||||
this._calibreIndex.set(this.normalizeText(calibre.label), calibre);
|
||||
}
|
||||
return this._calibreIndex;
|
||||
}
|
||||
|
||||
static getAliasIndex() {
|
||||
if (this._aliasIndex) return this._aliasIndex;
|
||||
this._aliasIndex = new Map([
|
||||
["3mm", "3mm"],
|
||||
["5mm", "5mm"],
|
||||
["8mm", "8mm"],
|
||||
["10mmpistol", "10mm-pistol"],
|
||||
["12mm", "12mm"],
|
||||
["3mmsmg", "3mm-smg"],
|
||||
["5mmsmg", "5mm-smg"],
|
||||
["10mmsmg", "10mm-smg"],
|
||||
["5mmrifle", "5mm-rifle"],
|
||||
["7mmrifle", "7mm-rifle"],
|
||||
["8mmrifle", "8mm-rifle"],
|
||||
["10mmrifle", "10mm-rifle"],
|
||||
["12gauge", "12-gauge"],
|
||||
["10gauge", "10-gauge"],
|
||||
["12gaugehe", "12-gauge-he"],
|
||||
["10gaugehe", "10-gauge-he"],
|
||||
["20mmhedp", "20mm-hedp"],
|
||||
["25mmhe", "25mm-he"],
|
||||
["30mmheat", "30mm-heat"],
|
||||
["40mmhegrenade", "40mm-he-grenade"],
|
||||
["40mmsmokegas", "40mm-smoke-gas"]
|
||||
]);
|
||||
return this._aliasIndex;
|
||||
}
|
||||
|
||||
static resolveCalibre(value = "") {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const key = this.normalizeText(raw);
|
||||
const alias = this.getAliasIndex().get(key) ?? null;
|
||||
if (alias) return this.getCalibreIndex().get(alias) ?? null;
|
||||
return this.getCalibreIndex().get(key) ?? null;
|
||||
}
|
||||
|
||||
static deriveWeaponCalibre(weapon) {
|
||||
const explicit = weapon?.system?.ammoCalibre ?? weapon?.ammoCalibre ?? "";
|
||||
const resolvedExplicit = this.resolveCalibre(explicit);
|
||||
if (resolvedExplicit) return resolvedExplicit;
|
||||
|
||||
const fallback = weapon?.system?.ammoType ?? weapon?.ammoType ?? "";
|
||||
const resolvedFallback = this.resolveCalibre(fallback);
|
||||
if (resolvedFallback) return resolvedFallback;
|
||||
|
||||
const text = `${weapon?.name ?? ""} ${weapon?.ammoText ?? ""} ${(weapon?.notes ?? []).join(" ")}`.toLowerCase();
|
||||
const rules = [
|
||||
[/fen\s*603\s*heavy|heavy pistol|ga\s*50|finisher/, "8mm"],
|
||||
[/fen\s*603|auto[\s-]*pistol/, "3mm"],
|
||||
[/shiver pistol|fen\s*401/, "5mm"],
|
||||
[/gunhead|fen\s*204/, "5mm-smg"],
|
||||
[/fen\s*209|machine pistol/, "3mm-smg"],
|
||||
[/fen\s*ar|assault rifle/, "7mm-rifle"],
|
||||
[/sniper|fen\s*981/, "8mm-rifle"],
|
||||
[/urban carbine|fen\s*701|carbine/, "5mm-rifle"],
|
||||
[/street sweeper|auto-shotgun|kps|mangler|shotgun/, "12-gauge"],
|
||||
[/reaper|lmg/, "7mm-rifle"],
|
||||
[/mila|40mm/, "40mm-he-grenade"]
|
||||
];
|
||||
for (const [re, calibreKey] of rules) {
|
||||
if (re.test(text)) {
|
||||
return this.getCalibreIndex().get(calibreKey) ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static collectSeededAmmoCalibreKeys() {
|
||||
const keys = new Set();
|
||||
for (const item of game.items ?? []) {
|
||||
if (item.type !== "gear") continue;
|
||||
const meta = this.getAmmoMetaFromItem(item);
|
||||
if (!meta?.calibreKey) continue;
|
||||
keys.add(meta.calibreKey);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
static getCostPerRound(calibreKeyOrLabel, ammoTag = "STD") {
|
||||
const calibre = this.resolveCalibre(calibreKeyOrLabel);
|
||||
if (!calibre) return 0;
|
||||
const tag = this.normalizeTag(ammoTag);
|
||||
const rule = this.TAG_RULES[tag] ?? this.TAG_RULES.STD;
|
||||
const value = calibre.cost * Number(rule.multiplier ?? 1) + Number(rule.flatAdd ?? 0);
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
static getBaseRoundCost(calibreKeyOrLabel, fallback = 0) {
|
||||
const calibre = this.resolveCalibre(calibreKeyOrLabel);
|
||||
if (calibre) return Number(calibre.cost ?? 0);
|
||||
const parsed = Number(fallback ?? 0);
|
||||
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
}
|
||||
|
||||
static getExtraTagSurcharge(calibreKeyOrLabel, ammoTag = "STD", fallbackBase = 0) {
|
||||
const perRound = this.getCostPerRound(calibreKeyOrLabel, ammoTag);
|
||||
const base = this.getBaseRoundCost(calibreKeyOrLabel, fallbackBase);
|
||||
return Math.max(0, Math.round((perRound - base) * 100) / 100);
|
||||
}
|
||||
|
||||
static buildAmmoIconPath(calibreKeyOrLabel, ammoTag = "STD") {
|
||||
const calibre = this.resolveCalibre(calibreKeyOrLabel);
|
||||
if (!calibre) return null;
|
||||
const tag = this.normalizeTag(ammoTag);
|
||||
return `${this.AMMO_ICON_PATH}/${calibre.key}__${tag}.png`;
|
||||
}
|
||||
|
||||
static getAmmoMetaFromItem(item) {
|
||||
if (!item || item.type !== "gear") return null;
|
||||
const direct =
|
||||
item.flags?.[game.system.id]?.slaAmmo ??
|
||||
item.flags?.brp?.slaAmmo ??
|
||||
null;
|
||||
if (direct?.isAmmo) {
|
||||
const calibre = this.resolveCalibre(direct.calibreKey ?? direct.calibreLabel ?? "");
|
||||
if (!calibre) return null;
|
||||
return {
|
||||
calibreKey: calibre.key,
|
||||
calibreLabel: calibre.label,
|
||||
costPerRound: Number(this.getBaseRoundCost(calibre.key, calibre.cost))
|
||||
};
|
||||
}
|
||||
|
||||
const name = String(item.name ?? "").trim();
|
||||
const cleanName = name
|
||||
.replace(/^\s*ammo[:\s-]*/i, "")
|
||||
.replace(/\[(STD|AP|HE|HEAP|SUB|TRACER)\]/ig, "")
|
||||
.trim();
|
||||
const calibre = this.resolveCalibre(cleanName);
|
||||
if (!calibre) return null;
|
||||
return {
|
||||
calibreKey: calibre.key,
|
||||
calibreLabel: calibre.label,
|
||||
costPerRound: Number(this.getBaseRoundCost(calibre.key, calibre.cost))
|
||||
};
|
||||
}
|
||||
|
||||
static buildAmmoGearName(calibreLabel) {
|
||||
return `Ammo: ${calibreLabel}`;
|
||||
}
|
||||
|
||||
static buildAmmoDescription(calibre, baseCost) {
|
||||
return `<p><strong>${calibre.group}</strong>: ${calibre.typical}</p><ul><li>Calibre: ${calibre.label}</li><li>Cost per round: ${baseCost} CRD</li><li>Use ammo type at attack time (STD/AP/HE/HEAP).</li></ul>`;
|
||||
}
|
||||
|
||||
static buildAmmoGearData(calibre) {
|
||||
const baseCost = this.getBaseRoundCost(calibre.key, calibre.cost);
|
||||
const name = this.buildAmmoGearName(calibre.label);
|
||||
const description = this.buildAmmoDescription(calibre, baseCost);
|
||||
const brpid = `i.gear.sla-ammo-${calibre.key}`;
|
||||
const meta = {
|
||||
isAmmo: true,
|
||||
calibreKey: calibre.key,
|
||||
calibreLabel: calibre.label,
|
||||
baseCost: Number(baseCost)
|
||||
};
|
||||
return {
|
||||
name,
|
||||
type: "gear",
|
||||
img: this.buildAmmoIconPath(calibre.key, "STD"),
|
||||
system: {
|
||||
quantity: 0,
|
||||
enc: 0,
|
||||
crdEach: baseCost,
|
||||
crdTotal: 0,
|
||||
price: "average",
|
||||
equipStatus: "carried",
|
||||
description
|
||||
},
|
||||
flags: {
|
||||
[game.system.id]: {
|
||||
brpidFlag: {
|
||||
id: brpid,
|
||||
lang: game.i18n.lang,
|
||||
priority: 0
|
||||
},
|
||||
slaAmmo: meta
|
||||
},
|
||||
brp: {
|
||||
brpidFlag: {
|
||||
id: brpid,
|
||||
lang: game.i18n.lang,
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async ensureAmmoFolder(folderName = "SLA Ammo") {
|
||||
let folder = game.folders.find((f) => f.type === "Item" && f.name === folderName && !f.folder);
|
||||
if (!folder) {
|
||||
folder = await Folder.create({
|
||||
name: folderName,
|
||||
type: "Item",
|
||||
color: "#6b4f2f"
|
||||
});
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
static async seedWorldAmmoGear({ overwrite = false, folderName = "SLA Ammo" } = {}) {
|
||||
const folder = await this.ensureAmmoFolder(folderName);
|
||||
const existing = new Map(
|
||||
game.items
|
||||
.filter((item) => item.type === "gear" && item.folder?.id === folder.id)
|
||||
.map((item) => [item.name.toLowerCase().trim(), item])
|
||||
);
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const calibre of this.BASE_CALIBRES) {
|
||||
const payload = this.buildAmmoGearData(calibre);
|
||||
payload.folder = folder.id;
|
||||
const key = payload.name.toLowerCase().trim();
|
||||
const current = existing.get(key);
|
||||
if (!current) {
|
||||
await Item.create(payload);
|
||||
created++;
|
||||
} else if (overwrite) {
|
||||
await current.update(payload);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
ui.notifications.info(`SLA ammo seeded: ${created} created, ${updated} updated.`);
|
||||
return { created, updated, folder: folder.name };
|
||||
}
|
||||
|
||||
static async normalizeActorAmmoGear(actor) {
|
||||
if (!actor?.items) return { actor: actor?.name ?? "", updated: 0, deleted: 0 };
|
||||
const grouped = new Map();
|
||||
for (const item of actor.items) {
|
||||
if (item.type !== "gear") continue;
|
||||
const meta = this.getAmmoMetaFromItem(item);
|
||||
if (!meta) continue;
|
||||
if (!grouped.has(meta.calibreKey)) grouped.set(meta.calibreKey, []);
|
||||
grouped.get(meta.calibreKey).push({ item, meta });
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
for (const [calibreKey, rows] of grouped.entries()) {
|
||||
const calibre = this.resolveCalibre(calibreKey);
|
||||
if (!calibre || !rows.length) continue;
|
||||
const totalQty = rows.reduce((sum, row) => sum + Math.max(0, Number(row.item.system?.quantity ?? 0)), 0);
|
||||
const baseCost = this.getBaseRoundCost(calibre.key, calibre.cost);
|
||||
const canonicalName = this.buildAmmoGearName(calibre.label);
|
||||
const keeper = rows.find((row) => String(row.item.name ?? "").trim().toLowerCase() === canonicalName.toLowerCase())?.item ?? rows[0].item;
|
||||
const canonical = this.buildAmmoGearData(calibre);
|
||||
const updateData = {
|
||||
name: canonicalName,
|
||||
"system.quantity": totalQty,
|
||||
"system.crdEach": baseCost,
|
||||
"system.crdTotal": Math.round(totalQty * baseCost * 100) / 100,
|
||||
[`flags.${game.system.id}.slaAmmo`]: canonical.flags?.[game.system.id]?.slaAmmo ?? null,
|
||||
[`flags.${game.system.id}.brpidFlag`]: canonical.flags?.[game.system.id]?.brpidFlag ?? null,
|
||||
"flags.brp.brpidFlag": canonical.flags?.brp?.brpidFlag ?? null
|
||||
};
|
||||
await keeper.update(updateData);
|
||||
updated += 1;
|
||||
|
||||
const extra = rows.map((row) => row.item).filter((item) => item.id !== keeper.id);
|
||||
if (extra.length) {
|
||||
await actor.deleteEmbeddedDocuments("Item", extra.map((item) => item.id));
|
||||
deleted += extra.length;
|
||||
}
|
||||
}
|
||||
return { actor: actor.name, updated, deleted };
|
||||
}
|
||||
|
||||
static async normalizeAllActorAmmoGear() {
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
const perActor = [];
|
||||
for (const actor of game.actors ?? []) {
|
||||
const result = await this.normalizeActorAmmoGear(actor);
|
||||
updated += result.updated;
|
||||
deleted += result.deleted;
|
||||
if (result.updated > 0 || result.deleted > 0) {
|
||||
perActor.push(result);
|
||||
}
|
||||
}
|
||||
ui.notifications.info(`SLA ammo normalized on actors: ${updated} stacks updated, ${deleted} stacks merged.`);
|
||||
return { updated, deleted, perActor };
|
||||
}
|
||||
|
||||
static getWeaponAllowedTags(weapon) {
|
||||
const sys = weapon?.system ?? {};
|
||||
const defaults = {
|
||||
STD: true,
|
||||
AP: true,
|
||||
HE: true,
|
||||
HEAP: true
|
||||
};
|
||||
|
||||
const flags = {
|
||||
STD: typeof sys.ammoAllowStd === "boolean" ? sys.ammoAllowStd : defaults.STD,
|
||||
AP: typeof sys.ammoAllowAp === "boolean" ? sys.ammoAllowAp : defaults.AP,
|
||||
HE: typeof sys.ammoAllowHe === "boolean" ? sys.ammoAllowHe : defaults.HE,
|
||||
HEAP: typeof sys.ammoAllowHeap === "boolean" ? sys.ammoAllowHeap : defaults.HEAP
|
||||
};
|
||||
|
||||
const allowed = this.MECHANICAL_TAGS.filter((tag) => flags[tag]);
|
||||
if (!allowed.length) return ["STD"];
|
||||
if (!allowed.includes("STD")) allowed.unshift("STD");
|
||||
return Array.from(new Set(allowed));
|
||||
}
|
||||
|
||||
static async backfillWeaponAmmoProfiles({
|
||||
overwrite = false,
|
||||
includeWorldWeapons = true,
|
||||
includeActorWeapons = true
|
||||
} = {}) {
|
||||
let scanned = 0;
|
||||
let updated = 0;
|
||||
|
||||
const processWeapon = async (weapon) => {
|
||||
if (!weapon || weapon.type !== "weapon") return;
|
||||
scanned += 1;
|
||||
const calibre =
|
||||
this.resolveCalibre(weapon.system?.ammoCalibre ?? "") ??
|
||||
this.resolveCalibre(weapon.system?.ammoType ?? "") ??
|
||||
this.deriveWeaponCalibre(weapon);
|
||||
if (!calibre) return;
|
||||
|
||||
const currentCalibre = String(weapon.system?.ammoCalibre ?? "").trim();
|
||||
const currentBaseCost = Number(weapon.system?.ammoBaseCost ?? 0);
|
||||
const needsCalibre = overwrite || !currentCalibre;
|
||||
const needsBaseCost = overwrite || currentBaseCost <= 0;
|
||||
const needsAllowStd = overwrite || typeof weapon.system?.ammoAllowStd !== "boolean";
|
||||
const needsAllowAp = overwrite || typeof weapon.system?.ammoAllowAp !== "boolean";
|
||||
const needsAllowHe = overwrite || typeof weapon.system?.ammoAllowHe !== "boolean";
|
||||
const needsAllowHeap = overwrite || typeof weapon.system?.ammoAllowHeap !== "boolean";
|
||||
if (!needsCalibre && !needsBaseCost && !needsAllowStd && !needsAllowAp && !needsAllowHe && !needsAllowHeap) return;
|
||||
|
||||
const updateData = {};
|
||||
if (needsCalibre) updateData["system.ammoCalibre"] = calibre.label;
|
||||
if (needsBaseCost) updateData["system.ammoBaseCost"] = Number(calibre.cost ?? 0);
|
||||
if (needsAllowStd) updateData["system.ammoAllowStd"] = true;
|
||||
if (needsAllowAp) updateData["system.ammoAllowAp"] = true;
|
||||
if (needsAllowHe) updateData["system.ammoAllowHe"] = true;
|
||||
if (needsAllowHeap) updateData["system.ammoAllowHeap"] = true;
|
||||
await weapon.update(updateData);
|
||||
updated += 1;
|
||||
};
|
||||
|
||||
if (includeWorldWeapons) {
|
||||
const worldWeapons = game.items.filter((item) => item.type === "weapon" && !item.parent);
|
||||
for (const weapon of worldWeapons) {
|
||||
await processWeapon(weapon);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeActorWeapons) {
|
||||
for (const actor of game.actors ?? []) {
|
||||
for (const weapon of actor.items.filter((item) => item.type === "weapon")) {
|
||||
await processWeapon(weapon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ui.notifications.info(`SLA weapon ammo profile backfill: ${updated}/${scanned} updated.`);
|
||||
return { scanned, updated };
|
||||
}
|
||||
|
||||
static auditWeaponCalibres({
|
||||
includeWorldWeapons = true,
|
||||
includeActorWeapons = true
|
||||
} = {}) {
|
||||
const seededAmmo = this.collectSeededAmmoCalibreKeys();
|
||||
const rows = [];
|
||||
|
||||
const pushWeapon = (weapon, owner = "World") => {
|
||||
if (!weapon || weapon.type !== "weapon") return;
|
||||
const explicit = this.resolveCalibre(weapon.system?.ammoCalibre ?? "");
|
||||
const derived = this.deriveWeaponCalibre(weapon);
|
||||
const resolved = explicit ?? derived;
|
||||
const calibreKey = resolved?.key ?? "";
|
||||
rows.push({
|
||||
owner,
|
||||
name: weapon.name,
|
||||
ammoCalibre: weapon.system?.ammoCalibre ?? "",
|
||||
resolvedCalibre: resolved?.label ?? "UNRESOLVED",
|
||||
ammoType: weapon.system?.ammoType ?? "",
|
||||
hasSeededAmmo: calibreKey ? seededAmmo.has(calibreKey) : false
|
||||
});
|
||||
};
|
||||
|
||||
if (includeWorldWeapons) {
|
||||
for (const weapon of game.items.filter((item) => item.type === "weapon" && !item.parent)) {
|
||||
pushWeapon(weapon, "World");
|
||||
}
|
||||
}
|
||||
if (includeActorWeapons) {
|
||||
for (const actor of game.actors ?? []) {
|
||||
for (const weapon of actor.items.filter((item) => item.type === "weapon")) {
|
||||
pushWeapon(weapon, actor.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unresolved = rows.filter((r) => r.resolvedCalibre === "UNRESOLVED");
|
||||
const missingAmmo = rows.filter((r) => r.resolvedCalibre !== "UNRESOLVED" && !r.hasSeededAmmo);
|
||||
const summary = {
|
||||
total: rows.length,
|
||||
unresolved: unresolved.length,
|
||||
missingAmmo: missingAmmo.length
|
||||
};
|
||||
console.table(rows);
|
||||
console.log("sla-industries-brp | Weapon calibre audit summary", summary, { unresolved, missingAmmo });
|
||||
return { summary, rows, unresolved, missingAmmo };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { SLAAmmoCatalog } from "./sla-ammo-catalog.mjs";
|
||||
|
||||
export class SLAAmmoTracker {
|
||||
static DEFAULTS = {
|
||||
ammoTracking: true,
|
||||
ammoAutoSpend: true,
|
||||
ammoNotify: true,
|
||||
ammoDeficitMode: "warn"
|
||||
};
|
||||
|
||||
static AMMO_TAGS = ["STD", "AP", "HE", "HEAP"];
|
||||
|
||||
static isTrackingEnabled() {
|
||||
return game.settings.get("sla-industries-brp", "ammoTracking") ?? this.DEFAULTS.ammoTracking;
|
||||
}
|
||||
|
||||
static isAutoSpendEnabled() {
|
||||
return game.settings.get("sla-industries-brp", "ammoAutoSpend") ?? this.DEFAULTS.ammoAutoSpend;
|
||||
}
|
||||
|
||||
static shouldNotify() {
|
||||
return game.settings.get("sla-industries-brp", "ammoNotify") ?? this.DEFAULTS.ammoNotify;
|
||||
}
|
||||
|
||||
static deficitMode() {
|
||||
const mode = String(game.settings.get("sla-industries-brp", "ammoDeficitMode") ?? this.DEFAULTS.ammoDeficitMode);
|
||||
if (["allow", "warn", "block"].includes(mode)) return mode;
|
||||
return this.DEFAULTS.ammoDeficitMode;
|
||||
}
|
||||
|
||||
static resolveWeapon(actor, itemId) {
|
||||
if (!actor || !itemId) return null;
|
||||
const weapon = actor.items.get(itemId);
|
||||
if (!weapon || weapon.type !== "weapon") return null;
|
||||
return weapon;
|
||||
}
|
||||
|
||||
static usesAmmo(weapon) {
|
||||
if (!weapon) return false;
|
||||
const cap = Number(weapon.system.ammo ?? 0);
|
||||
return cap > 0;
|
||||
}
|
||||
|
||||
static normalizeAmmoTag(tag = "") {
|
||||
const normal = String(tag ?? "").trim().toUpperCase();
|
||||
if (this.AMMO_TAGS.includes(normal)) return normal;
|
||||
return "STD";
|
||||
}
|
||||
|
||||
static getLoadedAmmoTag(weapon) {
|
||||
return this.normalizeAmmoTag(weapon?.system?.ammoLoadedType ?? weapon?.system?.ammoTag ?? "STD");
|
||||
}
|
||||
|
||||
static getWeaponActor(weapon, explicitActor = null) {
|
||||
if (explicitActor?.documentName === "Actor") return explicitActor;
|
||||
const parent = weapon?.parent ?? null;
|
||||
if (parent?.documentName === "Actor") return parent;
|
||||
return null;
|
||||
}
|
||||
|
||||
static resolveWeaponCalibre(weapon) {
|
||||
return SLAAmmoCatalog.resolveCalibre(weapon?.system?.ammoCalibre ?? "") ?? SLAAmmoCatalog.deriveWeaponCalibre(weapon);
|
||||
}
|
||||
|
||||
static getWeaponAllowedTags(weapon) {
|
||||
return SLAAmmoCatalog.getWeaponAllowedTags(weapon);
|
||||
}
|
||||
|
||||
static normalizeAllowedAmmoTag(weapon, tag) {
|
||||
const normal = this.normalizeAmmoTag(tag);
|
||||
const allowed = this.getWeaponAllowedTags(weapon);
|
||||
if (allowed.includes(normal)) return normal;
|
||||
return allowed[0] ?? "STD";
|
||||
}
|
||||
|
||||
static getWeaponAmmoProfile(weapon) {
|
||||
const calibre = this.resolveWeaponCalibre(weapon);
|
||||
const calibreLabel = calibre?.label ?? String(weapon?.system?.ammoCalibre ?? "").trim();
|
||||
const baseCost = SLAAmmoCatalog.getBaseRoundCost(calibreLabel || calibre?.key || "", weapon?.system?.ammoBaseCost ?? 0);
|
||||
const allowed = this.getWeaponAllowedTags(weapon);
|
||||
const selected = this.normalizeAllowedAmmoTag(weapon, weapon?.system?.ammoLoadedType ?? "STD");
|
||||
return {
|
||||
calibre,
|
||||
calibreLabel,
|
||||
baseCost,
|
||||
allowed,
|
||||
selected
|
||||
};
|
||||
}
|
||||
|
||||
static getAmmoDialogData(actor, weapon, fireMode = "single") {
|
||||
const profile = this.getWeaponAmmoProfile(weapon);
|
||||
const rounds = this.getSpendCost(weapon, fireMode);
|
||||
const options = {};
|
||||
for (const tag of profile.allowed) {
|
||||
const perRound = SLAAmmoCatalog.getCostPerRound(profile.calibreLabel || profile.calibre?.key || "", tag);
|
||||
const surcharge = Math.max(0, Math.round((perRound - profile.baseCost) * 100) / 100);
|
||||
const totalSurcharge = Math.round((surcharge * rounds) * 100) / 100;
|
||||
const surchargeLabel = totalSurcharge > 0 ? ` (extra ${totalSurcharge} CRD)` : "";
|
||||
options[tag] = `${tag} (${perRound} CRD/rd${surchargeLabel})`;
|
||||
}
|
||||
|
||||
const credits = this.getActorCreditsState(actor);
|
||||
return {
|
||||
ammoTagOptions: options,
|
||||
ammoTagSelected: profile.selected,
|
||||
ammoCalibreLabel: profile.calibreLabel || "-",
|
||||
ammoBaseCost: profile.baseCost,
|
||||
ammoRoundsSpent: rounds,
|
||||
actorCredits: credits.value,
|
||||
hasCredits: credits.path !== ""
|
||||
};
|
||||
}
|
||||
|
||||
static collectActorAmmoGear(actor) {
|
||||
if (!actor?.items) return [];
|
||||
const stacks = [];
|
||||
for (const item of actor.items) {
|
||||
if (item.type !== "gear") continue;
|
||||
const meta = SLAAmmoCatalog.getAmmoMetaFromItem(item);
|
||||
if (!meta) continue;
|
||||
const quantity = Math.max(0, Math.floor(Number(item.system?.quantity ?? 0)));
|
||||
stacks.push({ item, meta, quantity });
|
||||
}
|
||||
return stacks;
|
||||
}
|
||||
|
||||
static getGearReserveData(actor, weapon) {
|
||||
if (!actor || !weapon) return null;
|
||||
const calibre = this.resolveWeaponCalibre(weapon);
|
||||
if (!calibre) return null;
|
||||
const allStacks = this.collectActorAmmoGear(actor);
|
||||
const stacks = allStacks.filter((entry) => entry.meta.calibreKey === calibre.key);
|
||||
if (!stacks.length) return null;
|
||||
const total = stacks.reduce((sum, entry) => sum + entry.quantity, 0);
|
||||
return {
|
||||
calibre,
|
||||
total,
|
||||
stacks
|
||||
};
|
||||
}
|
||||
|
||||
static buildReserveUpdatesFromGear(gearData) {
|
||||
return {
|
||||
"system.ammoReserveStd": 0,
|
||||
"system.ammoReserveAp": 0,
|
||||
"system.ammoReserveHe": 0,
|
||||
"system.ammoReserveHeap": 0,
|
||||
"system.ammoReserve": Math.max(0, Number(gearData?.total ?? 0))
|
||||
};
|
||||
}
|
||||
|
||||
static applyDisplayReserves(actor, weaponData) {
|
||||
if (!weaponData?.system) return;
|
||||
const derivedCalibre = this.resolveWeaponCalibre(weaponData);
|
||||
if (derivedCalibre) {
|
||||
if (!String(weaponData.system.ammoCalibre ?? "").trim()) {
|
||||
weaponData.system.ammoCalibre = derivedCalibre.label;
|
||||
}
|
||||
if (Number(weaponData.system.ammoBaseCost ?? 0) <= 0) {
|
||||
weaponData.system.ammoBaseCost = Number(derivedCalibre.cost ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
weaponData.system.ammoLoadedType = this.normalizeAllowedAmmoTag(weaponData, weaponData.system.ammoLoadedType ?? "STD");
|
||||
weaponData.system.ammoAllowStd = typeof weaponData.system.ammoAllowStd === "boolean" ? weaponData.system.ammoAllowStd : true;
|
||||
weaponData.system.ammoAllowAp = typeof weaponData.system.ammoAllowAp === "boolean" ? weaponData.system.ammoAllowAp : true;
|
||||
weaponData.system.ammoAllowHe = typeof weaponData.system.ammoAllowHe === "boolean" ? weaponData.system.ammoAllowHe : true;
|
||||
weaponData.system.ammoAllowHeap = typeof weaponData.system.ammoAllowHeap === "boolean" ? weaponData.system.ammoAllowHeap : true;
|
||||
|
||||
const gearData = this.getGearReserveData(actor, weaponData);
|
||||
Object.assign(weaponData.system, {
|
||||
ammoReserveStd: 0,
|
||||
ammoReserveAp: 0,
|
||||
ammoReserveHe: 0,
|
||||
ammoReserveHeap: 0,
|
||||
ammoReserve: Math.max(0, Number(gearData?.total ?? 0))
|
||||
});
|
||||
}
|
||||
|
||||
static async consumeGearAmmo(actor, weapon, amount) {
|
||||
const required = Math.max(0, Number(amount ?? 0));
|
||||
if (!actor || !weapon || required <= 0) return { moved: 0, reserveBefore: 0, reserveAfter: 0 };
|
||||
const gearData = this.getGearReserveData(actor, weapon);
|
||||
if (!gearData) return { moved: 0, reserveBefore: 0, reserveAfter: 0 };
|
||||
const reserveBefore = Math.max(0, Number(gearData.total ?? 0));
|
||||
if (reserveBefore <= 0) return { moved: 0, reserveBefore, reserveAfter: reserveBefore };
|
||||
|
||||
let remaining = Math.min(required, reserveBefore);
|
||||
const updates = [];
|
||||
for (const stack of gearData.stacks) {
|
||||
if (remaining <= 0) break;
|
||||
if (stack.quantity <= 0) continue;
|
||||
const taken = Math.min(stack.quantity, remaining);
|
||||
if (taken <= 0) continue;
|
||||
updates.push({
|
||||
_id: stack.item.id,
|
||||
"system.quantity": stack.quantity - taken
|
||||
});
|
||||
remaining -= taken;
|
||||
}
|
||||
|
||||
if (updates.length) {
|
||||
await actor.updateEmbeddedDocuments("Item", updates);
|
||||
}
|
||||
|
||||
const moved = Math.min(required, reserveBefore) - remaining;
|
||||
const reserveAfter = reserveBefore - moved;
|
||||
return { moved, reserveBefore, reserveAfter };
|
||||
}
|
||||
|
||||
static getSpendCost(weapon, mode = "single") {
|
||||
const single = Math.max(1, Number(weapon.system.ammoPerShot ?? 1));
|
||||
const burst = Math.max(1, Number(weapon.system.ammoPerBurst ?? 3));
|
||||
const auto = Math.max(1, Number(weapon.system.ammoPerAuto ?? Math.max(10, Number(weapon.system.rof ?? 0))));
|
||||
switch (mode) {
|
||||
case "burst":
|
||||
return burst;
|
||||
case "auto":
|
||||
return auto;
|
||||
case "single":
|
||||
default:
|
||||
return single;
|
||||
}
|
||||
}
|
||||
|
||||
static getFireModeOptions(weapon) {
|
||||
const options = { single: game.i18n.localize("BRP.single") };
|
||||
if (!weapon) return options;
|
||||
const single = Math.max(1, Number(weapon.system?.ammoPerShot ?? 1));
|
||||
const burst = Math.max(1, Number(weapon.system?.ammoPerBurst ?? 3));
|
||||
const auto = Math.max(1, Number(weapon.system?.ammoPerAuto ?? Math.max(10, Number(weapon.system?.rof ?? 0))));
|
||||
const special = String(weapon.system?.special ?? "").toLowerCase();
|
||||
const rof = Number(weapon.system?.rof ?? 0);
|
||||
const canBurst = burst > single || ["burst", "auto", "fullauto"].includes(special) || rof >= 3;
|
||||
const canAuto = auto > burst || ["auto", "fullauto"].includes(special) || rof >= 5;
|
||||
if (canBurst) options.burst = game.i18n.localize("BRP.burst");
|
||||
if (canAuto) options.auto = game.i18n.localize("BRP.automatic");
|
||||
return options;
|
||||
}
|
||||
|
||||
static getReloadAmount(weapon) {
|
||||
const mag = Math.max(0, Number(weapon.system.ammo ?? 0));
|
||||
const fallback = Math.max(1, mag);
|
||||
return Math.max(1, Number(weapon.system.reloadAmount ?? fallback));
|
||||
}
|
||||
|
||||
static currentAmmo(weapon) {
|
||||
return Math.max(0, Number(weapon.system.ammoCurr ?? 0));
|
||||
}
|
||||
|
||||
static reserveAmmo(weapon) {
|
||||
return Math.max(0, Number(weapon.system.ammoReserve ?? 0));
|
||||
}
|
||||
|
||||
static async spend(weapon, { mode = "single", amount = null, notify = true, autoReload = true } = {}) {
|
||||
if (!this.usesAmmo(weapon)) return { ok: true, skipped: true };
|
||||
const cost = amount ?? this.getSpendCost(weapon, mode);
|
||||
const cap = Math.max(0, Number(weapon.system.ammo ?? 0));
|
||||
const actor = this.getWeaponActor(weapon);
|
||||
let current = this.currentAmmo(weapon);
|
||||
let reloaded = false;
|
||||
|
||||
if (cost > current && autoReload) {
|
||||
let guard = 0;
|
||||
while (cost > current && guard < 8) {
|
||||
const reloadResult = await this.reload(weapon, { notify: false });
|
||||
if (!reloadResult.ok || reloadResult.moved <= 0) break;
|
||||
current = reloadResult.after;
|
||||
reloaded = true;
|
||||
guard += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (cost > current) {
|
||||
if (notify) {
|
||||
ui.notifications.warn(`${weapon.name}: out of ammo (${current}/${cap})`);
|
||||
}
|
||||
return { ok: false, reason: "insufficient-ammo", cost, current };
|
||||
}
|
||||
|
||||
const next = Math.max(0, current - cost);
|
||||
await weapon.update({ "system.ammoCurr": next });
|
||||
if (notify && this.shouldNotify()) {
|
||||
const reloadLabel = reloaded ? ", auto-reload used" : "";
|
||||
ui.notifications.info(`${weapon.name}: ${current} -> ${next} ammo (${mode}${reloadLabel})`);
|
||||
}
|
||||
return { ok: true, before: current, after: next, cost, mode, reloaded, actor };
|
||||
}
|
||||
|
||||
static async reload(weapon, { notify = true, full = false } = {}) {
|
||||
if (!this.usesAmmo(weapon)) return { ok: true, skipped: true };
|
||||
|
||||
const cap = Math.max(0, Number(weapon.system.ammo ?? 0));
|
||||
const current = this.currentAmmo(weapon);
|
||||
const actor = this.getWeaponActor(weapon);
|
||||
const gearData = this.getGearReserveData(actor, weapon);
|
||||
const actorWeapon = Boolean(actor);
|
||||
const reserve = actorWeapon ? Math.max(0, Number(gearData?.total ?? 0)) : this.reserveAmmo(weapon);
|
||||
const reloadAmount = full ? cap : this.getReloadAmount(weapon);
|
||||
const calibreLabel = this.resolveWeaponCalibre(weapon)?.label ?? weapon.system?.ammoCalibre ?? "ammo";
|
||||
|
||||
if (current >= cap) {
|
||||
if (notify && this.shouldNotify()) {
|
||||
ui.notifications.info(`${weapon.name}: magazine already full`);
|
||||
}
|
||||
return { ok: true, before: current, after: current, reserveBefore: reserve, reserveAfter: reserve, moved: 0 };
|
||||
}
|
||||
if (reserve <= 0) {
|
||||
if (notify) {
|
||||
if (actorWeapon) {
|
||||
ui.notifications.warn(`${weapon.name}: no reserve ${calibreLabel} ammo in gear`);
|
||||
} else {
|
||||
ui.notifications.warn(`${weapon.name}: no reserve ammo`);
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: "no-reserve", before: current, reserveBefore: reserve, moved: 0 };
|
||||
}
|
||||
|
||||
const missing = cap - current;
|
||||
let moved = Math.min(missing, reloadAmount, reserve);
|
||||
let nextAmmo = current + moved;
|
||||
let nextReserve = reserve - moved;
|
||||
|
||||
const updates = {
|
||||
"system.ammoCurr": nextAmmo
|
||||
};
|
||||
if (actorWeapon) {
|
||||
const consumed = await this.consumeGearAmmo(actor, weapon, moved);
|
||||
moved = Math.max(0, Number(consumed.moved ?? 0));
|
||||
nextAmmo = current + moved;
|
||||
nextReserve = Math.max(0, Number(consumed.reserveAfter ?? reserve - moved));
|
||||
updates["system.ammoCurr"] = nextAmmo;
|
||||
const nextGearData = this.getGearReserveData(actor, weapon);
|
||||
Object.assign(updates, this.buildReserveUpdatesFromGear(nextGearData));
|
||||
} else {
|
||||
updates["system.ammoReserve"] = nextReserve;
|
||||
updates["system.ammoReserveStd"] = 0;
|
||||
updates["system.ammoReserveAp"] = 0;
|
||||
updates["system.ammoReserveHe"] = 0;
|
||||
updates["system.ammoReserveHeap"] = 0;
|
||||
}
|
||||
|
||||
await weapon.update(updates);
|
||||
if (notify && this.shouldNotify()) {
|
||||
const reloadLabel = full ? "full reload" : `reloaded +${moved}`;
|
||||
ui.notifications.info(`${weapon.name}: ${reloadLabel} (${nextAmmo}/${cap})`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
before: current,
|
||||
after: nextAmmo,
|
||||
reserveBefore: reserve,
|
||||
reserveAfter: nextReserve,
|
||||
moved
|
||||
};
|
||||
}
|
||||
|
||||
static async cycleAmmoType(weapon, { notify = true } = {}) {
|
||||
if (!weapon) return { ok: false, reason: "no-weapon" };
|
||||
const current = this.getLoadedAmmoTag(weapon);
|
||||
const allowed = this.getWeaponAllowedTags(weapon);
|
||||
const index = allowed.indexOf(current);
|
||||
const next = allowed[(index + 1) % allowed.length] ?? "STD";
|
||||
await weapon.update({ "system.ammoLoadedType": next });
|
||||
if (notify && this.shouldNotify()) {
|
||||
ui.notifications.info(`${weapon.name}: ammo type ${current} -> ${next}`);
|
||||
}
|
||||
return { ok: true, before: current, after: next };
|
||||
}
|
||||
|
||||
static getActorCreditsState(actor) {
|
||||
if (!actor) return { path: "", value: 0 };
|
||||
const slaCredits = Number(actor.system?.sla?.credits);
|
||||
if (Number.isFinite(slaCredits)) {
|
||||
return { path: "system.sla.credits", value: slaCredits };
|
||||
}
|
||||
const wealthValue = Number(actor.system?.wealthValue);
|
||||
if (Number.isFinite(wealthValue)) {
|
||||
return { path: "system.wealthValue", value: wealthValue };
|
||||
}
|
||||
return { path: "", value: 0 };
|
||||
}
|
||||
|
||||
static async deductCredits(actor, amount) {
|
||||
const charge = Math.max(0, Math.round(Number(amount ?? 0) * 100) / 100);
|
||||
if (!actor || charge <= 0) return { ok: true, skipped: true, before: 0, after: 0, spent: 0 };
|
||||
const state = this.getActorCreditsState(actor);
|
||||
if (!state.path) return { ok: true, skipped: true, before: 0, after: 0, spent: 0 };
|
||||
const after = Math.round((state.value - charge) * 100) / 100;
|
||||
await actor.update({ [state.path]: after });
|
||||
return {
|
||||
ok: true,
|
||||
before: state.value,
|
||||
after,
|
||||
spent: charge,
|
||||
path: state.path,
|
||||
deficit: after < 0,
|
||||
deficitAmount: after < 0 ? Math.abs(after) : 0
|
||||
};
|
||||
}
|
||||
|
||||
static calculateSurchargeForShot(weapon, ammoTag, rounds = 1) {
|
||||
const profile = this.getWeaponAmmoProfile(weapon);
|
||||
const tag = this.normalizeAllowedAmmoTag(weapon, ammoTag);
|
||||
const perRound = SLAAmmoCatalog.getCostPerRound(profile.calibreLabel || profile.calibre?.key || "", tag);
|
||||
const surchargePerRound = Math.max(0, Math.round((perRound - profile.baseCost) * 100) / 100);
|
||||
const totalSurcharge = Math.round((surchargePerRound * Math.max(1, Number(rounds ?? 1))) * 100) / 100;
|
||||
return {
|
||||
tag,
|
||||
perRound,
|
||||
surchargePerRound,
|
||||
totalSurcharge,
|
||||
baseCost: profile.baseCost,
|
||||
calibreLabel: profile.calibreLabel
|
||||
};
|
||||
}
|
||||
|
||||
static async consumeForCheck(config, actor) {
|
||||
if (!this.isTrackingEnabled() || !this.isAutoSpendEnabled()) return { ok: true, skipped: true };
|
||||
if (!["CM", "QC"].includes(config.rollType)) return { ok: true, skipped: true };
|
||||
|
||||
const weapon = this.resolveWeapon(actor, config.itemId);
|
||||
if (!weapon || !this.usesAmmo(weapon)) return { ok: true, skipped: true };
|
||||
|
||||
const mode = config.fireMode ?? "single";
|
||||
const rounds = this.getSpendCost(weapon, mode);
|
||||
const surcharge = this.calculateSurchargeForShot(weapon, config.ammoTag ?? this.getLoadedAmmoTag(weapon), rounds);
|
||||
config.ammoTag = surcharge.tag;
|
||||
await weapon.update({ "system.ammoLoadedType": surcharge.tag });
|
||||
|
||||
const creditState = this.getActorCreditsState(actor);
|
||||
const roundCostTotal = Math.round((Number(surcharge.perRound ?? 0) * Number(rounds ?? 0)) * 100) / 100;
|
||||
const deficitMode = this.deficitMode();
|
||||
|
||||
config.ammoCreditsSpent = 0;
|
||||
config.ammoCreditsBefore = creditState.value;
|
||||
config.ammoCreditsAfter = creditState.value;
|
||||
config.ammoRoundCost = surcharge.perRound;
|
||||
config.ammoSurchargePerRound = surcharge.surchargePerRound;
|
||||
config.ammoCreditsDeficit = false;
|
||||
config.ammoCreditsDeficitAmount = 0;
|
||||
config.ammoCostTotal = roundCostTotal;
|
||||
config.fireMode = mode;
|
||||
|
||||
if (
|
||||
actor?.type === "character" &&
|
||||
deficitMode === "block" &&
|
||||
Number(roundCostTotal ?? 0) > 0 &&
|
||||
Number(creditState.value ?? 0) < Number(roundCostTotal ?? 0)
|
||||
) {
|
||||
ui.notifications.warn(`${weapon.name}: insufficient CRD (${creditState.value.toFixed(2)} available, ${roundCostTotal.toFixed(2)} required).`);
|
||||
return { ok: false, reason: "insufficient-credits", required: roundCostTotal, current: creditState.value };
|
||||
}
|
||||
|
||||
const spendResult = await this.spend(weapon, { mode, amount: rounds, notify: false, autoReload: true });
|
||||
if (!spendResult.ok) return spendResult;
|
||||
|
||||
if (Number(roundCostTotal ?? 0) > 0 && actor?.type === "character") {
|
||||
const charge = await this.deductCredits(actor, roundCostTotal);
|
||||
if (!charge.ok) {
|
||||
return { ok: false, reason: "credit-update-failed", required: roundCostTotal };
|
||||
}
|
||||
config.ammoCreditsSpent = Number(charge.spent ?? 0);
|
||||
config.ammoCreditsBefore = Number(charge.before ?? config.ammoCreditsBefore);
|
||||
config.ammoCreditsAfter = Number(charge.after ?? config.ammoCreditsAfter);
|
||||
config.ammoCreditsDeficit = Boolean(charge.deficit);
|
||||
config.ammoCreditsDeficitAmount = Number(charge.deficitAmount ?? 0);
|
||||
if (config.ammoCreditsDeficit && deficitMode === "warn") {
|
||||
ui.notifications.warn(`${weapon.name}: CRD deficit ${config.ammoCreditsDeficitAmount.toFixed(2)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
rounds,
|
||||
mode,
|
||||
ammoTag: surcharge.tag,
|
||||
ammoRoundCost: surcharge.perRound,
|
||||
ammoSurchargePerRound: surcharge.surchargePerRound,
|
||||
ammoCostTotal: roundCostTotal,
|
||||
ammoCreditsSpent: config.ammoCreditsSpent,
|
||||
ammoCreditsBefore: config.ammoCreditsBefore,
|
||||
ammoCreditsAfter: config.ammoCreditsAfter,
|
||||
ammoCreditsDeficit: config.ammoCreditsDeficit,
|
||||
ammoCreditsDeficitAmount: config.ammoCreditsDeficitAmount
|
||||
};
|
||||
}
|
||||
|
||||
static async handleSheetAction({ actor, itemId, action }) {
|
||||
const weapon = this.resolveWeapon(actor, itemId);
|
||||
if (!weapon) return { ok: false, reason: "no-weapon" };
|
||||
|
||||
switch (action) {
|
||||
case "reload":
|
||||
return this.reload(weapon, { notify: true });
|
||||
case "reloadFull":
|
||||
return this.reload(weapon, { notify: true, full: true });
|
||||
case "cycleType":
|
||||
return this.cycleAmmoType(weapon, { notify: true });
|
||||
case "single":
|
||||
case "burst":
|
||||
case "auto":
|
||||
return this.spend(weapon, { mode: action, notify: true });
|
||||
default:
|
||||
return { ok: false, reason: "unknown-action" };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import { BRPUtilities } from "./utilities.mjs";
|
||||
import { BRPactorItemDrop } from "../actor/actor-itemDrop.mjs";
|
||||
import { SLASeedImporter } from "./sla-seed-importer.mjs";
|
||||
import { SLASkillPoints } from "./sla-skill-points.mjs";
|
||||
|
||||
const STAT_KEYS = ["str", "con", "siz", "int", "pow", "dex", "cha", "edu"];
|
||||
|
||||
export class SLACharacterGenerator {
|
||||
static async createCharacter({
|
||||
name = "New Operative",
|
||||
species = "Human",
|
||||
packageName = null,
|
||||
rollCharacteristics = true,
|
||||
assignLoadout = true,
|
||||
pulpSla = null
|
||||
} = {}) {
|
||||
await this.applyPulpRuleChoice(pulpSla);
|
||||
|
||||
const actor = await Actor.create({
|
||||
name,
|
||||
type: "character"
|
||||
});
|
||||
await SLASkillPoints.initializeForActor({
|
||||
actor,
|
||||
trainingPackage: null,
|
||||
resetAllocations: false,
|
||||
creationMode: true
|
||||
});
|
||||
|
||||
const applied = await this.applyTemplate({
|
||||
actor,
|
||||
species,
|
||||
packageName,
|
||||
rollCharacteristics,
|
||||
assignLoadout
|
||||
});
|
||||
|
||||
ui.notifications.info(
|
||||
`SLA character generated: ${actor.name} (${applied.species ?? "Unknown"} / ${applied.trainingPackage ?? "No package"})`
|
||||
);
|
||||
return { actor, ...applied };
|
||||
}
|
||||
|
||||
static async applyPulpRuleChoice(pulpSla = null) {
|
||||
if (pulpSla === null || pulpSla === undefined) return;
|
||||
if (!game.user?.isGM) return;
|
||||
const enabled = Boolean(pulpSla);
|
||||
const current = Boolean(game.settings.get("sla-industries-brp", "pulpSla"));
|
||||
if (current === enabled) return;
|
||||
await game.settings.set("sla-industries-brp", "pulpSla", enabled);
|
||||
}
|
||||
|
||||
static async applyTemplate({
|
||||
actor,
|
||||
species,
|
||||
packageName = null,
|
||||
rollCharacteristics = true,
|
||||
assignLoadout = true,
|
||||
overwrite = true
|
||||
} = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
if (!actorDoc) {
|
||||
throw new Error("SLACharacterGenerator.applyTemplate requires a valid actor.");
|
||||
}
|
||||
|
||||
const speciesDoc = await this.resolveSeedItem({
|
||||
type: "culture",
|
||||
ref: species,
|
||||
folderName: "SLA Species"
|
||||
});
|
||||
if (!speciesDoc) {
|
||||
throw new Error(`Unable to find SLA species: ${species}`);
|
||||
}
|
||||
|
||||
const speciesResult = await this.applySpecies({
|
||||
actor: actorDoc,
|
||||
species: speciesDoc,
|
||||
rollCharacteristics,
|
||||
overwrite
|
||||
});
|
||||
|
||||
const trainingPackageDoc = await this.resolveStarterPackage(speciesDoc, packageName);
|
||||
let packageResult = { applied: false, trainingPackage: null };
|
||||
if (trainingPackageDoc) {
|
||||
packageResult = await this.applyTrainingPackage({
|
||||
actor: actorDoc,
|
||||
trainingPackage: trainingPackageDoc,
|
||||
assignLoadout,
|
||||
overwrite
|
||||
});
|
||||
} else {
|
||||
ui.notifications.warn("No SLA training package found for starter assignment.");
|
||||
}
|
||||
|
||||
return {
|
||||
species: speciesResult.species,
|
||||
rolls: speciesResult.rolls,
|
||||
trainingPackage: packageResult.trainingPackage,
|
||||
skills: packageResult.skills,
|
||||
loadout: packageResult.loadout
|
||||
};
|
||||
}
|
||||
|
||||
static async applySpecies({
|
||||
actor,
|
||||
species,
|
||||
rollCharacteristics = true,
|
||||
overwrite = true
|
||||
} = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
const speciesDoc = await this.resolveSeedItem({
|
||||
type: "culture",
|
||||
ref: species,
|
||||
folderName: "SLA Species"
|
||||
});
|
||||
|
||||
if (!actorDoc || !speciesDoc) {
|
||||
throw new Error("applySpecies requires a valid actor and species.");
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
await this.clearActorCulture(actorDoc);
|
||||
}
|
||||
|
||||
const speciesBrpid = this.getBRPID(speciesDoc);
|
||||
const hasSpecies = actorDoc.items.some(
|
||||
(item) => item.type === "culture" && this.getBRPID(item) === speciesBrpid
|
||||
);
|
||||
if (!hasSpecies) {
|
||||
await actorDoc.createEmbeddedDocuments("Item", [this.toEmbeddedData(speciesDoc)]);
|
||||
}
|
||||
|
||||
const speciesMeta = this.getScopeFlag(speciesDoc, "slaSpecies") ?? {};
|
||||
const updates = {
|
||||
"system.culture": speciesDoc.name,
|
||||
"system.move": Number(speciesDoc.system?.move ?? actorDoc.system.move ?? 0)
|
||||
};
|
||||
|
||||
if (Number.isFinite(Number(speciesMeta.startingScl))) {
|
||||
updates["system.sla.scl"] = Number(speciesMeta.startingScl);
|
||||
}
|
||||
|
||||
const rolls = {};
|
||||
for (const key of STAT_KEYS) {
|
||||
const stat = speciesDoc.system?.stats?.[key];
|
||||
if (!stat) continue;
|
||||
|
||||
const formula = String(stat.formula ?? "").trim();
|
||||
updates[`system.stats.${key}.formula`] = formula;
|
||||
updates[`system.stats.${key}.culture`] = Number(stat.mod ?? 0);
|
||||
|
||||
if (rollCharacteristics && formula) {
|
||||
const rolled = await this.rollFormula(formula);
|
||||
if (rolled !== null) {
|
||||
updates[`system.stats.${key}.base`] = rolled;
|
||||
rolls[key] = rolled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await actorDoc.update(updates);
|
||||
|
||||
// Keep SLA skill roster aligned with species for both manual and auto template flows.
|
||||
if (game.brp?.BRPActor?.ensureSLASkillRoster) {
|
||||
const includeEbb = game.brp?.SLAEbbSystem?.isEbbSpecies?.(actorDoc) ?? false;
|
||||
await game.brp.BRPActor.ensureSLASkillRoster(actorDoc, {
|
||||
includeEbb,
|
||||
pruneEbbForNonEligible: true
|
||||
});
|
||||
}
|
||||
|
||||
const skillBonuses = Array.isArray(speciesMeta.skillBonuses) ? speciesMeta.skillBonuses : [];
|
||||
const skillBonusResult = await this.applySpeciesSkillBonuses(actorDoc, skillBonuses);
|
||||
const choiceBonuses = Array.isArray(speciesMeta.choiceBonuses) ? speciesMeta.choiceBonuses : [];
|
||||
if (choiceBonuses.length) {
|
||||
await actorDoc.setFlag(game.system.id, "slaPendingSkillChoices", choiceBonuses);
|
||||
const totalChoices = choiceBonuses.reduce(
|
||||
(acc, row) => acc + Math.max(0, Number(row?.count ?? 0)),
|
||||
0
|
||||
);
|
||||
if (totalChoices > 0) {
|
||||
ui.notifications.info(
|
||||
`${actorDoc.name}: ${speciesDoc.name} has ${totalChoices} unassigned species skill choice bonus(es).`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await actorDoc.unsetFlag(game.system.id, "slaPendingSkillChoices");
|
||||
}
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
species: speciesDoc.name,
|
||||
rolls,
|
||||
skillBonuses: skillBonusResult,
|
||||
pendingChoices: choiceBonuses
|
||||
};
|
||||
}
|
||||
|
||||
static async applySpeciesSkillBonuses(actor, bonusRows = []) {
|
||||
const normalized = [];
|
||||
for (const row of bonusRows ?? []) {
|
||||
const skillRef = String(row?.skillRef ?? row?.ref ?? "").trim();
|
||||
const bonus = Number(row?.bonus ?? 0);
|
||||
if (!skillRef || !Number.isFinite(bonus) || bonus === 0) continue;
|
||||
normalized.push({ skillRef, bonus });
|
||||
}
|
||||
if (!normalized.length) {
|
||||
return { created: 0, updated: 0, missing: [], applied: [] };
|
||||
}
|
||||
|
||||
const aggregate = new Map();
|
||||
const missing = [];
|
||||
for (const row of normalized) {
|
||||
const brpid = await SLASeedImporter.resolveSkillBrpid(row.skillRef);
|
||||
if (!brpid || brpid === "none") {
|
||||
missing.push(row.skillRef);
|
||||
continue;
|
||||
}
|
||||
aggregate.set(brpid, Number(aggregate.get(brpid) ?? 0) + row.bonus);
|
||||
}
|
||||
|
||||
const ensured = await this.ensureSkills(actor, [...aggregate.keys()], "culture");
|
||||
const updates = [];
|
||||
const applied = [];
|
||||
for (const item of actor.items.filter((i) => i.type === "skill")) {
|
||||
const brpid = this.getBRPID(item);
|
||||
if (!brpid || !aggregate.has(brpid)) continue;
|
||||
const delta = Number(aggregate.get(brpid) ?? 0);
|
||||
const next = Number(item.system?.culture ?? 0) + delta;
|
||||
updates.push({
|
||||
_id: item.id,
|
||||
"system.cultural": true,
|
||||
"system.culture": next
|
||||
});
|
||||
applied.push({ name: item.name, bonus: delta });
|
||||
}
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actor });
|
||||
}
|
||||
|
||||
return {
|
||||
created: ensured.created,
|
||||
updated: ensured.updated + updates.length,
|
||||
missing: [...new Set([...(ensured.missing ?? []), ...missing])],
|
||||
applied
|
||||
};
|
||||
}
|
||||
|
||||
static async applyTrainingPackage({
|
||||
actor,
|
||||
trainingPackage,
|
||||
assignLoadout = true,
|
||||
overwrite = true
|
||||
} = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
const packageDoc = await this.resolveSeedItem({
|
||||
type: "profession",
|
||||
ref: trainingPackage,
|
||||
folderName: "SLA Training Packages"
|
||||
});
|
||||
|
||||
if (!actorDoc || !packageDoc) {
|
||||
throw new Error("applyTrainingPackage requires a valid actor and profession package.");
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
await this.clearActorProfession(actorDoc);
|
||||
}
|
||||
|
||||
const packageBrpid = this.getBRPID(packageDoc);
|
||||
const hasPackage = actorDoc.items.some(
|
||||
(item) => item.type === "profession" && this.getBRPID(item) === packageBrpid
|
||||
);
|
||||
if (!hasPackage) {
|
||||
await actorDoc.createEmbeddedDocuments("Item", [this.toEmbeddedData(packageDoc)]);
|
||||
}
|
||||
|
||||
const trainingMeta = this.getScopeFlag(packageDoc, "slaTraining") ?? {};
|
||||
const skillBrpids = await this.resolveTrainingSkillBrpids(packageDoc, trainingMeta);
|
||||
|
||||
const skills = await this.ensureSkills(actorDoc, skillBrpids, "profession");
|
||||
|
||||
let weaponBrpids = [...(trainingMeta.weaponBrpids ?? [])];
|
||||
let armourBrpids = [...(trainingMeta.armourBrpids ?? [])];
|
||||
if (assignLoadout && (!weaponBrpids.length && !armourBrpids.length)) {
|
||||
const fallback = await this.resolveLoadoutFromSeedNames(packageDoc.name);
|
||||
weaponBrpids = fallback.weaponBrpids;
|
||||
armourBrpids = fallback.armourBrpids;
|
||||
}
|
||||
|
||||
const loadout = assignLoadout
|
||||
? await this.ensureEquipment(actorDoc, weaponBrpids, armourBrpids)
|
||||
: { createdWeapons: 0, createdArmour: 0, missing: [], skipped: true };
|
||||
|
||||
const updates = {
|
||||
"system.professionName": packageDoc.name,
|
||||
"system.creationMode": true,
|
||||
"system.trainingPackage.name": packageDoc.name,
|
||||
"system.trainingPackage.professionalSkills": [...skillBrpids]
|
||||
};
|
||||
if (Number.isFinite(Number(trainingMeta.startingCredits))) {
|
||||
updates["system.wealthValue"] = Number(trainingMeta.startingCredits);
|
||||
}
|
||||
await actorDoc.update(updates);
|
||||
await SLASkillPoints.initializeForActor({
|
||||
actor: actorDoc,
|
||||
trainingPackage: packageDoc,
|
||||
resetAllocations: Boolean(overwrite),
|
||||
preserveGeneral: !overwrite,
|
||||
creationMode: true,
|
||||
seedPackageProfessional: true,
|
||||
professionalSeedPerSkill: 20
|
||||
});
|
||||
|
||||
if (!skillBrpids.length) {
|
||||
ui.notifications.warn(`${actorDoc.name}: ${packageDoc.name} has no resolved professional skills. Check package seed/link data.`);
|
||||
}
|
||||
|
||||
return {
|
||||
applied: true,
|
||||
trainingPackage: packageDoc.name,
|
||||
skills,
|
||||
loadout
|
||||
};
|
||||
}
|
||||
|
||||
static async resolveTrainingSkillBrpids(packageDoc, trainingMeta = null) {
|
||||
const resolved = [];
|
||||
const add = (brpid) => {
|
||||
const value = String(brpid ?? "").trim();
|
||||
if (!value || value === "none" || resolved.includes(value)) return;
|
||||
resolved.push(value);
|
||||
};
|
||||
|
||||
const meta = trainingMeta ?? this.getScopeFlag(packageDoc, "slaTraining") ?? {};
|
||||
for (const brpid of (meta.skillBrpids ?? [])) {
|
||||
add(brpid);
|
||||
}
|
||||
|
||||
if (!resolved.length) {
|
||||
for (const ref of (meta.skillRefs ?? [])) {
|
||||
const brpid = await SLASeedImporter.resolveSkillBrpid(ref);
|
||||
add(brpid);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.length) {
|
||||
for (const row of (packageDoc?.system?.skills ?? [])) {
|
||||
add(row?.brpid);
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.length) {
|
||||
const seed = await SLASeedImporter.loadSeed("training-packages.json").catch(() => null);
|
||||
const rows = Array.isArray(seed?.packages) ? seed.packages : [];
|
||||
const match = rows.find((row) =>
|
||||
String(row?.name ?? "").trim().toLowerCase() === String(packageDoc?.name ?? "").trim().toLowerCase()
|
||||
);
|
||||
if (match) {
|
||||
for (const ref of (match.skills ?? [])) {
|
||||
const brpid = await SLASeedImporter.resolveSkillBrpid(ref);
|
||||
add(brpid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static async resolveStarterPackage(speciesDoc, packageName = null) {
|
||||
if (packageName) {
|
||||
return this.resolveSeedItem({
|
||||
type: "profession",
|
||||
ref: packageName,
|
||||
folderName: "SLA Training Packages"
|
||||
});
|
||||
}
|
||||
|
||||
const speciesMeta = this.getScopeFlag(speciesDoc, "slaSpecies") ?? {};
|
||||
if (speciesMeta.starterPackage) {
|
||||
const fromSpecies = await this.resolveSeedItem({
|
||||
type: "profession",
|
||||
ref: speciesMeta.starterPackage,
|
||||
folderName: "SLA Training Packages"
|
||||
});
|
||||
if (fromSpecies) return fromSpecies;
|
||||
}
|
||||
|
||||
const folder = this.findFolder("SLA Training Packages");
|
||||
const choices = game.items.filter(
|
||||
(item) => item.type === "profession" && (!folder || item.folder?.id === folder.id)
|
||||
);
|
||||
choices.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return choices[0] ?? null;
|
||||
}
|
||||
|
||||
static async resolveSeedItem({ type, ref, folderName } = {}) {
|
||||
if (!ref) return null;
|
||||
if (ref.type === type) return ref;
|
||||
|
||||
let byBrpid = null;
|
||||
if (typeof ref === "string" && ref.startsWith("i.")) {
|
||||
byBrpid = await this.resolveByBrpid(ref, type);
|
||||
if (byBrpid) return byBrpid;
|
||||
}
|
||||
|
||||
const folder = this.findFolder(folderName);
|
||||
const pool = game.items.filter(
|
||||
(item) => item.type === type && (!folder || item.folder?.id === folder.id)
|
||||
);
|
||||
if (!pool.length) return byBrpid;
|
||||
|
||||
const normRef = this.normalizeText(ref);
|
||||
const exact = pool.find((item) => this.normalizeText(item.name) === normRef);
|
||||
if (exact) return exact;
|
||||
|
||||
const fuzzy = pool.find((item) => this.normalizeText(item.name).includes(normRef));
|
||||
if (fuzzy) return fuzzy;
|
||||
|
||||
if (typeof ref === "string") {
|
||||
const slug = BRPUtilities.toKebabCase(ref);
|
||||
const expectedBrpid = `i.${type}.sla-${slug}`;
|
||||
byBrpid = await this.resolveByBrpid(expectedBrpid, type);
|
||||
if (byBrpid) return byBrpid;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async resolveByBrpid(brpid, type = null) {
|
||||
const matches = await game.system.api.brpid.fromBRPIDBest({ brpid });
|
||||
if (!type) return matches[0] ?? null;
|
||||
return matches.find((doc) => doc.type === type) ?? null;
|
||||
}
|
||||
|
||||
static async clearActorCulture(actor) {
|
||||
const updates = [];
|
||||
for (const item of actor.items) {
|
||||
if (["skill", "magic", "psychic"].includes(item.type)) {
|
||||
updates.push({
|
||||
_id: item.id,
|
||||
"system.cultural": false,
|
||||
"system.culture": 0
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actor });
|
||||
}
|
||||
|
||||
const cultures = actor.items.filter((item) => item.type === "culture").map((item) => item.id);
|
||||
if (cultures.length) {
|
||||
await actor.deleteEmbeddedDocuments("Item", cultures);
|
||||
}
|
||||
}
|
||||
|
||||
static async clearActorProfession(actor) {
|
||||
const updates = [];
|
||||
for (const item of actor.items) {
|
||||
if (["skill", "magic", "psychic"].includes(item.type)) {
|
||||
updates.push({
|
||||
_id: item.id,
|
||||
"system.occupation": false,
|
||||
"system.profession": 0
|
||||
});
|
||||
}
|
||||
}
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actor });
|
||||
}
|
||||
|
||||
const professions = actor.items.filter((item) => item.type === "profession").map((item) => item.id);
|
||||
if (professions.length) {
|
||||
await actor.deleteEmbeddedDocuments("Item", professions);
|
||||
}
|
||||
}
|
||||
|
||||
static async ensureSkills(actor, skillBrpids = [], source = "profession") {
|
||||
const unique = [...new Set(skillBrpids.filter((id) => id && id !== "none"))];
|
||||
const existing = new Map();
|
||||
for (const item of actor.items.filter((i) => i.type === "skill")) {
|
||||
const id = this.getBRPID(item);
|
||||
if (id) existing.set(id, item);
|
||||
}
|
||||
|
||||
const createData = [];
|
||||
const updateData = [];
|
||||
const missing = [];
|
||||
|
||||
for (const brpid of unique) {
|
||||
const current = existing.get(brpid);
|
||||
if (current) {
|
||||
if (source === "profession") {
|
||||
updateData.push({ _id: current.id, "system.occupation": true });
|
||||
} else if (source === "culture") {
|
||||
updateData.push({ _id: current.id, "system.cultural": true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceDoc = await this.resolveByBrpid(brpid, "skill");
|
||||
if (!sourceDoc) {
|
||||
missing.push(brpid);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = this.toEmbeddedData(sourceDoc);
|
||||
data.system = data.system ?? {};
|
||||
if (source === "profession") {
|
||||
data.system.occupation = true;
|
||||
} else if (source === "culture") {
|
||||
data.system.cultural = true;
|
||||
}
|
||||
data.system.base = await BRPactorItemDrop._calcBase(data, actor);
|
||||
createData.push(data);
|
||||
}
|
||||
|
||||
if (createData.length) {
|
||||
await actor.createEmbeddedDocuments("Item", createData);
|
||||
}
|
||||
if (updateData.length) {
|
||||
await Item.updateDocuments(updateData, { parent: actor });
|
||||
}
|
||||
|
||||
return {
|
||||
created: createData.length,
|
||||
updated: updateData.length,
|
||||
missing
|
||||
};
|
||||
}
|
||||
|
||||
static async ensureEquipment(actor, weaponBrpids = [], armourBrpids = []) {
|
||||
const existing = new Set();
|
||||
for (const item of actor.items.filter((i) => i.type === "weapon" || i.type === "armour")) {
|
||||
const brpid = this.getBRPID(item);
|
||||
if (brpid) existing.add(brpid);
|
||||
}
|
||||
|
||||
const createData = [];
|
||||
const missing = [];
|
||||
let createdWeapons = 0;
|
||||
let createdArmour = 0;
|
||||
|
||||
const ensureList = async (list, expectedType) => {
|
||||
for (const brpid of [...new Set((list ?? []).filter((id) => id && id !== "none"))]) {
|
||||
if (existing.has(brpid)) continue;
|
||||
const sourceDoc = await this.resolveByBrpid(brpid, expectedType);
|
||||
if (!sourceDoc) {
|
||||
missing.push(brpid);
|
||||
continue;
|
||||
}
|
||||
const data = this.toEmbeddedData(sourceDoc);
|
||||
data.system = data.system ?? {};
|
||||
data.system.equipStatus = "carried";
|
||||
if (expectedType === "weapon") {
|
||||
data.system.ammoCurr = Number(data.system.ammo ?? data.system.ammoCurr ?? 0);
|
||||
data.system.ammoLoadedType = String(data.system.ammoLoadedType ?? data.system.ammoTag ?? "STD").toUpperCase();
|
||||
createdWeapons++;
|
||||
} else {
|
||||
createdArmour++;
|
||||
}
|
||||
createData.push(data);
|
||||
}
|
||||
};
|
||||
|
||||
await ensureList(weaponBrpids, "weapon");
|
||||
await ensureList(armourBrpids, "armour");
|
||||
|
||||
if (createData.length) {
|
||||
await actor.createEmbeddedDocuments("Item", createData);
|
||||
}
|
||||
|
||||
return {
|
||||
createdWeapons,
|
||||
createdArmour,
|
||||
missing
|
||||
};
|
||||
}
|
||||
|
||||
static async resolveLoadoutFromSeedNames(packageName) {
|
||||
const out = {
|
||||
weaponBrpids: [],
|
||||
armourBrpids: []
|
||||
};
|
||||
|
||||
const data = await SLASeedImporter.loadSeed("equipment.json").catch(() => null);
|
||||
if (!data) return out;
|
||||
|
||||
const loadout = data.packageLoadouts?.[packageName];
|
||||
if (!loadout) return out;
|
||||
|
||||
const weaponPool = game.items.filter((item) => item.type === "weapon");
|
||||
const armourPool = game.items.filter((item) => item.type === "armour");
|
||||
const weaponIndex = this.indexByName(weaponPool);
|
||||
const armourIndex = this.indexByName(armourPool);
|
||||
|
||||
for (const name of loadout.weapons ?? []) {
|
||||
const item = weaponIndex.get(this.normalizeText(name));
|
||||
const brpid = this.getBRPID(item);
|
||||
if (brpid) out.weaponBrpids.push(brpid);
|
||||
}
|
||||
for (const name of loadout.armour ?? []) {
|
||||
const item = armourIndex.get(this.normalizeText(name));
|
||||
const brpid = this.getBRPID(item);
|
||||
if (brpid) out.armourBrpids.push(brpid);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
static resolveActor(actor) {
|
||||
if (!actor) return null;
|
||||
if (actor instanceof Actor) return actor;
|
||||
if (typeof actor === "string") {
|
||||
return game.actors.get(actor) ?? game.actors.getName(actor) ?? null;
|
||||
}
|
||||
if (actor.id) {
|
||||
return game.actors.get(actor.id) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static findFolder(name) {
|
||||
return game.folders.find(
|
||||
(folder) => folder.type === "Item" && folder.name === name && !folder.folder
|
||||
);
|
||||
}
|
||||
|
||||
static toEmbeddedData(item) {
|
||||
const data = item.toObject();
|
||||
delete data._id;
|
||||
delete data.folder;
|
||||
delete data.sort;
|
||||
delete data.ownership;
|
||||
delete data.pack;
|
||||
|
||||
const brpid = this.getBRPID(item);
|
||||
if (brpid) {
|
||||
data.flags = SLASeedImporter.buildFlags(brpid, data.flags ?? {});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static getBRPID(document) {
|
||||
if (!document) return null;
|
||||
return document.flags?.[game.system.id]?.brpidFlag?.id ?? document.flags?.brp?.brpidFlag?.id ?? null;
|
||||
}
|
||||
|
||||
static getScopeFlag(document, key) {
|
||||
if (!document) return null;
|
||||
return document.flags?.[game.system.id]?.[key] ?? document.flags?.brp?.[key] ?? null;
|
||||
}
|
||||
|
||||
static indexByName(items) {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
map.set(this.normalizeText(item.name), item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
static normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
static async rollFormula(formula) {
|
||||
try {
|
||||
const roll = new Roll(formula);
|
||||
await roll.evaluate();
|
||||
return Number(roll.total ?? roll.result ?? 0);
|
||||
} catch (err) {
|
||||
console.warn("sla-industries-brp | Could not roll formula", { formula, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
export class SLADialog {
|
||||
static _asElement(source) {
|
||||
if (!source) return null;
|
||||
if (source instanceof HTMLElement) return source;
|
||||
if (Array.isArray(source) && source[0] instanceof HTMLElement) return source[0];
|
||||
if (source?.[0] instanceof HTMLElement) return source[0];
|
||||
if (typeof source?.get === "function") {
|
||||
const fromGet = source.get(0);
|
||||
if (fromGet instanceof HTMLElement) return fromGet;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static _extractRoot(source) {
|
||||
if (!source) return null;
|
||||
const direct = this._asElement(source);
|
||||
if (direct) return direct;
|
||||
|
||||
const candidates = [
|
||||
source?.element,
|
||||
source?.window?.content,
|
||||
source?.content,
|
||||
source?.window,
|
||||
source?.root,
|
||||
source?.parent?.element
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const element = this._asElement(candidate);
|
||||
if (element) return element;
|
||||
}
|
||||
|
||||
if (typeof source?.querySelector === "function") return source;
|
||||
return null;
|
||||
}
|
||||
|
||||
static _findForm(source, selector = "form") {
|
||||
const root = this._extractRoot(source);
|
||||
if (root && typeof root.querySelector === "function") {
|
||||
const fromRoot = root.querySelector(selector) ?? (selector !== "form" ? root.querySelector("form") : null);
|
||||
if (fromRoot) return fromRoot;
|
||||
}
|
||||
|
||||
// Final fallback for DialogV2 wrapper edge-cases: search latest open dialog.
|
||||
if (typeof document !== "undefined") {
|
||||
const exact = document.querySelector(selector);
|
||||
if (exact) return exact;
|
||||
const openDialogs = Array.from(document.querySelectorAll(".app.window-app.dialog, .application.dialog"));
|
||||
for (let i = openDialogs.length - 1; i >= 0; i -= 1) {
|
||||
const dlg = openDialogs[i];
|
||||
const candidate = dlg.querySelector(selector) ?? (selector !== "form" ? dlg.querySelector("form") : null);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async waitForm({
|
||||
title = "Dialog",
|
||||
content = "",
|
||||
formSelector = "form",
|
||||
submitLabel = "Proceed",
|
||||
cancelLabel = "Cancel",
|
||||
onSubmit = null,
|
||||
onRender = null,
|
||||
cancelValue = false
|
||||
} = {}) {
|
||||
const useV2 = Boolean(foundry?.applications?.api?.DialogV2?.wait);
|
||||
|
||||
if (useV2) {
|
||||
try {
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title },
|
||||
content,
|
||||
buttons: [
|
||||
{
|
||||
action: "submit",
|
||||
label: submitLabel,
|
||||
default: true,
|
||||
callback: async (_event, _button, dialog) => {
|
||||
const form = this._findForm(dialog, formSelector);
|
||||
if (!form) {
|
||||
ui.notifications.error("Dialog form not found.");
|
||||
return cancelValue;
|
||||
}
|
||||
const fd = new FormData(form);
|
||||
if (typeof onSubmit === "function") {
|
||||
return onSubmit(fd, form, dialog);
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "cancel",
|
||||
label: cancelLabel,
|
||||
callback: () => cancelValue
|
||||
}
|
||||
],
|
||||
render: (_event, dialog) => {
|
||||
if (typeof onRender === "function") {
|
||||
onRender(dialog?.element ?? null, dialog);
|
||||
}
|
||||
},
|
||||
close: () => cancelValue
|
||||
});
|
||||
return typeof result === "undefined" ? cancelValue : result;
|
||||
} catch (err) {
|
||||
console.warn("sla-industries-brp | DialogV2.wait failed, falling back to V1 Dialog", err);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const dlg = new Dialog({
|
||||
title,
|
||||
content,
|
||||
buttons: {
|
||||
submit: {
|
||||
label: submitLabel,
|
||||
callback: async (html) => {
|
||||
const form = html?.[0]?.querySelector?.(formSelector)
|
||||
?? (typeof $ !== "undefined" ? $(html).find(formSelector)[0] : null);
|
||||
if (!form) {
|
||||
ui.notifications.error("Dialog form not found.");
|
||||
return finish(cancelValue);
|
||||
}
|
||||
const fd = new FormData(form);
|
||||
if (typeof onSubmit === "function") {
|
||||
return finish(await onSubmit(fd, form, html));
|
||||
}
|
||||
return finish(fd);
|
||||
}
|
||||
},
|
||||
cancel: {
|
||||
label: cancelLabel,
|
||||
callback: () => finish(cancelValue)
|
||||
}
|
||||
},
|
||||
default: "submit",
|
||||
render: (html) => {
|
||||
if (typeof onRender === "function") {
|
||||
onRender(html?.[0] ?? null, html);
|
||||
}
|
||||
},
|
||||
close: () => finish(cancelValue)
|
||||
});
|
||||
dlg.render(true);
|
||||
});
|
||||
}
|
||||
|
||||
static async choose({
|
||||
title = "Choose",
|
||||
content = "",
|
||||
choices = {},
|
||||
defaultChoice = "",
|
||||
cancelValue = null
|
||||
} = {}) {
|
||||
const entries = Object.entries(choices);
|
||||
if (!entries.length) return cancelValue;
|
||||
|
||||
const useV2 = Boolean(foundry?.applications?.api?.DialogV2?.wait);
|
||||
if (useV2) {
|
||||
try {
|
||||
const buttons = entries.map(([key, value]) => ({
|
||||
action: key,
|
||||
label: String(value?.label ?? key),
|
||||
default: key === defaultChoice,
|
||||
callback: () => (typeof value?.value === "undefined" ? key : value.value)
|
||||
}));
|
||||
|
||||
const result = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title },
|
||||
content,
|
||||
buttons,
|
||||
close: () => cancelValue
|
||||
});
|
||||
return typeof result === "undefined" ? cancelValue : result;
|
||||
} catch (err) {
|
||||
console.warn("sla-industries-brp | DialogV2 choose failed, falling back to V1 Dialog", err);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const buttons = {};
|
||||
for (const [key, value] of entries) {
|
||||
buttons[key] = {
|
||||
label: String(value?.label ?? key),
|
||||
callback: () => finish(typeof value?.value === "undefined" ? key : value.value)
|
||||
};
|
||||
}
|
||||
|
||||
const dlg = new Dialog({
|
||||
title,
|
||||
content,
|
||||
buttons,
|
||||
default: defaultChoice || entries[0][0],
|
||||
close: () => finish(cancelValue)
|
||||
});
|
||||
dlg.render(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
import { SLADialog } from "./sla-dialog.mjs";
|
||||
import { SLATraitEngine } from "../traits/trait-engine.mjs";
|
||||
|
||||
export class SLAMentalSystem {
|
||||
static FLAG_KEY = "slaMental";
|
||||
|
||||
static TEMP_INSANITY_EFFECTS = [
|
||||
"Acute paranoia",
|
||||
"Fight-or-flight panic",
|
||||
"Catatonic shock",
|
||||
"Violent dissociation",
|
||||
"Hallucinatory breakdown",
|
||||
"Compulsive shaking"
|
||||
];
|
||||
|
||||
static resolveActor(actorRef) {
|
||||
if (!actorRef) return null;
|
||||
if (actorRef.documentName === "Actor") return actorRef;
|
||||
if (typeof actorRef === "string") {
|
||||
return game.actors.get(actorRef) ?? game.actors.find((a) => a.name === actorRef) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static normalizeText(value = "") {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
static get roundSeconds() {
|
||||
return Math.max(1, Number(CONFIG?.time?.roundTime ?? game.combat?.roundTime ?? 6));
|
||||
}
|
||||
|
||||
static getWorldTime() {
|
||||
return Number(game.time?.worldTime ?? Math.floor(Date.now() / 1000));
|
||||
}
|
||||
|
||||
static getDefaultState() {
|
||||
return {
|
||||
sanityLossLog: [],
|
||||
shakenUntil: 0,
|
||||
panickedUntil: 0,
|
||||
temporaryInsanityUntil: 0,
|
||||
temporaryInsanityEffect: "",
|
||||
last: {
|
||||
sanLoss: 0,
|
||||
sanRoll: 0,
|
||||
coolRoll: 0,
|
||||
coolResult: ""
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static _cleanState(state, now = this.getWorldTime()) {
|
||||
const next = foundry.utils.deepClone(state ?? this.getDefaultState());
|
||||
const cutoff = now - 3600;
|
||||
next.sanityLossLog = (next.sanityLossLog ?? []).filter((entry) => Number(entry?.time ?? 0) >= cutoff);
|
||||
next.shakenUntil = Math.max(0, Number(next.shakenUntil ?? 0));
|
||||
next.panickedUntil = Math.max(0, Number(next.panickedUntil ?? 0));
|
||||
next.temporaryInsanityUntil = Math.max(0, Number(next.temporaryInsanityUntil ?? 0));
|
||||
if (next.temporaryInsanityUntil <= now) {
|
||||
next.temporaryInsanityEffect = "";
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
static async getState(actorRef, { clean = true } = {}) {
|
||||
const actor = this.resolveActor(actorRef);
|
||||
if (!actor) return this.getDefaultState();
|
||||
const fromSystem = actor.flags?.[game.system.id]?.[this.FLAG_KEY];
|
||||
const fromLegacy = actor.flags?.brp?.[this.FLAG_KEY];
|
||||
let state = foundry.utils.mergeObject(
|
||||
this.getDefaultState(),
|
||||
foundry.utils.deepClone(fromSystem ?? fromLegacy ?? {}),
|
||||
{ inplace: false }
|
||||
);
|
||||
if (clean) {
|
||||
state = this._cleanState(state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
static async setState(actorRef, state) {
|
||||
const actor = this.resolveActor(actorRef);
|
||||
if (!actor) return;
|
||||
await actor.setFlag(game.system.id, this.FLAG_KEY, state);
|
||||
}
|
||||
|
||||
static getSkillTarget(actor, item) {
|
||||
if (!actor || !item) return 0;
|
||||
const base = Number(item.system?.total ?? 0);
|
||||
const category = String(item.system?.category ?? "");
|
||||
const catBonus = Number(actor.system?.skillcategory?.[category] ?? 0);
|
||||
return base + catBonus;
|
||||
}
|
||||
|
||||
static findCoolSkill(actor) {
|
||||
if (!actor) return null;
|
||||
const preferred = new Set([
|
||||
"cool",
|
||||
"coolrating",
|
||||
"coolheaded",
|
||||
"coolness"
|
||||
]);
|
||||
return actor.items.find((item) => {
|
||||
if (item.type !== "skill") return false;
|
||||
return preferred.has(this.normalizeText(item.name ?? ""));
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
static getCoolTarget(actor, { fallbackMultiplier = 2 } = {}) {
|
||||
if (!actor) return { target: 0, source: "none", skillId: "" };
|
||||
|
||||
const res5Value = Number(actor.system?.res5?.value ?? 0);
|
||||
const res5Max = Number(actor.system?.res5?.max ?? 0);
|
||||
const coolTrack = Math.max(0, res5Value, res5Max);
|
||||
if (coolTrack > 0) {
|
||||
return {
|
||||
target: Math.max(1, coolTrack),
|
||||
source: String(actor.system?.res5?.label ?? game.i18n.localize("BRP.res5")),
|
||||
skillId: ""
|
||||
};
|
||||
}
|
||||
|
||||
const coolSkill = this.findCoolSkill(actor);
|
||||
if (coolSkill) {
|
||||
return {
|
||||
target: Math.max(1, Number(this.getSkillTarget(actor, coolSkill) ?? 0)),
|
||||
source: coolSkill.name,
|
||||
skillId: coolSkill.id
|
||||
};
|
||||
}
|
||||
|
||||
const pow = Number(actor.system?.stats?.pow?.total ?? 0);
|
||||
return {
|
||||
target: Math.max(1, pow * Math.max(1, Number(fallbackMultiplier ?? 2))),
|
||||
source: "POW fallback",
|
||||
skillId: ""
|
||||
};
|
||||
}
|
||||
|
||||
static parseSanityLossNotation(loss = "0/1D4") {
|
||||
const text = String(loss ?? "0/1D4").trim();
|
||||
if (!text.includes("/")) {
|
||||
return { successLoss: "0", failureLoss: text || "0" };
|
||||
}
|
||||
const [successPart, failurePart] = text.split("/", 2);
|
||||
return {
|
||||
successLoss: (successPart ?? "0").trim() || "0",
|
||||
failureLoss: (failurePart ?? "0").trim() || "0"
|
||||
};
|
||||
}
|
||||
|
||||
static async rollFormulaTotal(formula = "0") {
|
||||
const text = String(formula ?? "0").trim();
|
||||
if (!text) return 0;
|
||||
if (/^[+-]?\d+$/.test(text)) return Number(text);
|
||||
const roll = await new Roll(text).evaluate();
|
||||
if (game.modules.get("dice-so-nice")?.active && game.dice3d) {
|
||||
game.dice3d.showForRoll(roll, game.user, true, null, false);
|
||||
}
|
||||
return Number(roll.total ?? 0);
|
||||
}
|
||||
|
||||
static resultLevelFromRoll(rollVal, targetScore) {
|
||||
const critChance = Math.ceil(0.05 * targetScore);
|
||||
const fumbleChance = Math.min(95 + critChance, 100);
|
||||
const specialChance = Math.round(0.2 * targetScore);
|
||||
const successChance = Math.min(targetScore, 95);
|
||||
|
||||
if (rollVal <= critChance) return 4;
|
||||
if (rollVal <= specialChance) return 3;
|
||||
if (rollVal <= successChance) return 2;
|
||||
if (rollVal >= fumbleChance) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static resultLabel(resultLevel = 1) {
|
||||
const map = {
|
||||
0: "Fumble",
|
||||
1: "Failure",
|
||||
2: "Success",
|
||||
3: "Special",
|
||||
4: "Critical"
|
||||
};
|
||||
return map[Number(resultLevel)] ?? "Result";
|
||||
}
|
||||
|
||||
static normaliseRollEdge(rawValue = "normal") {
|
||||
const value = String(rawValue ?? "").trim().toLowerCase();
|
||||
if (["advantage", "adv", "easy"].includes(value)) return "advantage";
|
||||
if (["disadvantage", "dis", "difficult", "hard", "extreme", "impossible", "tricky", "awkward"].includes(value)) return "disadvantage";
|
||||
return "normal";
|
||||
}
|
||||
|
||||
static rollFormulaForEdge(edge = "normal") {
|
||||
const mode = this.normaliseRollEdge(edge);
|
||||
if (mode === "advantage") return "1D100";
|
||||
if (mode === "disadvantage") return "1D100";
|
||||
return "1D100";
|
||||
}
|
||||
|
||||
static rollEdgeLabel(edge = "normal") {
|
||||
const mode = this.normaliseRollEdge(edge);
|
||||
if (mode === "advantage") return game.i18n.localize("BRP.adv");
|
||||
if (mode === "disadvantage") return game.i18n.localize("BRP.dis");
|
||||
return game.i18n.localize("BRP.normal");
|
||||
}
|
||||
|
||||
static extractRollValues(roll) {
|
||||
const values = [];
|
||||
for (const die of roll?.dice ?? []) {
|
||||
if (Array.isArray(die?.values)) {
|
||||
for (const value of die.values) values.push(Number(value));
|
||||
}
|
||||
}
|
||||
return values.filter((value) => Number.isFinite(value));
|
||||
}
|
||||
|
||||
static unitsDigitFromPercentile(rollVal = 100) {
|
||||
const value = Math.max(1, Math.min(100, Number(rollVal ?? 100) || 100));
|
||||
return value % 10;
|
||||
}
|
||||
|
||||
static tensDigitFromD10(rollVal = 10) {
|
||||
const value = Number(rollVal ?? 10);
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return ((value % 10) + 10) % 10;
|
||||
}
|
||||
|
||||
static percentileFromDigits(tensDigit = 0, unitsDigit = 0) {
|
||||
const tens = Math.max(0, Math.min(9, Number(tensDigit ?? 0) || 0));
|
||||
const units = Math.max(0, Math.min(9, Number(unitsDigit ?? 0) || 0));
|
||||
if (tens === 0 && units === 0) return 100;
|
||||
return (tens * 10) + units;
|
||||
}
|
||||
|
||||
static async rollPercentileWithEdge(edge = "normal") {
|
||||
const mode = this.normaliseRollEdge(edge);
|
||||
const baseRoll = await new Roll("1D100").evaluate();
|
||||
if (game.modules.get("dice-so-nice")?.active && game.dice3d) {
|
||||
game.dice3d.showForRoll(baseRoll, game.user, true, null, false);
|
||||
}
|
||||
const base = Math.max(1, Math.min(100, Number(baseRoll.total ?? 100) || 100));
|
||||
const unitsDigit = this.unitsDigitFromPercentile(base);
|
||||
|
||||
if (mode === "normal") {
|
||||
return {
|
||||
mode,
|
||||
base,
|
||||
candidate: base,
|
||||
kept: base,
|
||||
unitsDigit,
|
||||
tensRaw: null,
|
||||
tensDigit: null,
|
||||
values: [base],
|
||||
detail: String(base)
|
||||
};
|
||||
}
|
||||
|
||||
const tensRoll = await new Roll("1D10").evaluate();
|
||||
if (game.modules.get("dice-so-nice")?.active && game.dice3d) {
|
||||
game.dice3d.showForRoll(tensRoll, game.user, true, null, false);
|
||||
}
|
||||
const tensRaw = Number(tensRoll.total ?? 10);
|
||||
const tensDigit = this.tensDigitFromD10(tensRaw);
|
||||
const candidate = this.percentileFromDigits(tensDigit, unitsDigit);
|
||||
const kept = mode === "advantage" ? Math.min(base, candidate) : Math.max(base, candidate);
|
||||
return {
|
||||
mode,
|
||||
base,
|
||||
candidate,
|
||||
kept,
|
||||
unitsDigit,
|
||||
tensRaw,
|
||||
tensDigit,
|
||||
values: [base, candidate],
|
||||
detail: `${base} | d10:${tensRaw} => ${candidate} | keep ${kept}`
|
||||
};
|
||||
}
|
||||
|
||||
static async postMentalChat({
|
||||
actor,
|
||||
title = "Mental Check",
|
||||
summary = "",
|
||||
details = []
|
||||
} = {}) {
|
||||
if (!actor) return;
|
||||
const lines = [];
|
||||
if (summary) lines.push(`<div>${summary}</div>`);
|
||||
for (const line of details ?? []) {
|
||||
if (line) lines.push(`<div>${line}</div>`);
|
||||
}
|
||||
const content = `
|
||||
<form class="brp gr-card">
|
||||
<div class="bold">${title}</div>
|
||||
<div class="quick-combat" style="margin-left:0;">
|
||||
<div><strong>${actor.name}</strong></div>
|
||||
${lines.join("")}
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
let chatType = "";
|
||||
if (!foundry.utils.isNewerVersion(game.version, "11")) {
|
||||
chatType = CONST.CHAT_MESSAGE_STYLES.OTHER;
|
||||
} else {
|
||||
chatType = CONST.CHAT_MESSAGE_OTHER;
|
||||
}
|
||||
await ChatMessage.create({
|
||||
author: game.user.id,
|
||||
type: chatType,
|
||||
content,
|
||||
speaker: {
|
||||
actor: actor.id,
|
||||
alias: actor.name
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static sanityThreshold(currentSanity = 0) {
|
||||
return Math.max(1, Math.floor(Math.max(0, Number(currentSanity ?? 0)) / 5));
|
||||
}
|
||||
|
||||
static async getSanityThresholdInfo(actorRef) {
|
||||
const actor = this.resolveActor(actorRef);
|
||||
if (!actor) {
|
||||
return {
|
||||
current: 0,
|
||||
threshold: 1,
|
||||
lastHourLoss: 0,
|
||||
temporaryInsanity: false
|
||||
};
|
||||
}
|
||||
const current = Math.max(0, Number(actor.system?.sanity?.value ?? 0));
|
||||
const threshold = this.sanityThreshold(current);
|
||||
const state = await this.getState(actor, { clean: true });
|
||||
const lastHourLoss = Number(
|
||||
(state.sanityLossLog ?? []).reduce((sum, entry) => sum + Math.max(0, Number(entry?.loss ?? 0)), 0)
|
||||
);
|
||||
const now = this.getWorldTime();
|
||||
return {
|
||||
current,
|
||||
threshold,
|
||||
lastHourLoss,
|
||||
temporaryInsanity: Number(state.temporaryInsanityUntil ?? 0) > now,
|
||||
temporaryInsanityEffect: String(state.temporaryInsanityEffect ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
static randomEntry(entries = []) {
|
||||
if (!entries.length) return "";
|
||||
const index = Math.floor(Math.random() * entries.length);
|
||||
return entries[index] ?? "";
|
||||
}
|
||||
|
||||
static async applySanityLoss(actorRef, loss = 0, { reason = "" } = {}) {
|
||||
const actor = this.resolveActor(actorRef);
|
||||
const value = Math.max(0, Number(loss ?? 0));
|
||||
if (!actor || value <= 0) {
|
||||
return {
|
||||
before: Number(actor?.system?.sanity?.value ?? 0),
|
||||
after: Number(actor?.system?.sanity?.value ?? 0),
|
||||
applied: 0,
|
||||
threshold: this.sanityThreshold(Number(actor?.system?.sanity?.value ?? 0)),
|
||||
thresholdBreached: false,
|
||||
temporaryInsanity: false,
|
||||
permanentInsanity: Number(actor?.system?.sanity?.value ?? 0) <= 0,
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
const before = Math.max(0, Number(actor.system?.sanity?.value ?? 0));
|
||||
const after = Math.max(0, before - value);
|
||||
const applied = before - after;
|
||||
await actor.update({ "system.sanity.value": after });
|
||||
|
||||
const now = this.getWorldTime();
|
||||
const state = await this.getState(actor, { clean: true });
|
||||
state.sanityLossLog.push({
|
||||
time: now,
|
||||
loss: applied,
|
||||
reason: String(reason ?? "")
|
||||
});
|
||||
const clean = this._cleanState(state, now);
|
||||
const threshold = this.sanityThreshold(before);
|
||||
const lastHourLoss = Number(
|
||||
(clean.sanityLossLog ?? []).reduce((sum, entry) => sum + Math.max(0, Number(entry?.loss ?? 0)), 0)
|
||||
);
|
||||
const thresholdBreached = lastHourLoss >= threshold;
|
||||
let temporaryInsanity = false;
|
||||
let temporaryInsanityEffect = String(clean.temporaryInsanityEffect ?? "");
|
||||
|
||||
if (thresholdBreached && Number(clean.temporaryInsanityUntil ?? 0) <= now) {
|
||||
const durationMinutes = Math.max(10, await this.rollFormulaTotal("1D10*10"));
|
||||
clean.temporaryInsanityUntil = now + (durationMinutes * 60);
|
||||
temporaryInsanity = true;
|
||||
temporaryInsanityEffect = this.randomEntry(this.TEMP_INSANITY_EFFECTS);
|
||||
clean.temporaryInsanityEffect = temporaryInsanityEffect;
|
||||
}
|
||||
|
||||
clean.last = clean.last ?? {};
|
||||
clean.last.sanLoss = applied;
|
||||
await this.setState(actor, clean);
|
||||
|
||||
return {
|
||||
before,
|
||||
after,
|
||||
applied,
|
||||
threshold,
|
||||
lastHourLoss,
|
||||
thresholdBreached,
|
||||
temporaryInsanity: temporaryInsanity || (Number(clean.temporaryInsanityUntil ?? 0) > now),
|
||||
temporaryInsanityEffect: String(clean.temporaryInsanityEffect ?? ""),
|
||||
permanentInsanity: after <= 0,
|
||||
reason
|
||||
};
|
||||
}
|
||||
|
||||
static async sanityCheck({
|
||||
actor,
|
||||
loss = "0/1D4",
|
||||
reason = "",
|
||||
edge = "normal"
|
||||
} = {}) {
|
||||
const resolvedActor = this.resolveActor(actor);
|
||||
if (!resolvedActor) {
|
||||
ui.notifications.warn("SLA SAN: actor not found.");
|
||||
return { ok: false, reason: "no-actor" };
|
||||
}
|
||||
|
||||
const beforeSan = Math.max(0, Number(resolvedActor.system?.sanity?.value ?? 0));
|
||||
const traitContext = await SLATraitEngine.getMentalContext(resolvedActor, {
|
||||
kind: "san",
|
||||
reason,
|
||||
preview: false
|
||||
});
|
||||
const target = Math.max(1, Math.min(99, beforeSan + Number(traitContext.flatMod ?? 0)));
|
||||
const rollEdge = this.normaliseRollEdge(edge);
|
||||
const edgeRoll = await this.rollPercentileWithEdge(rollEdge);
|
||||
const rollValues = edgeRoll.values;
|
||||
const rollVal = Number(edgeRoll.kept ?? 0);
|
||||
const success = rollVal <= target;
|
||||
const parsed = this.parseSanityLossNotation(loss);
|
||||
const lossFormula = success ? parsed.successLoss : parsed.failureLoss;
|
||||
const lossAmount = await this.rollFormulaTotal(lossFormula);
|
||||
const applied = await this.applySanityLoss(resolvedActor, lossAmount, { reason });
|
||||
|
||||
const details = [
|
||||
`Roll (${this.rollEdgeLabel(rollEdge)}): ${edgeRoll.detail} vs SAN ${target} (${success ? "Success" : "Failure"})`,
|
||||
`Loss: ${lossFormula} -> ${applied.applied} SAN`,
|
||||
`SAN: ${applied.before} -> ${applied.after}`,
|
||||
`Threshold: ${applied.lastHourLoss}/${applied.threshold} SAN in last hour`
|
||||
];
|
||||
if (traitContext.summary) {
|
||||
details.push(`Traits: ${traitContext.summary}`);
|
||||
}
|
||||
if (applied.temporaryInsanity) {
|
||||
details.push(`Temporary insanity: ${applied.temporaryInsanityEffect || "Triggered"}`);
|
||||
}
|
||||
if (applied.permanentInsanity) {
|
||||
details.push("SAN reached 0: permanent insanity.");
|
||||
}
|
||||
|
||||
await this.postMentalChat({
|
||||
actor: resolvedActor,
|
||||
title: "SAN Check",
|
||||
summary: reason ? `Trigger: ${reason}` : "Sanity stress event",
|
||||
details
|
||||
});
|
||||
|
||||
ui.notifications.info(
|
||||
`${resolvedActor.name}: SAN check ${success ? "success" : "failure"}, loss ${applied.applied}.`
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
actor: resolvedActor,
|
||||
edge: rollEdge,
|
||||
rollValues,
|
||||
rollVal,
|
||||
target,
|
||||
success,
|
||||
traitFlatMod: Number(traitContext.flatMod ?? 0),
|
||||
traitSummary: String(traitContext.summary ?? ""),
|
||||
lossFormula,
|
||||
lossAmount: applied.applied,
|
||||
before: applied.before,
|
||||
after: applied.after,
|
||||
thresholdBreached: applied.thresholdBreached,
|
||||
temporaryInsanity: applied.temporaryInsanity,
|
||||
permanentInsanity: applied.permanentInsanity
|
||||
};
|
||||
}
|
||||
|
||||
static async promptSanityCheck({ actor } = {}) {
|
||||
const resolvedActor = this.resolveActor(actor);
|
||||
if (!resolvedActor) return { ok: false, reason: "no-actor" };
|
||||
const content = `
|
||||
<form class="brp sla-roll-dialog" id="sla-san-check-form">
|
||||
<header class="sla-dialog-head">
|
||||
<div class="sla-dialog-kicker">SLA INDUSTRIES // MENTAL CONTROL</div>
|
||||
<div class="diff-label">SAN CHECK</div>
|
||||
<div class="sla-dialog-sub">Set edge, loss formula, and reason before rolling.</div>
|
||||
</header>
|
||||
<div class="flexcol">
|
||||
<div class="flexrow">
|
||||
<label class="resource-label-diff">Edge</label>
|
||||
<select name="edge">
|
||||
<option value="advantage">${game.i18n.localize("BRP.adv")} (1D100 + tens re-roll, keep best)</option>
|
||||
<option value="normal" selected>${game.i18n.localize("BRP.normal")} (1D100)</option>
|
||||
<option value="disadvantage">${game.i18n.localize("BRP.dis")} (1D100 + tens re-roll, keep worst)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flexrow">
|
||||
<label class="resource-label-diff">Loss</label>
|
||||
<input class="diff-number-input" name="loss" type="text" value="0/1D4" />
|
||||
</div>
|
||||
<div class="flexrow">
|
||||
<label class="resource-label-diff">Reason</label>
|
||||
<input class="diff-number-input" name="reason" type="text" value="" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
const formData = await SLADialog.waitForm({
|
||||
title: "SAN Check",
|
||||
content,
|
||||
formSelector: "#sla-san-check-form",
|
||||
submitLabel: game.i18n.localize("BRP.proceed"),
|
||||
cancelLabel: game.i18n.localize("Cancel"),
|
||||
cancelValue: false
|
||||
});
|
||||
if (!formData) return { ok: false, reason: "cancelled" };
|
||||
return this.sanityCheck({
|
||||
actor: resolvedActor,
|
||||
edge: String(formData.get("edge") ?? "normal"),
|
||||
loss: String(formData.get("loss") ?? "0/1D4"),
|
||||
reason: String(formData.get("reason") ?? "")
|
||||
});
|
||||
}
|
||||
|
||||
static async promptCoolCheck({ actor, reason = "Immediate threat response" } = {}) {
|
||||
const resolvedActor = this.resolveActor(actor);
|
||||
if (!resolvedActor) return { ok: false, reason: "no-actor" };
|
||||
const content = `
|
||||
<form class="brp sla-roll-dialog" id="sla-cool-check-form">
|
||||
<header class="sla-dialog-head">
|
||||
<div class="sla-dialog-kicker">SLA INDUSTRIES // MENTAL CONTROL</div>
|
||||
<div class="diff-label">COOL CHECK</div>
|
||||
<div class="sla-dialog-sub">Set edge, modifier, and reason before rolling.</div>
|
||||
</header>
|
||||
<div class="flexcol">
|
||||
<div class="flexrow">
|
||||
<label class="resource-label-diff">Edge</label>
|
||||
<select name="edge">
|
||||
<option value="advantage">${game.i18n.localize("BRP.adv")} (1D100 + tens re-roll, keep best)</option>
|
||||
<option value="normal" selected>${game.i18n.localize("BRP.normal")} (1D100)</option>
|
||||
<option value="disadvantage">${game.i18n.localize("BRP.dis")} (1D100 + tens re-roll, keep worst)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flexrow">
|
||||
<label class="resource-label-diff">Modifier</label>
|
||||
<input class="diff-number-input" name="modifier" type="number" value="0" />
|
||||
</div>
|
||||
<div class="flexrow">
|
||||
<label class="resource-label-diff">Reason</label>
|
||||
<input class="diff-number-input" name="reason" type="text" value="${String(reason ?? "")}" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
const formData = await SLADialog.waitForm({
|
||||
title: "COOL Check",
|
||||
content,
|
||||
formSelector: "#sla-cool-check-form",
|
||||
submitLabel: game.i18n.localize("BRP.proceed"),
|
||||
cancelLabel: game.i18n.localize("Cancel"),
|
||||
cancelValue: false
|
||||
});
|
||||
if (!formData) return { ok: false, reason: "cancelled" };
|
||||
return this.coolCheck({
|
||||
actor: resolvedActor,
|
||||
edge: String(formData.get("edge") ?? "normal"),
|
||||
modifier: Number(formData.get("modifier") ?? 0),
|
||||
reason: String(formData.get("reason") ?? reason ?? "")
|
||||
});
|
||||
}
|
||||
|
||||
static async coolCheck({
|
||||
actor,
|
||||
modifier = 0,
|
||||
reason = "",
|
||||
edge = "normal"
|
||||
} = {}) {
|
||||
const resolvedActor = this.resolveActor(actor);
|
||||
if (!resolvedActor) {
|
||||
ui.notifications.warn("SLA COOL: actor not found.");
|
||||
return { ok: false, reason: "no-actor" };
|
||||
}
|
||||
|
||||
const cool = this.getCoolTarget(resolvedActor);
|
||||
const traitContext = await SLATraitEngine.getMentalContext(resolvedActor, {
|
||||
kind: "cool",
|
||||
reason,
|
||||
modifier: Number(modifier ?? 0),
|
||||
preview: false
|
||||
});
|
||||
const coolPercentDelta = Number(traitContext.coolPercentDelta ?? 0);
|
||||
const coolTargetAfterCap = Math.round(Number(cool.target ?? 0) * (1 + (coolPercentDelta / 100)));
|
||||
const target = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
99,
|
||||
Number(coolTargetAfterCap ?? cool.target ?? 0)
|
||||
+ Number(modifier ?? 0)
|
||||
+ Number(traitContext.flatMod ?? 0)
|
||||
)
|
||||
);
|
||||
const rollEdge = this.normaliseRollEdge(edge);
|
||||
const edgeRoll = await this.rollPercentileWithEdge(rollEdge);
|
||||
const rollValues = edgeRoll.values;
|
||||
const rollVal = Number(edgeRoll.kept ?? 0);
|
||||
let resultLevel = this.resultLevelFromRoll(rollVal, target);
|
||||
let resultLabel = this.resultLabel(resultLevel);
|
||||
|
||||
const now = this.getWorldTime();
|
||||
const state = await this.getState(resolvedActor, { clean: true });
|
||||
let shock = "Steady";
|
||||
let traitAutoPassUsed = false;
|
||||
if (resultLevel < 2 && traitContext.autoPassCool) {
|
||||
resultLevel = 2;
|
||||
resultLabel = "Success";
|
||||
traitAutoPassUsed = true;
|
||||
shock = "Steady (Exceedingly Cool auto-pass)";
|
||||
try {
|
||||
await SLATraitEngine.consumeSessionAbility(resolvedActor, "exceedingly-cool", "auto-cool-pass");
|
||||
} catch (err) {
|
||||
console.warn("sla-industries-brp | Trait auto-pass consume failed", err);
|
||||
}
|
||||
}
|
||||
if (resultLevel < 2) {
|
||||
if (resultLevel === 0) {
|
||||
state.panickedUntil = Math.max(Number(state.panickedUntil ?? 0), now + this.roundSeconds);
|
||||
state.shakenUntil = Math.max(Number(state.shakenUntil ?? 0), now + this.roundSeconds);
|
||||
shock = "Panicked: lose control for 1 round";
|
||||
} else {
|
||||
state.shakenUntil = Math.max(Number(state.shakenUntil ?? 0), now + this.roundSeconds);
|
||||
shock = "Shaken: -20% actions for 1 round";
|
||||
}
|
||||
}
|
||||
state.last = state.last ?? {};
|
||||
state.last.coolRoll = rollVal;
|
||||
state.last.coolResult = resultLabel;
|
||||
await this.setState(resolvedActor, state);
|
||||
|
||||
await this.postMentalChat({
|
||||
actor: resolvedActor,
|
||||
title: "COOL Check",
|
||||
summary: reason ? `Trigger: ${reason}` : "Immediate stress response",
|
||||
details: [
|
||||
`Source: ${cool.source}`,
|
||||
`Roll (${this.rollEdgeLabel(rollEdge)}): ${edgeRoll.detail} vs COOL ${target} (${resultLabel})`,
|
||||
traitContext.summary ? `Traits: ${traitContext.summary}` : "",
|
||||
shock
|
||||
].filter(Boolean)
|
||||
});
|
||||
|
||||
ui.notifications.info(`${resolvedActor.name}: COOL ${resultLabel.toLowerCase()}.`);
|
||||
return {
|
||||
ok: true,
|
||||
actor: resolvedActor,
|
||||
edge: rollEdge,
|
||||
rollValues,
|
||||
rollVal,
|
||||
target,
|
||||
resultLevel,
|
||||
resultLabel,
|
||||
shock,
|
||||
source: cool.source,
|
||||
traitFlatMod: Number(traitContext.flatMod ?? 0),
|
||||
traitSummary: String(traitContext.summary ?? ""),
|
||||
coolPercentDelta,
|
||||
traitAutoPassUsed
|
||||
};
|
||||
}
|
||||
|
||||
static async clearStress(actorRef) {
|
||||
const actor = this.resolveActor(actorRef);
|
||||
if (!actor) return { ok: false, reason: "no-actor" };
|
||||
const state = await this.getState(actor, { clean: true });
|
||||
state.shakenUntil = 0;
|
||||
state.panickedUntil = 0;
|
||||
await this.setState(actor, state);
|
||||
ui.notifications.info(`${actor.name}: shaken/panicked stress cleared.`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
static async getCheckContext(actorRef, config = {}, { preview = false } = {}) {
|
||||
const actor = this.resolveActor(actorRef);
|
||||
if (!actor) {
|
||||
return {
|
||||
flatMod: 0,
|
||||
effects: [],
|
||||
summary: ""
|
||||
};
|
||||
}
|
||||
|
||||
const now = this.getWorldTime();
|
||||
const state = await this.getState(actor, { clean: true });
|
||||
if (!preview) {
|
||||
await this.setState(actor, state);
|
||||
}
|
||||
|
||||
const rollType = String(config?.rollType ?? "").toUpperCase();
|
||||
const cardType = String(config?.cardType ?? "").toUpperCase();
|
||||
// SLA: COOL/SAN state should not directly suppress core characteristic resistance checks.
|
||||
if (rollType === "CH" || cardType === "RE" || cardType === "PP") {
|
||||
return {
|
||||
flatMod: 0,
|
||||
effects: [],
|
||||
summary: ""
|
||||
};
|
||||
}
|
||||
|
||||
const effects = [];
|
||||
let flatMod = 0;
|
||||
const currentSan = Math.max(0, Number(actor.system?.sanity?.value ?? 0));
|
||||
if (currentSan <= 20) {
|
||||
flatMod -= 20;
|
||||
effects.push("SAN <= 20: COOL penalty -20%");
|
||||
} else if (currentSan <= 40) {
|
||||
flatMod -= 10;
|
||||
effects.push("SAN <= 40: COOL penalty -10%");
|
||||
}
|
||||
|
||||
if (Number(state.temporaryInsanityUntil ?? 0) > now) {
|
||||
flatMod -= 20;
|
||||
const label = state.temporaryInsanityEffect ? `Temporary insanity (${state.temporaryInsanityEffect})` : "Temporary insanity";
|
||||
effects.push(`${label}: -20%`);
|
||||
}
|
||||
|
||||
if (Number(state.panickedUntil ?? 0) > now) {
|
||||
flatMod -= 40;
|
||||
effects.push("Panicked: -40%");
|
||||
} else if (Number(state.shakenUntil ?? 0) > now) {
|
||||
flatMod -= 20;
|
||||
effects.push("Shaken: -20%");
|
||||
}
|
||||
|
||||
return {
|
||||
flatMod,
|
||||
effects,
|
||||
summary: effects.join("; ")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { BRPCheck } from "./check.mjs";
|
||||
import { SLAAmmoTracker } from "./sla-ammo-tracker.mjs";
|
||||
import { SLADrugSystem } from "./sla-drug-system.mjs";
|
||||
import { SLAEbbSystem } from "./sla-ebb-system.mjs";
|
||||
|
||||
export class SLAQAHarness {
|
||||
static async runQuick({ postToChat = true } = {}) {
|
||||
const tests = [];
|
||||
const add = (name, pass, detail = "") => tests.push({ name, pass: Boolean(pass), detail: String(detail ?? "") });
|
||||
|
||||
try {
|
||||
add("BRPCheck edge normalisation", BRPCheck.normaliseRollEdge("adv") === "advantage");
|
||||
add("BRPCheck burst hit bonus", BRPCheck.getFireModeHitBonus("burst") === 10);
|
||||
add("BRPCheck auto hit count crit", BRPCheck.getFireModeHitCount("auto", 4) === 6);
|
||||
|
||||
const mockWeapon = { system: { ammoPerShot: 1, ammoPerBurst: 3, ammoPerAuto: 10, rof: 10, special: "auto" } };
|
||||
add("Ammo spend cost single", SLAAmmoTracker.getSpendCost(mockWeapon, "single") === 1);
|
||||
add("Ammo spend cost burst", SLAAmmoTracker.getSpendCost(mockWeapon, "burst") === 3);
|
||||
add("Ammo spend cost auto", SLAAmmoTracker.getSpendCost(mockWeapon, "auto") === 10);
|
||||
|
||||
const drug = SLADrugSystem.resolveDrugDef("KickStart");
|
||||
add("Drug definition KickStart", Boolean(drug?.id), drug?.id ?? "missing");
|
||||
|
||||
const ebbAbility = game.items?.find((i) => i.type === "psychic" && String(i.name ?? "").toLowerCase().includes("ebb"));
|
||||
if (ebbAbility) {
|
||||
const meta = SLAEbbSystem.getAbilityMeta(ebbAbility);
|
||||
add("EBB meta resolves", Boolean(meta?.id), meta?.id ?? "missing");
|
||||
} else {
|
||||
add("EBB meta resolves", true, "Skipped (no EBB item in world)");
|
||||
}
|
||||
|
||||
const firstEbbActor = game.actors?.find((a) => a.type === "character" && SLAEbbSystem.isEbbSpecies(a));
|
||||
if (firstEbbActor) {
|
||||
const audit = SLAEbbSystem.auditActorDisciplineSkills(firstEbbActor);
|
||||
const missing = audit.filter((row) => !row.ok);
|
||||
add(
|
||||
"EBB discipline formulation skills resolve",
|
||||
missing.length === 0,
|
||||
`${firstEbbActor.name}: ${audit.length - missing.length}/${audit.length} linked`
|
||||
);
|
||||
} else {
|
||||
add("EBB discipline formulation skills resolve", true, "Skipped (no Ebon/Brain Waster actor)");
|
||||
}
|
||||
|
||||
const isGM = Boolean(game.user?.isGM);
|
||||
add("User context available", Boolean(game.user?.id), game.user?.id ?? "none");
|
||||
add("GM context (expected for full QA)", isGM, isGM ? "GM" : "Player");
|
||||
|
||||
const passCount = tests.filter((t) => t.pass).length;
|
||||
const failCount = tests.length - passCount;
|
||||
const summary = { ok: failCount === 0, tests, passCount, failCount };
|
||||
|
||||
if (postToChat) {
|
||||
const rows = tests.map((t) => `<li><strong>${t.pass ? "PASS" : "FAIL"}</strong> - ${this._escape(t.name)}${t.detail ? ` <span style="opacity:.85">(${this._escape(t.detail)})</span>` : ""}</li>`).join("");
|
||||
await ChatMessage.create({
|
||||
user: game.user?.id,
|
||||
speaker: ChatMessage.getSpeaker({ alias: "SLA QA Harness" }),
|
||||
content: `
|
||||
<div class="brp gr-card">
|
||||
<div class="bold">SLA Quick QA Harness</div>
|
||||
<p>Pass: <strong>${passCount}</strong> | Fail: <strong>${failCount}</strong></p>
|
||||
<ol>${rows}</ol>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
if (failCount > 0) {
|
||||
ui.notifications.warn(`SLA QA Harness: ${failCount} check(s) failed.`);
|
||||
} else {
|
||||
ui.notifications.info(`SLA QA Harness: ${passCount} checks passed.`);
|
||||
}
|
||||
|
||||
return summary;
|
||||
} catch (err) {
|
||||
console.error("sla-industries-brp | SLAQAHarness failed", err);
|
||||
ui.notifications.error("SLA QA Harness failed. See console for details.");
|
||||
return { ok: false, error: String(err?.message ?? err) };
|
||||
}
|
||||
}
|
||||
|
||||
static _escape(value = "") {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export class SLARollPipeline {
|
||||
static formatSigned(value = 0) {
|
||||
const n = Number(value ?? 0);
|
||||
if (!Number.isFinite(n)) return "+0";
|
||||
return `${n >= 0 ? "+" : ""}${n}`;
|
||||
}
|
||||
|
||||
static buildTargetBreakdown(config = {}) {
|
||||
const base = Number(config.targetBase ?? config.rawScore ?? 0);
|
||||
const manual = Number(config.flatMod ?? 0);
|
||||
const status = Number(config.systemFlatModTotal ?? 0);
|
||||
const fireModeBonus = Number(config.fireModeHitBonus ?? 0);
|
||||
const finalTarget = Number(config.targetScore ?? 0);
|
||||
const capApplied = Number(config.targetCapApplied ?? 0);
|
||||
|
||||
const parts = [
|
||||
`Base ${base}%`,
|
||||
`Manual ${this.formatSigned(manual)}%`,
|
||||
`SLA Mods ${this.formatSigned(status)}%`
|
||||
];
|
||||
|
||||
if (fireModeBonus !== 0) {
|
||||
parts.push(`Fire Mode ${this.formatSigned(fireModeBonus)}%`);
|
||||
}
|
||||
parts.push(`Final ${finalTarget}%`);
|
||||
if (capApplied > 0) parts.push(`Cap -${capApplied}%`);
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
static debugPayload(config = {}) {
|
||||
return {
|
||||
rollType: String(config.rollType ?? ""),
|
||||
cardType: String(config.cardType ?? ""),
|
||||
edge: String(config.diff ?? "normal"),
|
||||
rawScore: Number(config.rawScore ?? 0),
|
||||
targetBase: Number(config.targetBase ?? 0),
|
||||
targetAfterManual: Number(config.targetAfterManual ?? 0),
|
||||
targetPreCap: Number(config.targetPreCap ?? 0),
|
||||
targetFinal: Number(config.targetScore ?? 0),
|
||||
targetCapApplied: Number(config.targetCapApplied ?? 0),
|
||||
manualFlat: Number(config.flatMod ?? 0),
|
||||
systemFlat: Number(config.systemFlatModTotal ?? 0),
|
||||
traitFlat: Number(config.traitFlatMod ?? 0),
|
||||
fireModeBonus: Number(config.fireModeHitBonus ?? 0),
|
||||
totalFlat: Number(config.totalFlatMod ?? 0),
|
||||
edgeText: String(config.edgeRollText ?? ""),
|
||||
diceRolled: String(config.diceRolled ?? ""),
|
||||
rollVal: Number(config.rollVal ?? 0),
|
||||
resultLevel: Number(config.resultLevel ?? -1)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { SLADialog } from "./sla-dialog.mjs";
|
||||
|
||||
export class SLARulesConsole {
|
||||
static SETTINGS = [
|
||||
{ key: "initiativeMode", type: "string", default: "escalation" },
|
||||
{ key: "pulpSla", type: "boolean", default: false },
|
||||
{ key: "showManipulationSkills", type: "boolean", default: false },
|
||||
{ key: "showSocialSkills", type: "boolean", default: false },
|
||||
{ key: "showSupernaturalSkills", type: "boolean", default: false },
|
||||
{ key: "combatTargetingMode", type: "string", default: "prefer-selected" },
|
||||
{ key: "ammoDeficitMode", type: "string", default: "warn" },
|
||||
{ key: "debugRollOverlay", type: "boolean", default: false }
|
||||
];
|
||||
|
||||
static _value(key, fallback = null) {
|
||||
try {
|
||||
return game.settings.get("sla-industries-brp", key);
|
||||
} catch (_err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
static async open() {
|
||||
if (!game.user?.isGM) {
|
||||
ui.notifications.warn("Only a GM can open the SLA Rules Console.");
|
||||
return { ok: false, reason: "not-gm" };
|
||||
}
|
||||
|
||||
const values = {
|
||||
initiativeMode: String(this._value("initiativeMode", "escalation")),
|
||||
pulpSla: Boolean(this._value("pulpSla", false)),
|
||||
showManipulationSkills: Boolean(this._value("showManipulationSkills", false)),
|
||||
showSocialSkills: Boolean(this._value("showSocialSkills", false)),
|
||||
showSupernaturalSkills: Boolean(this._value("showSupernaturalSkills", false)),
|
||||
combatTargetingMode: String(this._value("combatTargetingMode", "prefer-selected")),
|
||||
ammoDeficitMode: String(this._value("ammoDeficitMode", "warn")),
|
||||
debugRollOverlay: Boolean(this._value("debugRollOverlay", false))
|
||||
};
|
||||
|
||||
const content = `
|
||||
<form id="sla-rules-console-form" class="brp">
|
||||
<div class="form-group"><label>Initiative Mode</label>
|
||||
<select name="initiativeMode">
|
||||
<option value="classic" ${values.initiativeMode === "classic" ? "selected" : ""}>Classic (Formula)</option>
|
||||
<option value="escalation" ${values.initiativeMode === "escalation" ? "selected" : ""}>SLA Escalation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>PULP-SLA (Double HP)</label><input type="checkbox" name="pulpSla" ${values.pulpSla ? "checked" : ""}></div>
|
||||
<hr />
|
||||
<div class="form-group"><label>Show Manipulation Skills</label><input type="checkbox" name="showManipulationSkills" ${values.showManipulationSkills ? "checked" : ""}></div>
|
||||
<div class="form-group"><label>Show Social Skills</label><input type="checkbox" name="showSocialSkills" ${values.showSocialSkills ? "checked" : ""}></div>
|
||||
<div class="form-group"><label>Show Supernatural Skills</label><input type="checkbox" name="showSupernaturalSkills" ${values.showSupernaturalSkills ? "checked" : ""}></div>
|
||||
<hr />
|
||||
<div class="form-group"><label>Combat Targeting Mode</label>
|
||||
<select name="combatTargetingMode">
|
||||
<option value="prefer-selected" ${values.combatTargetingMode === "prefer-selected" ? "selected" : ""}>Prefer selected target, fallback card/dummy</option>
|
||||
<option value="selected-only" ${values.combatTargetingMode === "selected-only" ? "selected" : ""}>Selected target only (else dummy)</option>
|
||||
<option value="card-then-selected" ${values.combatTargetingMode === "card-then-selected" ? "selected" : ""}>Prefer card target, fallback selected/dummy</option>
|
||||
<option value="dummy-only" ${values.combatTargetingMode === "dummy-only" ? "selected" : ""}>Always dummy target</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>Ammo Deficit Policy</label>
|
||||
<select name="ammoDeficitMode">
|
||||
<option value="allow" ${values.ammoDeficitMode === "allow" ? "selected" : ""}>Allow deficit</option>
|
||||
<option value="warn" ${values.ammoDeficitMode === "warn" ? "selected" : ""}>Allow + warning</option>
|
||||
<option value="block" ${values.ammoDeficitMode === "block" ? "selected" : ""}>Block when insufficient CRD</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group"><label>GM Roll Debug Overlay</label><input type="checkbox" name="debugRollOverlay" ${values.debugRollOverlay ? "checked" : ""}></div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const formData = await SLADialog.waitForm({
|
||||
title: "SLA Rules Console",
|
||||
content,
|
||||
formSelector: "#sla-rules-console-form",
|
||||
submitLabel: "Apply Rules",
|
||||
cancelLabel: "Cancel",
|
||||
cancelValue: false
|
||||
});
|
||||
if (!formData) return { ok: false, reason: "cancelled" };
|
||||
|
||||
const updates = {
|
||||
initiativeMode: String(formData.get("initiativeMode") ?? "escalation"),
|
||||
pulpSla: formData.get("pulpSla") !== null,
|
||||
showManipulationSkills: formData.get("showManipulationSkills") !== null,
|
||||
showSocialSkills: formData.get("showSocialSkills") !== null,
|
||||
showSupernaturalSkills: formData.get("showSupernaturalSkills") !== null,
|
||||
combatTargetingMode: String(formData.get("combatTargetingMode") ?? "prefer-selected"),
|
||||
ammoDeficitMode: String(formData.get("ammoDeficitMode") ?? "warn"),
|
||||
debugRollOverlay: formData.get("debugRollOverlay") !== null
|
||||
};
|
||||
|
||||
for (const def of this.SETTINGS) {
|
||||
if (!(def.key in updates)) continue;
|
||||
await game.settings.set("sla-industries-brp", def.key, updates[def.key]);
|
||||
}
|
||||
|
||||
ui.notifications.info("SLA rules updated.");
|
||||
return { ok: true, updates };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
export class SLASkillPoints {
|
||||
static DEFAULTS = {
|
||||
professionalTotal: 300,
|
||||
generalTotal: 120,
|
||||
professionalPerSkillMax: 45,
|
||||
generalPerSkillMax: 25,
|
||||
creationCap: 75,
|
||||
professionalSeedPerSkill: 20
|
||||
};
|
||||
|
||||
static resolveActor(actor) {
|
||||
if (!actor) return null;
|
||||
if (actor instanceof Actor) return actor;
|
||||
if (typeof actor === "string") {
|
||||
return game.actors.get(actor) ?? game.actors.getName(actor) ?? null;
|
||||
}
|
||||
if (actor.id) return game.actors.get(actor.id) ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
static getBrpid(document) {
|
||||
if (!document) return "";
|
||||
return String(
|
||||
document.flags?.[game.system.id]?.brpidFlag?.id
|
||||
?? document.flags?.brp?.brpidFlag?.id
|
||||
?? ""
|
||||
);
|
||||
}
|
||||
|
||||
static normalizeText(value) {
|
||||
return String(value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
static resolveTrainingPackageDoc(ref = null, actor = null) {
|
||||
if (ref && ref.type === "profession") return ref;
|
||||
|
||||
const candidates = game.items.filter((item) => item.type === "profession");
|
||||
if (!candidates.length) return null;
|
||||
|
||||
if (!ref && actor) {
|
||||
const existing = actor.items.find((item) => item.type === "profession");
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
if (typeof ref !== "string" || !ref.trim()) return null;
|
||||
const lookup = ref.trim();
|
||||
const norm = this.normalizeText(lookup);
|
||||
const byBrpid = candidates.find((item) => this.getBrpid(item) === lookup);
|
||||
if (byBrpid) return byBrpid;
|
||||
return candidates.find((item) => this.normalizeText(item.name) === norm) ?? null;
|
||||
}
|
||||
|
||||
static getTrainingMeta(actor, packageDoc = null) {
|
||||
const resolvedPackage = packageDoc ?? this.resolveTrainingPackageDoc(null, actor);
|
||||
const meta = foundry.utils.deepClone(
|
||||
resolvedPackage?.flags?.[game.system.id]?.slaTraining
|
||||
?? resolvedPackage?.flags?.brp?.slaTraining
|
||||
?? {}
|
||||
);
|
||||
|
||||
const fromActor = actor?.system?.skillPools ?? {};
|
||||
const professionalTotal = Number(
|
||||
meta.professionalSkillPool
|
||||
?? fromActor?.professional?.total
|
||||
?? this.DEFAULTS.professionalTotal
|
||||
);
|
||||
const generalTotal = Number(
|
||||
meta.generalSkillPool
|
||||
?? fromActor?.general?.total
|
||||
?? this.DEFAULTS.generalTotal
|
||||
);
|
||||
const professionalPerSkillMax = Number(
|
||||
meta.professionalSkillMax
|
||||
?? fromActor?.professional?.perSkillMax
|
||||
?? this.DEFAULTS.professionalPerSkillMax
|
||||
);
|
||||
const generalPerSkillMax = Number(
|
||||
meta.generalSkillMax
|
||||
?? fromActor?.general?.perSkillMax
|
||||
?? this.DEFAULTS.generalPerSkillMax
|
||||
);
|
||||
const creationCap = Number(
|
||||
meta.creationSkillCap
|
||||
?? fromActor?.creationCap
|
||||
?? this.DEFAULTS.creationCap
|
||||
);
|
||||
const professionalSeedPerSkill = Number(
|
||||
meta.professionalSeedPerSkill
|
||||
?? fromActor?.professional?.seedPerSkill
|
||||
?? this.DEFAULTS.professionalSeedPerSkill
|
||||
);
|
||||
|
||||
const skillBrpids = Array.isArray(meta.skillBrpids) ? meta.skillBrpids.filter(Boolean) : [];
|
||||
return {
|
||||
packageDoc: resolvedPackage ?? null,
|
||||
professionalTotal: Number.isFinite(professionalTotal) ? professionalTotal : this.DEFAULTS.professionalTotal,
|
||||
generalTotal: Number.isFinite(generalTotal) ? generalTotal : this.DEFAULTS.generalTotal,
|
||||
professionalPerSkillMax: Number.isFinite(professionalPerSkillMax) ? professionalPerSkillMax : this.DEFAULTS.professionalPerSkillMax,
|
||||
generalPerSkillMax: Number.isFinite(generalPerSkillMax) ? generalPerSkillMax : this.DEFAULTS.generalPerSkillMax,
|
||||
creationCap: Number.isFinite(creationCap) ? creationCap : this.DEFAULTS.creationCap,
|
||||
professionalSeedPerSkill: Number.isFinite(professionalSeedPerSkill) ? professionalSeedPerSkill : this.DEFAULTS.professionalSeedPerSkill,
|
||||
skillBrpids,
|
||||
skillRefs: Array.isArray(meta.skillRefs) ? meta.skillRefs : []
|
||||
};
|
||||
}
|
||||
|
||||
static getProfessionalSkillSet(actor, meta = null) {
|
||||
const set = new Set();
|
||||
const source = meta ?? this.getTrainingMeta(actor);
|
||||
for (const brpid of source.skillBrpids ?? []) {
|
||||
if (brpid) set.add(String(brpid));
|
||||
}
|
||||
|
||||
if (!set.size && actor?.system?.trainingPackage?.professionalSkills?.length) {
|
||||
for (const brpid of actor.system.trainingPackage.professionalSkills) {
|
||||
if (brpid) set.add(String(brpid));
|
||||
}
|
||||
}
|
||||
|
||||
if (!set.size && actor) {
|
||||
const profession = actor.items.find((item) => item.type === "profession");
|
||||
const rows = profession?.system?.skills ?? [];
|
||||
for (const row of rows) {
|
||||
if (row?.brpid) set.add(String(row.brpid));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback and compatibility: any skill flagged as occupation/professional
|
||||
// is considered a professional package skill for pool restrictions + seeding.
|
||||
if (actor) {
|
||||
for (const skill of actor.items.filter((item) => item.type === "skill")) {
|
||||
if (!skill.system?.occupation) continue;
|
||||
const brpid = this.getBrpid(skill);
|
||||
if (brpid) set.add(String(brpid));
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
static getCreationCapForActor(actor) {
|
||||
const actorDoc = this.resolveActor(actor) ?? actor;
|
||||
const cap = Number(actorDoc?.system?.skillPools?.creationCap);
|
||||
if (Number.isFinite(cap)) return cap;
|
||||
const meta = this.getTrainingMeta(actorDoc);
|
||||
return Number(meta.creationCap ?? this.DEFAULTS.creationCap);
|
||||
}
|
||||
|
||||
static getSkillOutsidePoolBase(skill) {
|
||||
return Number(skill?.system?.base ?? 0)
|
||||
+ Number(skill?.system?.culture ?? 0)
|
||||
+ Number(skill?.system?.personality ?? 0)
|
||||
+ Number(skill?.system?.effects ?? 0)
|
||||
+ Number(skill?.system?.xp ?? 0);
|
||||
}
|
||||
|
||||
static buildPoolState(actor, meta = null) {
|
||||
const actorDoc = this.resolveActor(actor) ?? actor;
|
||||
const trainingMeta = meta ?? this.getTrainingMeta(actorDoc);
|
||||
const professionalSet = this.getProfessionalSkillSet(actorDoc, trainingMeta);
|
||||
|
||||
let spentProfessional = 0;
|
||||
let spentGeneral = 0;
|
||||
for (const skill of actorDoc?.items?.filter((item) => item.type === "skill") ?? []) {
|
||||
spentProfessional += Math.max(0, Number(skill.system?.profession ?? 0));
|
||||
spentGeneral += Math.max(0, Number(skill.system?.personal ?? 0));
|
||||
}
|
||||
|
||||
const professionalTotal = Math.max(0, Number(trainingMeta.professionalTotal ?? this.DEFAULTS.professionalTotal));
|
||||
const generalTotal = Math.max(0, Number(trainingMeta.generalTotal ?? this.DEFAULTS.generalTotal));
|
||||
|
||||
return {
|
||||
creationMode: Boolean(actorDoc?.system?.creationMode ?? true),
|
||||
creationCap: Math.max(1, Number(trainingMeta.creationCap ?? this.DEFAULTS.creationCap)),
|
||||
professional: {
|
||||
total: professionalTotal,
|
||||
spent: spentProfessional,
|
||||
remaining: Math.max(0, professionalTotal - spentProfessional),
|
||||
perSkillMax: Math.max(1, Number(trainingMeta.professionalPerSkillMax ?? this.DEFAULTS.professionalPerSkillMax)),
|
||||
seedPerSkill: Math.max(0, Number(trainingMeta.professionalSeedPerSkill ?? this.DEFAULTS.professionalSeedPerSkill))
|
||||
},
|
||||
general: {
|
||||
total: generalTotal,
|
||||
spent: spentGeneral,
|
||||
remaining: Math.max(0, generalTotal - spentGeneral),
|
||||
perSkillMax: Math.max(1, Number(trainingMeta.generalPerSkillMax ?? this.DEFAULTS.generalPerSkillMax))
|
||||
},
|
||||
packageName: trainingMeta.packageDoc?.name ?? String(actorDoc?.system?.trainingPackage?.name ?? ""),
|
||||
packageSkillBrpids: [...professionalSet]
|
||||
};
|
||||
}
|
||||
|
||||
static async initializeForActor({
|
||||
actor,
|
||||
trainingPackage = null,
|
||||
resetAllocations = false,
|
||||
preserveGeneral = false,
|
||||
creationMode = true,
|
||||
seedPackageProfessional = false,
|
||||
professionalSeedPerSkill = null
|
||||
} = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
if (!actorDoc || actorDoc.type !== "character") return null;
|
||||
|
||||
const packageDoc = this.resolveTrainingPackageDoc(trainingPackage, actorDoc);
|
||||
const meta = this.getTrainingMeta(actorDoc, packageDoc);
|
||||
const packageSkillSet = this.getProfessionalSkillSet(actorDoc, meta);
|
||||
|
||||
if (resetAllocations) {
|
||||
const updates = [];
|
||||
for (const skill of actorDoc.items.filter((item) => item.type === "skill")) {
|
||||
const patch = { _id: skill.id };
|
||||
let changed = false;
|
||||
if (Number(skill.system?.profession ?? 0) !== 0) {
|
||||
patch["system.profession"] = 0;
|
||||
changed = true;
|
||||
}
|
||||
if (!preserveGeneral && Number(skill.system?.personal ?? 0) !== 0) {
|
||||
patch["system.personal"] = 0;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) updates.push(patch);
|
||||
}
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actorDoc });
|
||||
}
|
||||
}
|
||||
|
||||
if (seedPackageProfessional && packageSkillSet.size) {
|
||||
const allSkills = actorDoc.items.filter((item) => item.type === "skill");
|
||||
const seedValue = Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
Number(
|
||||
professionalSeedPerSkill
|
||||
?? meta.professionalSeedPerSkill
|
||||
?? this.DEFAULTS.professionalSeedPerSkill
|
||||
)
|
||||
)
|
||||
);
|
||||
const proPerSkillMax = Math.max(1, Number(meta.professionalPerSkillMax ?? this.DEFAULTS.professionalPerSkillMax));
|
||||
const creationCap = Math.max(1, Number(meta.creationCap ?? this.DEFAULTS.creationCap));
|
||||
let seedBudget = Math.max(0, Number(meta.professionalTotal ?? this.DEFAULTS.professionalTotal));
|
||||
const updates = [];
|
||||
|
||||
for (const skill of allSkills) {
|
||||
const brpid = this.getBrpid(skill);
|
||||
const isPackageSkill = packageSkillSet.has(brpid);
|
||||
const currentPro = Math.max(0, Number(skill.system?.profession ?? 0));
|
||||
const currentGen = Math.max(0, Number(skill.system?.personal ?? 0));
|
||||
let targetPro = currentPro;
|
||||
|
||||
if (isPackageSkill) {
|
||||
const outside = this.getSkillOutsidePoolBase(skill);
|
||||
const allocCap = Math.max(0, creationCap - outside);
|
||||
const allowedProByCap = Math.max(0, allocCap - currentGen);
|
||||
targetPro = Math.min(seedValue, proPerSkillMax, allowedProByCap);
|
||||
const growth = Math.max(0, targetPro - currentPro);
|
||||
if (growth > seedBudget) {
|
||||
targetPro = currentPro + seedBudget;
|
||||
}
|
||||
seedBudget = Math.max(0, seedBudget - Math.max(0, targetPro - currentPro));
|
||||
} else if (resetAllocations && currentPro !== 0) {
|
||||
targetPro = 0;
|
||||
}
|
||||
|
||||
if (targetPro !== currentPro) {
|
||||
updates.push({
|
||||
_id: skill.id,
|
||||
"system.profession": targetPro
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actorDoc });
|
||||
}
|
||||
}
|
||||
|
||||
const state = this.buildPoolState(actorDoc, meta);
|
||||
await actorDoc.update({
|
||||
"system.creationMode": Boolean(creationMode),
|
||||
"system.trainingPackage.name": packageDoc?.name ?? "",
|
||||
"system.trainingPackage.professionalSkills": [...packageSkillSet],
|
||||
"system.skillPools.creationCap": state.creationCap,
|
||||
"system.skillPools.professional.total": state.professional.total,
|
||||
"system.skillPools.professional.spent": state.professional.spent,
|
||||
"system.skillPools.professional.remaining": state.professional.remaining,
|
||||
"system.skillPools.professional.perSkillMax": state.professional.perSkillMax,
|
||||
"system.skillPools.professional.seedPerSkill": state.professional.seedPerSkill,
|
||||
"system.skillPools.general.total": state.general.total,
|
||||
"system.skillPools.general.spent": state.general.spent,
|
||||
"system.skillPools.general.remaining": state.general.remaining,
|
||||
"system.skillPools.general.perSkillMax": state.general.perSkillMax
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
static async finalizeCreation(actor) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
if (!actorDoc || actorDoc.type !== "character") return null;
|
||||
const synced = await this.syncActorSkillPools(actorDoc, { clamp: true });
|
||||
await actorDoc.update({ "system.creationMode": false });
|
||||
return synced;
|
||||
}
|
||||
|
||||
static async syncActorSkillPools(actor, { clamp = true } = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
if (!actorDoc || actorDoc.type !== "character") return null;
|
||||
|
||||
const meta = this.getTrainingMeta(actorDoc);
|
||||
const packageSkills = this.getProfessionalSkillSet(actorDoc, meta);
|
||||
const state = this.buildPoolState(actorDoc, meta);
|
||||
|
||||
const updates = [];
|
||||
if (clamp && state.creationMode) {
|
||||
for (const skill of actorDoc.items.filter((item) => item.type === "skill")) {
|
||||
const brpid = this.getBrpid(skill);
|
||||
let pro = Math.max(0, Number(skill.system?.profession ?? 0));
|
||||
let gen = Math.max(0, Number(skill.system?.personal ?? 0));
|
||||
const outside = this.getSkillOutsidePoolBase(skill);
|
||||
const isPackageSkill = packageSkills.has(brpid);
|
||||
|
||||
if (!isPackageSkill && pro > 0) pro = 0;
|
||||
|
||||
pro = Math.min(pro, state.professional.perSkillMax);
|
||||
gen = Math.min(gen, state.general.perSkillMax);
|
||||
|
||||
const allocCap = Math.max(0, state.creationCap - outside);
|
||||
if ((pro + gen) > allocCap) {
|
||||
let overflow = (pro + gen) - allocCap;
|
||||
const dropGen = Math.min(gen, overflow);
|
||||
gen -= dropGen;
|
||||
overflow -= dropGen;
|
||||
if (overflow > 0) {
|
||||
pro = Math.max(0, pro - overflow);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
pro !== Number(skill.system?.profession ?? 0)
|
||||
|| gen !== Number(skill.system?.personal ?? 0)
|
||||
) {
|
||||
updates.push({
|
||||
_id: skill.id,
|
||||
"system.profession": pro,
|
||||
"system.personal": gen
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actorDoc });
|
||||
}
|
||||
|
||||
const finalState = this.buildPoolState(actorDoc, meta);
|
||||
await actorDoc.update({
|
||||
"system.skillPools.creationCap": finalState.creationCap,
|
||||
"system.skillPools.professional.total": finalState.professional.total,
|
||||
"system.skillPools.professional.spent": finalState.professional.spent,
|
||||
"system.skillPools.professional.remaining": finalState.professional.remaining,
|
||||
"system.skillPools.professional.perSkillMax": finalState.professional.perSkillMax,
|
||||
"system.skillPools.professional.seedPerSkill": finalState.professional.seedPerSkill,
|
||||
"system.skillPools.general.total": finalState.general.total,
|
||||
"system.skillPools.general.spent": finalState.general.spent,
|
||||
"system.skillPools.general.remaining": finalState.general.remaining,
|
||||
"system.skillPools.general.perSkillMax": finalState.general.perSkillMax,
|
||||
"system.trainingPackage.professionalSkills": finalState.packageSkillBrpids
|
||||
});
|
||||
|
||||
return { ...finalState, updates: updates.length };
|
||||
}
|
||||
|
||||
static async allocateSkillPoints({
|
||||
actor,
|
||||
skill,
|
||||
field,
|
||||
value
|
||||
} = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
if (!actorDoc || actorDoc.type !== "character") {
|
||||
return { ok: false, error: "Invalid actor." };
|
||||
}
|
||||
if (!actorDoc.system?.creationMode) {
|
||||
return { ok: false, error: "Skill point allocation is locked after character creation." };
|
||||
}
|
||||
|
||||
const skillDoc = typeof skill === "string"
|
||||
? actorDoc.items.get(skill)
|
||||
: skill;
|
||||
if (!skillDoc || skillDoc.type !== "skill") {
|
||||
return { ok: false, error: "Invalid skill." };
|
||||
}
|
||||
|
||||
const normalizedField = String(field ?? "").toLowerCase();
|
||||
const isProfessional = normalizedField === "profession" || normalizedField === "professional";
|
||||
const isGeneral = normalizedField === "personal" || normalizedField === "general";
|
||||
if (!isProfessional && !isGeneral) {
|
||||
return { ok: false, error: "Unsupported allocation field." };
|
||||
}
|
||||
|
||||
const state = this.buildPoolState(actorDoc);
|
||||
const brpid = this.getBrpid(skillDoc);
|
||||
const inPackage = state.packageSkillBrpids.includes(brpid);
|
||||
if (isProfessional && !inPackage) {
|
||||
return { ok: false, error: "Professional points can only be spent on package skills." };
|
||||
}
|
||||
|
||||
const currentPro = Math.max(0, Number(skillDoc.system?.profession ?? 0));
|
||||
const currentGen = Math.max(0, Number(skillDoc.system?.personal ?? 0));
|
||||
const outside = this.getSkillOutsidePoolBase(skillDoc);
|
||||
|
||||
const requested = Math.max(0, Math.floor(Number(value ?? 0)));
|
||||
const targetProUncapped = isProfessional ? requested : currentPro;
|
||||
const targetGenUncapped = isGeneral ? requested : currentGen;
|
||||
|
||||
let targetPro = Math.min(targetProUncapped, state.professional.perSkillMax);
|
||||
let targetGen = Math.min(targetGenUncapped, state.general.perSkillMax);
|
||||
|
||||
const allocCap = Math.max(0, state.creationCap - outside);
|
||||
if ((targetPro + targetGen) > allocCap) {
|
||||
if (isProfessional) {
|
||||
targetPro = Math.max(0, allocCap - targetGen);
|
||||
} else {
|
||||
targetGen = Math.max(0, allocCap - targetPro);
|
||||
}
|
||||
}
|
||||
|
||||
const delta = isProfessional ? (targetPro - currentPro) : (targetGen - currentGen);
|
||||
const remaining = isProfessional ? state.professional.remaining : state.general.remaining;
|
||||
if (delta > remaining) {
|
||||
if (isProfessional) {
|
||||
targetPro = currentPro + remaining;
|
||||
} else {
|
||||
targetGen = currentGen + remaining;
|
||||
}
|
||||
}
|
||||
|
||||
const finalPro = Math.max(0, Math.floor(targetPro));
|
||||
const finalGen = Math.max(0, Math.floor(targetGen));
|
||||
const changed = finalPro !== currentPro || finalGen !== currentGen;
|
||||
if (!changed) {
|
||||
const adjusted = requested !== (isProfessional ? finalPro : finalGen);
|
||||
return {
|
||||
ok: true,
|
||||
changed: false,
|
||||
adjusted,
|
||||
value: isProfessional ? finalPro : finalGen
|
||||
};
|
||||
}
|
||||
|
||||
await skillDoc.update({
|
||||
"system.profession": finalPro,
|
||||
"system.personal": finalGen
|
||||
});
|
||||
|
||||
const synced = await this.syncActorSkillPools(actorDoc, { clamp: true });
|
||||
return {
|
||||
ok: true,
|
||||
changed: true,
|
||||
adjusted: requested !== (isProfessional ? finalPro : finalGen),
|
||||
value: isProfessional ? finalPro : finalGen,
|
||||
state: synced
|
||||
};
|
||||
}
|
||||
|
||||
static async autoAllocateForActor({
|
||||
actor,
|
||||
strategy = "balanced",
|
||||
finalize = false
|
||||
} = {}) {
|
||||
const actorDoc = this.resolveActor(actor);
|
||||
if (!actorDoc || actorDoc.type !== "character") {
|
||||
return { ok: false, error: "Invalid actor." };
|
||||
}
|
||||
|
||||
const syncedState = await this.syncActorSkillPools(actorDoc, { clamp: true });
|
||||
if (!syncedState?.creationMode) {
|
||||
return { ok: false, error: "Skill point allocation is locked after character creation." };
|
||||
}
|
||||
|
||||
const allSkills = actorDoc.items.filter((item) => item.type === "skill");
|
||||
const byBrpid = new Map(
|
||||
allSkills.map((item) => [this.getBrpid(item), item])
|
||||
);
|
||||
const packageSkills = (syncedState.packageSkillBrpids ?? [])
|
||||
.map((brpid) => byBrpid.get(brpid))
|
||||
.filter(Boolean);
|
||||
|
||||
const proMap = new Map(allSkills.map((item) => [item.id, Math.max(0, Number(item.system?.profession ?? 0))]));
|
||||
const genMap = new Map(allSkills.map((item) => [item.id, Math.max(0, Number(item.system?.personal ?? 0))]));
|
||||
|
||||
const canAddPoint = (skill, field) => {
|
||||
const currentPro = proMap.get(skill.id) ?? 0;
|
||||
const currentGen = genMap.get(skill.id) ?? 0;
|
||||
const outside = this.getSkillOutsidePoolBase(skill);
|
||||
if ((outside + currentPro + currentGen) >= syncedState.creationCap) return false;
|
||||
if (field === "professional") {
|
||||
return currentPro < syncedState.professional.perSkillMax;
|
||||
}
|
||||
return currentGen < syncedState.general.perSkillMax;
|
||||
};
|
||||
|
||||
const distribute = (skills, field, points) => {
|
||||
if (!skills.length || points <= 0) return points;
|
||||
let remaining = Math.max(0, Math.floor(points));
|
||||
let cursor = 0;
|
||||
let noProgress = 0;
|
||||
const limit = Math.max(1, skills.length);
|
||||
|
||||
while (remaining > 0 && noProgress < limit) {
|
||||
const skill = skills[cursor % skills.length];
|
||||
cursor += 1;
|
||||
|
||||
if (!canAddPoint(skill, field)) {
|
||||
noProgress += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field === "professional") {
|
||||
proMap.set(skill.id, (proMap.get(skill.id) ?? 0) + 1);
|
||||
} else {
|
||||
genMap.set(skill.id, (genMap.get(skill.id) ?? 0) + 1);
|
||||
}
|
||||
remaining -= 1;
|
||||
noProgress = 0;
|
||||
}
|
||||
|
||||
return remaining;
|
||||
};
|
||||
|
||||
const proUnspent = distribute(
|
||||
packageSkills,
|
||||
"professional",
|
||||
syncedState.professional.remaining
|
||||
);
|
||||
|
||||
let generalTargets = [...allSkills].sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
|
||||
if (strategy === "package-first" && packageSkills.length) {
|
||||
const packageIds = new Set(packageSkills.map((item) => item.id));
|
||||
generalTargets = [
|
||||
...packageSkills,
|
||||
...generalTargets.filter((item) => !packageIds.has(item.id))
|
||||
];
|
||||
}
|
||||
const genUnspent = distribute(
|
||||
generalTargets,
|
||||
"general",
|
||||
syncedState.general.remaining
|
||||
);
|
||||
|
||||
const updates = [];
|
||||
for (const skill of allSkills) {
|
||||
const nextPro = proMap.get(skill.id) ?? 0;
|
||||
const nextGen = genMap.get(skill.id) ?? 0;
|
||||
const currentPro = Math.max(0, Number(skill.system?.profession ?? 0));
|
||||
const currentGen = Math.max(0, Number(skill.system?.personal ?? 0));
|
||||
if (nextPro !== currentPro || nextGen !== currentGen) {
|
||||
updates.push({
|
||||
_id: skill.id,
|
||||
"system.profession": nextPro,
|
||||
"system.personal": nextGen
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length) {
|
||||
await Item.updateDocuments(updates, { parent: actorDoc });
|
||||
}
|
||||
|
||||
const stateAfter = await this.syncActorSkillPools(actorDoc, { clamp: true });
|
||||
if (finalize) {
|
||||
await this.finalizeCreation(actorDoc);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
strategy,
|
||||
updatedSkills: updates.length,
|
||||
unspent: {
|
||||
professional: proUnspent,
|
||||
general: genUnspent
|
||||
},
|
||||
state: stateAfter
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BRPCheck } from './check.mjs';
|
||||
import { GRCard } from '../cards/combined-card.mjs';
|
||||
import { BRPDamage } from '../combat/damage.mjs';
|
||||
|
||||
export class BRPSystemSocket {
|
||||
|
||||
static async callSocket(data) {
|
||||
switch (data.type) {
|
||||
case 'chatUpdate':
|
||||
if (data.to === game.user.id) {
|
||||
await BRPCheck.handleChatButton(data.value);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'GRAdd':
|
||||
if (data.to === game.user.id) {
|
||||
await GRCard.GRAdd(data.value.config, data.value.msgId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'firstAidFumble':
|
||||
if (data.to === game.user.id) {
|
||||
BRPDamage.firstAidFumble(data.value.actorId, data.value.actorType, data.value.locationId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'toggleMapNotes':
|
||||
game.settings.set('core', NotesLayer.TOGGLE_SETTING, data.toggle === true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { BRPCharacterSheet } from '../actor/sheets/character.mjs';
|
||||
import { BRPactorDetails } from './actorDetails.mjs';
|
||||
import { BRPCheck } from './check.mjs';
|
||||
|
||||
export class BRPUtilities {
|
||||
|
||||
static async getDataset(el, dataitem) {
|
||||
const elem = await el.target ? el.target : el[0];
|
||||
const element = await elem?.closest(".item");
|
||||
return element.dataset[dataitem];
|
||||
}
|
||||
|
||||
static async triggerEdit(el, actor, dataitem) {
|
||||
const itemId = await this.getDataset(el, dataitem)
|
||||
if (!itemId) { return }
|
||||
const item = actor.items.get(itemId);
|
||||
item.sheet.render(true);
|
||||
return
|
||||
}
|
||||
|
||||
static async triggerDelete(el, actor, dataitem) {
|
||||
const itemId = await this.getDataset(el, dataitem)
|
||||
if (!itemId) { return }
|
||||
let name = actor.items.get(itemId).name
|
||||
let type = actor.items.get(itemId).type
|
||||
let confirmation = await this.confirmation(name, type);
|
||||
if (confirmation) {
|
||||
await BRPCharacterSheet.confirmItemDelete(actor, itemId);
|
||||
}
|
||||
return confirmation
|
||||
}
|
||||
|
||||
static async confirmation(name, type) {
|
||||
let title = ""
|
||||
if (type === 'chatMsg') {
|
||||
title = game.i18n.localize('BRP.' + name)
|
||||
} else {
|
||||
title = game.i18n.localize('BRP.delete') + ":" + game.i18n.localize('BRP.' + type) + "(" + name + ")";
|
||||
}
|
||||
let confirmation = await Dialog.confirm({
|
||||
title: title,
|
||||
content: game.i18n.localize('BRP.proceed'),
|
||||
});
|
||||
return confirmation;
|
||||
}
|
||||
|
||||
static async getDataFromDropEvent(event, entityType = 'Item') {
|
||||
if (event.originalEvent) return []
|
||||
try {
|
||||
const dataList = JSON.parse(event.dataTransfer.getData('text/plain'))
|
||||
if (dataList.type === 'Folder' && dataList.documentName === entityType) {
|
||||
const folder = await fromUuid(dataList.uuid)
|
||||
if (!folder) return []
|
||||
return folder.contents
|
||||
} else if (dataList.type === entityType) {
|
||||
const item = await fromUuid(dataList.uuid)
|
||||
if (!item) return []
|
||||
return [item]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
static async professionDelete(event, actor) {
|
||||
const confirmation = await this.triggerDelete(event, actor, "itemId")
|
||||
if (!confirmation) { return }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//Update attributes
|
||||
static async updateAttribute(actor, token, att, adj) {
|
||||
let partic = await BRPactorDetails._getParticipantPriority(token, actor)
|
||||
let checkprop = ""
|
||||
let newVal = partic.system[att].value
|
||||
let newMax = partic.system[att].max
|
||||
if (adj === 'spend') {
|
||||
checkprop = { [`system.${att}.value`]: newVal - 1 }
|
||||
} else if (adj === 'recover' && newVal < newMax) {
|
||||
checkprop = { [`system.${att}.value`]: newVal + 1 }
|
||||
} else if (adj === 'restore') {
|
||||
checkprop = { [`system.${att}.value`]: newMax }
|
||||
} else { return }
|
||||
partic.update(checkprop)
|
||||
}
|
||||
|
||||
//Create Macro
|
||||
static createMacro(bar, data, slot) {
|
||||
if (data.type !== 'Item') return
|
||||
const item = fromUuidSync(data.uuid, bar)
|
||||
if (!item) return
|
||||
let command = ''
|
||||
command = `game.brp.rollItemMacro("${data.uuid}");`
|
||||
if (command !== '') {
|
||||
// Create the macro command
|
||||
const macro = game.macros.contents.find(
|
||||
m => m.name === item.name && m.command === command
|
||||
)
|
||||
if (!macro) {
|
||||
Macro.create(foundry.utils.duplicate({
|
||||
name: item.name,
|
||||
type: 'script',
|
||||
img: item.img,
|
||||
command: command,
|
||||
flags: { "brp.itemMacro": true }
|
||||
})).then(macro => {
|
||||
game.user.assignHotbarMacro(macro, slot)
|
||||
})
|
||||
return false
|
||||
}
|
||||
game.user.assignHotbarMacro(macro, slot)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
//Toggle Beastiary Mode
|
||||
static async beastiaryMode(toggle) {
|
||||
let state = await game.settings.get('sla-industries-brp', 'beastiary')
|
||||
await game.settings.set('sla-industries-brp', 'beastiary', !state)
|
||||
ui.notifications.info(
|
||||
state
|
||||
? game.i18n.localize('BRP.beastiaryOff')
|
||||
: game.i18n.localize('BRP.beastiaryOn')
|
||||
)
|
||||
}
|
||||
|
||||
//Convert to Kebab Case
|
||||
static toKebabCase(s) {
|
||||
if (!s) {
|
||||
return ''
|
||||
}
|
||||
const match = s.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
|
||||
|
||||
if (!match) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return match.join('-').toLowerCase()
|
||||
}
|
||||
|
||||
|
||||
//Copy to Clipboard
|
||||
static async copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999px'
|
||||
textArea.style.top = '-999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
return new Promise((resolve, reject) => {
|
||||
document.execCommand('copy')
|
||||
? resolve()
|
||||
: reject(
|
||||
new Error(game.i18n.localize('BRP.UnableToCopyToClipboard'))
|
||||
)
|
||||
textArea.remove()
|
||||
}).catch(err => ui.notifications.error(err))
|
||||
}
|
||||
} catch (err) {
|
||||
ui.notifications.error(game.i18n.localize('BRP.UnableToCopyToClipboard'))
|
||||
}
|
||||
}
|
||||
|
||||
//Regex expression
|
||||
static quoteRegExp(string) {
|
||||
// https://bitbucket.org/cggaertner/js-hacks/raw/master/quote.js
|
||||
const len = string.length
|
||||
let qString = ''
|
||||
|
||||
for (let current, i = 0; i < len; ++i) {
|
||||
current = string.charAt(i)
|
||||
|
||||
if (current >= ' ' && current <= '~') {
|
||||
if (current === '\\' || current === "'") {
|
||||
qString += '\\'
|
||||
}
|
||||
|
||||
qString += current.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&')
|
||||
} else {
|
||||
switch (current) {
|
||||
case '\b':
|
||||
qString += '\\b'
|
||||
break
|
||||
|
||||
case '\f':
|
||||
qString += '\\f'
|
||||
break
|
||||
|
||||
case '\n':
|
||||
qString += '\\n'
|
||||
break
|
||||
|
||||
case '\r':
|
||||
qString += '\\r'
|
||||
break
|
||||
|
||||
case '\t':
|
||||
qString += '\\t'
|
||||
break
|
||||
|
||||
case '\v':
|
||||
qString += '\\v'
|
||||
break
|
||||
|
||||
default:
|
||||
qString += '\\u'
|
||||
current = current.charCodeAt(0).toString(16)
|
||||
for (let j = 4; --j >= current.length; qString += '0');
|
||||
qString += current
|
||||
}
|
||||
}
|
||||
}
|
||||
return qString
|
||||
}
|
||||
|
||||
//Sort by Name Key
|
||||
static sortByNameKey(a, b) {
|
||||
return a.name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase()
|
||||
.localeCompare(
|
||||
b.name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
//Send Item Description to Chat
|
||||
static async sendToChat(el, actor, dataitem) {
|
||||
const itemId = await this.getDataset(el, dataitem)
|
||||
if (!itemId) { return }
|
||||
const item = actor.items.get(itemId);
|
||||
let description = item.system.description.replace(/(<([^>]+)>)/g, "");
|
||||
let label = item.name
|
||||
let chatType = CONST.CHAT_MESSAGE_STYLES.OTHER
|
||||
let chatTemplate = 'systems/sla-industries-brp/templates/chat/itemDescription.html'
|
||||
|
||||
let chatMsgData = {
|
||||
description,
|
||||
label,
|
||||
chatType,
|
||||
chatTemplate,
|
||||
}
|
||||
const html = await BRPCheck.startChat(chatMsgData)
|
||||
|
||||
let chatData = {}
|
||||
chatData = {
|
||||
author: game.user.id,
|
||||
type: chatMsgData.chatType,
|
||||
content: html,
|
||||
speaker: {
|
||||
actor,
|
||||
alias: actor.name,
|
||||
},
|
||||
}
|
||||
let msg = await ChatMessage.create(chatData)
|
||||
return msg._id
|
||||
|
||||
}
|
||||
|
||||
}
|
||||