diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 00000000..33d736be --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,92 @@ +name: prerelease +run-name: Create prerelease +on: + push: + branches: + - 'release-*' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # Load the manifest into memory + - name: Load system manifest + id: manifest + uses: zoexx/github-action-json-file-properties@release + with: + file_path: "./src/system.json" + + # Set up variables + - name: Set up vars + run: | + BRANCH=${{github.ref_name}} + RELEASE_VERSION=$(echo ${{github.ref_name}} | cut -d'-' -f2) + echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_ENV + echo "ZIP_NAME=cosmere-rpg-prerelease-$RELEASE_VERSION.zip" >> $GITHUB_ENV + echo "DOWNLOAD_URL=https://github.com/${{github.repository}}/releases/download/prerelease-$RELEASE_VERSION/cosmere-rpg-prerelease-$RELEASE_VERSION.zip" >> $GITHUB_ENV + echo "MANIFEST_URL=https://github.com/${{github.repository}}/releases/download/prerelease-$RELEASE_VERSION/system.json" >> $GITHUB_ENV + + # Verify manifest + - name: Verify manifest + run: | + # Verify that the manifest version matches the branch + if [[ ! "${{env.RELEASE_VERSION}}" == $PACKAGE_VERSION ]]; then + echo "Manifest version does not match tag brach." + echo "Manifest version: $PACKAGE_VERSION" + echo "Branch: ${{env.RELEASE_VERSION}}" + echo "Please update the manifest version to match the branch." + exit 1 + fi + env: + PACKAGE_VERSION: ${{ steps.manifest.outputs.version }} + + # Update manifest + - name: Update manifest + uses: TomaszKandula/variable-substitution@v1.0.2 + with: + files: "./src/system.json" + env: + version: "prerelease-${{ env.RELEASE_VERSION }}" + manifest: ${{ env.MANIFEST_URL }} + download: ${{ env.DOWNLOAD_URL }} + + # Set up node + - name: Use Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Install dependencies + - name: NPM install + run: | + npm ci + + # Build + - name: Build release + run: | + npm run build:release + + # Create release + - name: Create release + uses: ncipollo/release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: "Pre-release ${{ env.RELEASE_VERSION }}" + tag: prerelease-${{ env.RELEASE_VERSION }} + artifacts: "./src/system.json, ./${{ env.ZIP_NAME }}" + draft: false + prerelease: true + allowUpdates: true + body: | + Pre-release build of ${{ env.RELEASE_VERSION }}. + This build is automatically generated from the latest changes targeting the ${{ env.RELEASE_VERSION }} release. + ⚠️ **This is a pre-release build and may contain bugs or incomplete features.** ⚠️ + + **Manifest url:** ${{ env.MANIFEST_URL }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..557d698c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +name: release +run-name: Create release +on: + push: + tags: + - 'release-*' + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + # Load the manifest into memory + - name: Load system manifest + id: manifest + uses: zoexx/github-action-json-file-properties@release + with: + file_path: "./src/system.json" + + # Set up variables + - name: Set up vars + run: | + TAG=${{github.ref_name}} + echo "ZIP_NAME=cosmere-rpg-$TAG.zip" >> $GITHUB_ENV + echo "DOWNLOAD_URL=https://github.com/${{github.repository}}/releases/download/$TAG/cosmere-rpg-$TAG.zip" >> $GITHUB_ENV + + # Verify manifest + - name: Verify manifest + run: | + # Verify that the manifest version matches the tag + if [[ ! "${{github.ref_name}}" == release-$PACKAGE_VERSION ]]; then + echo "Manifest version does not match tag name." + echo "Manifest version: $PACKAGE_VERSION" + echo "Tag name: $TAG" + echo "Please update the manifest version to match the tag name." + exit 1 + fi + + # Verify that the download URL matches the release asset + if [[ ! "${{ env.DOWNLOAD_URL }}" == $PACKAGE_DOWNLOAD ]]; then + echo "Download URL does not match release asset." + echo "Download URL: $DOWNLOAD_URL" + echo "Release asset: $PACKAGE_DOWNLOAD" + echo "Please update the manifest download URL to match the release asset." + exit 1 + fi + env: + PACKAGE_VERSION: ${{ steps.manifest.outputs.version }} + PACKAGE_DOWNLOAD: ${{ steps.manifest.outputs.download }} + + # Set up node + - name: Use Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + + # Install dependencies + - name: NPM install + run: | + npm ci + + # Build + - name: Build release + run: | + npm run build:release + + # Fetch latest release + - name: Fetch latest release + id: latest_release + uses: cardinalby/git-get-release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + latest: true + doNotFailIfNotFound: true + + # Determine whether this is a patch or a major/minor release + - name: Determine release type + id: release_type + run: | + if [[ -z "${{ steps.latest_release.outputs.tag_name }}" ]]; then + echo "RELEASE_TYPE=major-minor" >> $GITHUB_ENV + echo "RELEASE_NOTES=./src/release-notes.md" >> $GITHUB_ENV + echo "No previous releases found. Release is a major or minor release." + else + # Get the current version info + CURRENT_VERSION=$(echo ${{github.ref_name}} | cut -d'-' -f2) + CURRENT_VERSION_MAJOR=$(echo $CURRENT_VERSION | cut -d'.' -f1) + CURRENT_VERSION_MINOR=$(echo $CURRENT_VERSION | cut -d'.' -f2) + CURRENT_VERSION_PATCH=$(echo $CURRENT_VERSION | cut -d'.' -f3) + + # Get the latest version info + LATEST_VERSION=$(echo ${{steps.latest_release.outputs.tag_name}} | cut -d'-' -f2) + LATEST_VERSION_MAJOR=$(echo $LATEST_VERSION | cut -d'.' -f1) + LATEST_VERSION_MINOR=$(echo $LATEST_VERSION | cut -d'.' -f2) + LATEST_VERSION_PATCH=$(echo $LATEST_VERSION | cut -d'.' -f3) + + # Determine the release type + if [[ $CURRENT_VERSION_MAJOR -gt $LATEST_VERSION_MAJOR ]] || [[ $CURRENT_VERSION_MINOR -gt $LATEST_VERSION_MINOR ]]; then + echo "RELEASE_TYPE=major-minor" >> $GITHUB_ENV + echo "RELEASE_NOTES=./src/release-notes.md" >> $GITHUB_ENV + echo "Release is a major or minor release." + else + echo "RELEASE_TYPE=patch" >> $GITHUB_ENV + echo "RELEASE_NOTES=./src/patch-notes.md" >> $GITHUB_ENV + echo "Release is a patch release." + fi + fi + + # Create release + - name: Create release + uses: ncipollo/release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: ${{ github.ref_name }} + tag: ${{ github.ref_name }} + bodyFile: ${{ env.RELEASE_NOTES }} + artifacts: "./${{ env.ZIP_NAME }}" + draft: true \ No newline at end of file diff --git a/src/lang/en.json b/src/lang/en.json index 247f8ce9..7efe8fac 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -677,7 +677,9 @@ "Cost": "Activation Cost", "Consume": "Resource Consumption", "Uses": "Limited Uses", - "Recharge": "Recharge" + "Recharge": "Recharge", + "AdditionalFormula": "Roll Formula Extension", + "AdditionalFormulaDescription": "Input any extra modifiers you wish to add to this item's d20 Skill Roll. This input will be treated as a dice formula, but any die you add won't be available for advantage/disadvantage currently. Paramters you can use: @mod, @attribute & @skill.rank refer to the values of the skills selected here, but the whole list of skills and attributes can be accessed - ask on the discord for help!" }, "Attack": { "Title": "Attack", @@ -904,7 +906,8 @@ "Apply": "Apply", "Graze": "Graze", "Full": "Full" - } + }, + "TemporaryBonus": "Temporary Modifiers (formula)" }, "ROLLS": { "Recovery": "[character] rolls their recovery die.", @@ -1007,6 +1010,7 @@ "GENERIC": { "Unknown": "Unknown", "None": "None", + "Custom": "Custom", "Default": "Default", "Document": "Document", "Formula": "Formula", diff --git a/src/system/applications/dialogs/attack-configuration.ts b/src/system/applications/dialogs/attack-configuration.ts index 9f67e855..f511a04d 100644 --- a/src/system/applications/dialogs/attack-configuration.ts +++ b/src/system/applications/dialogs/attack-configuration.ts @@ -1,7 +1,11 @@ import { Attribute } from '@system/types/cosmere'; import { RollMode } from '@system/dice/types'; import { AdvantageMode } from '@system/types/roll'; -import { AnyObject } from '@system/types/utils'; +import { AnyObject, NONE, Nullable } from '@system/types/utils'; +import { + getFormulaDisplayString, + getNullableFromFormInput, +} from '@src/system/utils/generic'; import { D20RollData } from '@system/dice/d20-roll'; import { DamageRollData } from '@system/dice/damage-roll'; @@ -53,6 +57,11 @@ export namespace AttackConfigurationDialog { * Whether or not to include a plot die in the roll */ plotDie?: boolean; + + /** + * A dice formula stating any miscellanious other bonuses or negatives to the specific roll + */ + temporaryModifiers?: string; }; /** @@ -78,7 +87,7 @@ export namespace AttackConfigurationDialog { /** * The attribute that is used for the roll by default */ - defaultAttribute?: Attribute; + defaultAttribute?: Nullable; /** * The roll mode that should be selected by default @@ -87,12 +96,13 @@ export namespace AttackConfigurationDialog { } export interface Result { - attribute: Attribute; + attribute: Nullable; rollMode: RollMode; skillTest: { plotDie: boolean; advantageMode: AdvantageMode; advantageModePlot: AdvantageMode; + temporaryModifiers: string; }; damageRoll: { advantageMode: AdvantageMode; @@ -145,6 +155,7 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix /* eslint-enable @typescript-eslint/unbound-method */ private submitted = false; + private originalFormulaSize = 0; private constructor( private data: AttackConfigurationDialog.Data, @@ -159,6 +170,7 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix }); this.data.skillTest.parts.unshift('1d20'); + this.originalFormulaSize = this.data.skillTest.parts.length; this.data.skillTest.advantageMode ??= AdvantageMode.None; this.data.skillTest.advantageModePlot ??= AdvantageMode.None; this.data.damageRoll.advantageMode ??= AdvantageMode.None; @@ -184,19 +196,32 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix ) { if (event instanceof SubmitEvent) return; - const attribute = formData.get('attribute') as Attribute; + const attribute = getNullableFromFormInput( + formData.get('attribute') as string, + ); const rollMode = formData.get('rollMode') as RollMode; const plotDie = formData.get('plotDie') === 'true'; + const tempMod = formData.get('temporaryMod')?.valueOf() as string; + + // get rid of existing temp mod formula + if (this.data.skillTest.parts.length > this.originalFormulaSize) + this.data.skillTest.parts.pop(); + // add the current ones in for display in the formula bar + this.data.skillTest.parts.push(tempMod); + // store it + this.data.skillTest.temporaryModifiers = tempMod; const skill = this.data.skillTest.data.skill; - const attributeData = this.data.skillTest.data.attributes[attribute]; + const attributeData = attribute + ? this.data.skillTest.data.attributes[attribute] + : { value: 0, bonus: 0 }; const rank = skill.rank; const value = attributeData.value + attributeData.bonus; this.data.skillTest.data.mod = rank + value; this.data.skillTest.plotDie = plotDie; - this.data.defaultAttribute = attribute; + this.data.defaultAttribute = attribute ?? undefined; this.data.defaultRollMode = rollMode; void this.render(); @@ -209,9 +234,13 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix attribute: HTMLSelectElement; rollMode: HTMLSelectElement; plotDie: HTMLInputElement; + temporaryMod: HTMLInputElement; }; - const attribute = form.attribute.value as Attribute; + const attribute = getNullableFromFormInput( + form.attribute.value, + ); + const rollMode = form.rollMode.value as RollMode; const plotDie = form.plotDie.checked; @@ -229,6 +258,7 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix plotDie, advantageMode: skillTestAdvantageMode, advantageModePlot: skillTestAdvantageModePlot, + temporaryModifiers: form.temporaryMod.value, }, damageRoll: { advantageMode: damageRollAdvantageMode, @@ -269,7 +299,7 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix protected _prepareContext() { const skillTestFormula = foundry.dice.Roll.replaceFormulaData( - this.data.skillTest.parts.join(' + '), + getFormulaDisplayString(this.data.skillTest.parts), this.data.skillTest.data, { missing: '0', @@ -277,7 +307,7 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix ); const damageRollFormula = foundry.dice.Roll.replaceFormulaData( - this.data.damageRoll.parts.join(' + '), + getFormulaDisplayString(this.data.damageRoll.parts), this.data.damageRoll.data, { missing: '0', @@ -293,6 +323,7 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix plotDie: this.data.skillTest.plotDie, advantageMode: this.data.skillTest.advantageMode, advantageModePlot: this.data.skillTest.advantageModePlot, + temporaryModifiers: this.data.skillTest.temporaryModifiers, }, damageRoll: { dice: this.data.damageRoll.parts.find((part) => @@ -318,13 +349,16 @@ export class AttackConfigurationDialog extends ComponentHandlebarsApplicationMix }), {}, ), - attributes: Object.entries(CONFIG.COSMERE.attributes).reduce( - (acc, [key, config]) => ({ - ...acc, - [key]: config.label, - }), - {}, - ), + attributes: { + [NONE]: 'GENERIC.None', + ...Object.entries(CONFIG.COSMERE.attributes).reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ), + }, }); } } diff --git a/src/system/applications/dialogs/roll-configuration.ts b/src/system/applications/dialogs/roll-configuration.ts index 4854fda2..e968844c 100644 --- a/src/system/applications/dialogs/roll-configuration.ts +++ b/src/system/applications/dialogs/roll-configuration.ts @@ -1,7 +1,11 @@ import { Attribute } from '@system/types/cosmere'; import { RollMode } from '@system/dice/types'; import { AdvantageMode } from '@system/types/roll'; -import { AnyObject } from '@system/types/utils'; +import { AnyObject, NONE, Nullable } from '@system/types/utils'; +import { + getFormulaDisplayString, + getNullableFromFormInput, +} from '@src/system/utils/generic'; import { D20RollData } from '@system/dice/d20-roll'; @@ -29,6 +33,11 @@ export namespace RollConfigurationDialog { */ parts: string[]; + /** + * A dice formula stating any miscellaneous other bonuses or negatives to the specific roll + */ + temporaryModifiers?: string; + /** * The data to be used when parsing the roll */ @@ -42,7 +51,7 @@ export namespace RollConfigurationDialog { /** * The attribute that is used for the roll by default */ - defaultAttribute?: Attribute; + defaultAttribute?: Nullable; /** * The roll mode that should be selected by default @@ -61,11 +70,12 @@ export namespace RollConfigurationDialog { } export interface Result { - attribute: Attribute; + attribute: Nullable; rollMode: RollMode; plotDie: boolean; advantageMode: AdvantageMode; advantageModePlot: AdvantageMode; + temporaryModifiers: string; } } @@ -113,6 +123,7 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin /* eslint-enable @typescript-eslint/unbound-method */ private submitted = false; + private originalFormulaSize = 0; private constructor( private data: RollConfigurationDialog.Data, @@ -124,6 +135,7 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin }, }); + this.originalFormulaSize = this.data.parts.length; this.data.advantageMode ??= AdvantageMode.None; this.data.advantageModePlot ??= AdvantageMode.None; } @@ -146,17 +158,30 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin ) { if (event instanceof SubmitEvent) return; - const attribute = formData.get('attribute') as Attribute; + const attribute = getNullableFromFormInput( + formData.get('attribute') as string, + ); const rollMode = formData.get('rollMode') as RollMode; const plotDie = formData.get('plotDie') === 'true'; + const tempMod = formData.get('temporaryMod')?.valueOf() as string; + + // get rid of existing temp mod formula + if (this.data.parts.length > this.originalFormulaSize) + this.data.parts.pop(); + // add the current ones in for display in the formula bar + this.data.parts.push(tempMod); + // store it + this.data.temporaryModifiers = tempMod; const skill = this.data.data.skill; - const attributeData = this.data.data.attributes[attribute]; + const attributeData = attribute + ? this.data.data.attributes[attribute] + : { value: 0, bonus: 0 }; const rank = skill.rank; const value = attributeData.value + attributeData.bonus; this.data.data.mod = rank + value; - this.data.defaultAttribute = attribute; + this.data.defaultAttribute = attribute ?? undefined; this.data.defaultRollMode = rollMode; this.data.plotDie = plotDie; @@ -170,9 +195,12 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin attribute: HTMLSelectElement; rollMode: HTMLSelectElement; plotDie: HTMLInputElement; + temporaryMod: HTMLInputElement; }; - const attribute = form.attribute.value as Attribute; + const attribute = getNullableFromFormInput( + form.attribute.value, + ); const rollMode = form.rollMode.value as RollMode; const plotDie = form.plotDie.checked; @@ -180,12 +208,15 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin const advantageModePlot = this.data.advantageModePlot ?? AdvantageMode.None; + const temporaryModifiers = form.temporaryMod.value; + this.resolve({ attribute, rollMode, plotDie, advantageMode, advantageModePlot, + temporaryModifiers, }); this.submitted = true; void this.close(); @@ -222,7 +253,7 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin protected _prepareContext() { const formula = foundry.dice.Roll.replaceFormulaData( - this.data.parts.join(' + '), + getFormulaDisplayString(this.data.parts), this.data.data, { missing: '0', @@ -237,6 +268,7 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin plotDie: this.data.plotDie, advantageMode: this.data.advantageMode, advantageModePlot: this.data.advantageModePlot, + temporaryModifiers: this.data.temporaryModifiers, rollModes: CONFIG.Dice.rollModes, advantageModes: Object.entries( @@ -251,13 +283,16 @@ export class RollConfigurationDialog extends ComponentHandlebarsApplicationMixin }), {}, ), - attributes: Object.entries(CONFIG.COSMERE.attributes).reduce( - (acc, [key, config]) => ({ - ...acc, - [key]: config.label, - }), - {}, - ), + attributes: { + [NONE]: 'GENERIC.None', + ...Object.entries(CONFIG.COSMERE.attributes).reduce( + (acc, [key, config]) => ({ + ...acc, + [key]: config.label, + }), + {}, + ), + }, }); } } diff --git a/src/system/applications/item/base.ts b/src/system/applications/item/base.ts index 28015de1..04187f1d 100644 --- a/src/system/applications/item/base.ts +++ b/src/system/applications/item/base.ts @@ -1,6 +1,6 @@ -import { ArmorTraitId, WeaponTraitId } from '@system/types/cosmere'; +import { ArmorTraitId, Skill, WeaponTraitId } from '@system/types/cosmere'; import { CosmereItem } from '@system/documents/item'; -import { DeepPartial, AnyObject } from '@system/types/utils'; +import { DeepPartial, AnyObject, NONE } from '@system/types/utils'; // Mixins import { ComponentHandlebarsApplicationMixin } from '@system/applications/component-system'; @@ -73,10 +73,7 @@ export class BaseItemSheet extends TabsApplicationMixin( // Get the currency const currency = CONFIG.COSMERE.currencies[currencyId]; - formData.set( - 'system.price.currency', - currency ? currencyId : 'none', - ); + formData.set('system.price.currency', currency ? currencyId : NONE); if (currency) { // Get the primary denomination @@ -86,7 +83,7 @@ export class BaseItemSheet extends TabsApplicationMixin( formData.set( 'system.price.denomination.primary', - primaryDenomination?.id ?? 'none', + primaryDenomination?.id ?? NONE, ); } } @@ -94,43 +91,54 @@ export class BaseItemSheet extends TabsApplicationMixin( if (this.item.hasActivation()) { if ( 'system.activation.cost.type' in formData.object && - formData.object['system.activation.cost.type'] === 'none' + formData.object['system.activation.cost.type'] === NONE ) formData.set('system.activation.cost.type', null); if ( 'system.activation.consume.type' in formData.object && - formData.object['system.activation.consume.type'] === 'none' + formData.object['system.activation.consume.type'] === NONE ) formData.set('system.activation.consume', null); if ( 'system.activation.consume.resource' in formData.object && - formData.object['system.activation.consume.resource'] === 'none' + formData.object['system.activation.consume.resource'] === NONE ) formData.set('system.activation.consume.resource', null); if ( 'system.activation.skill' in formData.object && - formData.object['system.activation.skill'] === 'none' + formData.object['system.activation.skill'] === NONE ) formData.set('system.activation.skill', null); if ( 'system.activation.attribute' in formData.object && - formData.object['system.activation.attribute'] === 'none' + formData.object['system.activation.attribute'] === 'default' + ) { + formData.set( + 'system.activation.attribute', + CONFIG.COSMERE.skills[ + formData.object['system.activation.skill'] as Skill + ].attribute, + ); + } + if ( + 'system.activation.attribute' in formData.object && + formData.object['system.activation.attribute'] === NONE ) formData.set('system.activation.attribute', null); if ( 'system.activation.uses.type' in formData.object && - formData.object['system.activation.uses.type'] === 'none' + formData.object['system.activation.uses.type'] === NONE ) formData.set('system.activation.uses', null); if ( 'system.activation.uses.recharge' in formData.object && - formData.object['system.activation.uses.recharge'] === 'none' + formData.object['system.activation.uses.recharge'] === NONE ) formData.set('system.activation.uses.recharge', null); } @@ -145,19 +153,19 @@ export class BaseItemSheet extends TabsApplicationMixin( if ( 'system.damage.type' in formData.object && - formData.object['system.damage.type'] === 'none' + formData.object['system.damage.type'] === NONE ) formData.set('system.damage.type', null); if ( 'system.damage.skill' in formData.object && - formData.object['system.damage.skill'] === 'none' + formData.object['system.damage.skill'] === NONE ) formData.set('system.damage.skill', null); if ( 'system.damage.attribute' in formData.object && - formData.object['system.damage.attribute'] === 'none' + formData.object['system.damage.attribute'] === NONE ) formData.set('system.damage.attribute', null); } @@ -165,7 +173,7 @@ export class BaseItemSheet extends TabsApplicationMixin( if (this.item.hasAttack()) { if ( 'system.attack.range.unit' in formData.object && - formData.object['system.attack.range.unit'] === 'none' + formData.object['system.attack.range.unit'] === NONE ) formData.set('system.attack.range', null); } diff --git a/src/system/applications/item/components/details-activation.ts b/src/system/applications/item/components/details-activation.ts index acea3c9f..01b36c13 100644 --- a/src/system/applications/item/components/details-activation.ts +++ b/src/system/applications/item/components/details-activation.ts @@ -1,5 +1,5 @@ import { ActivationType } from '@system/types/cosmere'; -import { ConstructorOf } from '@system/types/utils'; +import { ConstructorOf, NONE } from '@system/types/utils'; // Component imports import { HandlebarsApplicationComponent } from '@system/applications/component-system'; @@ -44,7 +44,7 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< {}, ), costTypeSelectOptions: { - none: 'GENERIC.None', + [NONE]: 'GENERIC.None', ...Object.entries(CONFIG.COSMERE.action.costs).reduce( (acc, [key, config]) => ({ ...acc, @@ -54,7 +54,7 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< ), }, consumeTypeSelectOptions: { - none: 'GENERIC.None', + [NONE]: 'GENERIC.None', ...Object.entries( CONFIG.COSMERE.items.activation.consumeTypes, ).reduce( @@ -66,7 +66,7 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< ), }, resourceSelectOptions: { - none: 'GENERIC.None', + [NONE]: 'GENERIC.None', ...Object.entries(CONFIG.COSMERE.resources).reduce( (acc, [key, config]) => ({ ...acc, @@ -76,7 +76,7 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< ), }, usesTypeSelectOptions: { - none: 'GENERIC.None', + [NONE]: 'GENERIC.None', ...Object.entries( CONFIG.COSMERE.items.activation.uses.types, ).reduce( @@ -88,7 +88,7 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< ), }, rechargeSelectOptions: { - none: 'GENERIC.None', + [NONE]: 'GENERIC.None', ...Object.entries( CONFIG.COSMERE.items.activation.uses.recharge, ).reduce( @@ -100,7 +100,7 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< ), }, skillSelectOptions: { - none: 'GENERIC.None', + [NONE]: 'GENERIC.None', ...Object.entries(CONFIG.COSMERE.skills).reduce( (acc, [key, config]) => ({ ...acc, @@ -110,7 +110,8 @@ export class DetailsActivationComponent extends HandlebarsApplicationComponent< ), }, attributeSelectOptions: { - none: 'GENERIC.Default', + [NONE]: 'GENERIC.None', + default: 'GENERIC.Default', ...Object.entries(CONFIG.COSMERE.attributes).reduce( (acc, [key, config]) => ({ ...acc, diff --git a/src/system/data/item/mixins/activatable.ts b/src/system/data/item/mixins/activatable.ts index 853330a7..694463ff 100644 --- a/src/system/data/item/mixins/activatable.ts +++ b/src/system/data/item/mixins/activatable.ts @@ -40,6 +40,7 @@ export interface ActivatableItemData { /* -- Skill test activation -- */ skill?: Skill; attribute?: Attribute; + modifierFormula?: string; plotDie?: boolean; /** @@ -135,6 +136,10 @@ export function ActivatableItemMixin

() { blank: false, choices: Object.keys(CONFIG.COSMERE.attributes), }), + modifierFormula: new foundry.data.fields.StringField({ + nullable: true, + blank: true, + }), plotDie: new foundry.data.fields.BooleanField({ nullable: true, initial: false, diff --git a/src/system/dice/d20-roll.ts b/src/system/dice/d20-roll.ts index d9a1d6ce..d84fc1c8 100644 --- a/src/system/dice/d20-roll.ts +++ b/src/system/dice/d20-roll.ts @@ -9,6 +9,7 @@ import { PlotDie } from './plot-die'; import { RollMode } from './types'; import { hasKey } from '../utils/generic'; import { renderSystemTemplate, TEMPLATES } from '../utils/templates'; +import { Nullable } from '../types/utils'; // Constants const CONFIGURATION_DIALOG_TEMPLATE = @@ -26,10 +27,10 @@ export type D20RollData< } & { mod: number; skill: { - id: Skill; + id: Nullable; rank: number; mod: number; - attribute: Attribute; + attribute: Nullable; }; attribute: number; }; @@ -255,7 +256,9 @@ export class D20Roll extends foundry.dice.Roll { if (result.attribute !== this.options.defaultAttribute) { this.data.skill.attribute = result.attribute; const skill = this.data.skill; - const attribute = this.data.attributes[result.attribute]; + const attribute = result.attribute + ? this.data.attributes[result.attribute] + : { value: 0, bonus: 0 }; this.terms[2] = new foundry.dice.terms.NumericTerm({ number: skill.rank + attribute.value, }); @@ -265,6 +268,12 @@ export class D20Roll extends foundry.dice.Roll { this.options.plotDie = result.plotDie; this.options.advantageMode = result.advantageMode; this.options.advantageModePlot = result.advantageModePlot; + if (result.temporaryModifiers) { + const tempTerms = new Roll(`0 + ${result.temporaryModifiers}`) + .terms; + this.terms = this.terms.concat(tempTerms.slice(1)); + this.resetFormula(); + } this.configureModifiers(); return this; diff --git a/src/system/dice/index.ts b/src/system/dice/index.ts index edf3cd45..f1a2b7f8 100644 --- a/src/system/dice/index.ts +++ b/src/system/dice/index.ts @@ -2,7 +2,10 @@ import { Attribute } from '@system/types/cosmere'; import { D20Roll, D20RollOptions, D20RollData } from './d20-roll'; import { DamageRoll, DamageRollOptions, DamageRollData } from './damage-roll'; -import { determineConfigurationMode } from '../utils/generic'; +import { + determineConfigurationMode, + getFormulaDisplayString, +} from '../utils/generic'; import { AdvantageMode } from '../types/roll'; export * from './d20-roll'; @@ -89,7 +92,7 @@ export async function d20Roll( // Construct the roll const roll = new D20Roll( - ['1d20'].concat(config.parts ?? []).join(' + '), + getFormulaDisplayString(['1d20'].concat(config.parts ?? [])), config.data, { ...config }, ); diff --git a/src/system/documents/chat-message.ts b/src/system/documents/chat-message.ts index 4dd28054..0d0a319c 100644 --- a/src/system/documents/chat-message.ts +++ b/src/system/documents/chat-message.ts @@ -190,9 +190,12 @@ export class CosmereChatMessage extends ChatMessage { icon: 'fa-regular fa-dice-d20', title: game.i18n!.localize('GENERIC.SkillTest'), subtitle: { - skill: CONFIG.COSMERE.skills[skill.id].label, - attribute: - CONFIG.COSMERE.attributes[skill.attribute].labelShort, + skill: skill.id + ? CONFIG.COSMERE.skills[skill.id].label + : `${game.i18n!.localize('GENERIC.Custom')} ${game.i18n!.localize('GENERIC.Skill')}`, + attribute: skill.attribute + ? CONFIG.COSMERE.attributes[skill.attribute].labelShort + : game.i18n?.localize('GENERIC.None'), }, content: await d20Roll.getHTML(), }, diff --git a/src/system/documents/item.ts b/src/system/documents/item.ts index b0f72c34..27fa0a95 100644 --- a/src/system/documents/item.ts +++ b/src/system/documents/item.ts @@ -10,7 +10,7 @@ import { } from '@system/types/cosmere'; import { Goal } from '@system/types/item'; import { GoalItemData } from '@system/data/item/goal'; -import { DeepPartial } from '@system/types/utils'; +import { DeepPartial, NONE, Nullable } from '@system/types/utils'; import { CosmereActor } from './actor'; @@ -66,7 +66,6 @@ import { RollMode } from '@system/dice/types'; import { determineConfigurationMode, getTargetDescriptors, - hasKey, } from '../utils/generic'; import { MESSAGE_TYPES } from './chat-message'; import { renderSystemTemplate, TEMPLATES } from '../utils/templates'; @@ -387,8 +386,9 @@ export class CosmereItem< // Get skill to use const skillId = options.skill ?? this.system.activation.skill; - if (!skillId) return null; - const skill = actor.system.skills[skillId]; + const skill = skillId + ? actor.system.skills[skillId] + : { attribute: null, rank: 0 }; // Get the attribute id const attributeId = @@ -398,21 +398,28 @@ export class CosmereItem< // Set up actor data const data: D20RollData = this.getSkillTestRollData( - skillId, + skillId ? skillId : null, attributeId, actor, ); + const parts = ['@mod'].concat(options.parts ?? []); + if (options.temporaryModifiers) parts.push(options.temporaryModifiers); + // Perform the roll const roll = await d20Roll( foundry.utils.mergeObject(options, { data, chatMessage: false, - title: `${this.name} (${game.i18n!.localize( - CONFIG.COSMERE.skills[skillId].label, - )})`, - defaultAttribute: skill.attribute, - parts: ['@mod'].concat(options.parts ?? []), + title: `${this.name} (${ + skillId + ? game.i18n!.localize( + CONFIG.COSMERE.skills[skillId].label, + ) + : `${game.i18n!.localize('GENERIC.Custom')} ${game.i18n!.localize('GENERIC.Skill')}` + })`, + defaultAttribute: skill.attribute ? skill.attribute : undefined, + parts: parts, plotDie: options.plotDie ?? this.system.activation.plotDie, opportunity: options.opportunity ?? this.system.activation.opportunity, @@ -598,7 +605,6 @@ export class CosmereItem< // Get the skill to use during the skill test const skillTestSkillId = options.skillTest?.skill ?? this.system.activation.skill; - if (!skillTestSkillId) return null; // Get the skill to use during the damage roll const damageSkillId = @@ -607,18 +613,23 @@ export class CosmereItem< skillTestSkillId; // Get the attribute to use during the skill test - let skillTestAttributeId = + let skillTestAttributeId: Nullable = options.skillTest?.attribute ?? this.system.activation.attribute ?? - actor.system.skills[skillTestSkillId].attribute; + null; // Get the attribute to use during the damage roll - const damageAttributeId = + const damageAttributeId: Nullable = options.damage?.attribute ?? this.system.damage.attribute ?? - actor.system.skills[damageSkillId].attribute; + (damageSkillId + ? actor.system.skills[damageSkillId].attribute + : null); options.skillTest ??= {}; + options.skillTest.parts ??= this.system.activation.modifierFormula + ? [this.system.activation.modifierFormula] + : []; options.damage ??= {}; // Handle key modifiers @@ -643,14 +654,18 @@ export class CosmereItem< // Perform configuration if (!fastForward && options.configurable !== false) { const attackConfig = await AttackConfigurationDialog.show({ - title: `${this.name} (${game.i18n!.localize( - CONFIG.COSMERE.skills[skillTestSkillId].label, - )})`, + title: `${this.name} (${ + skillTestSkillId + ? game.i18n!.localize( + CONFIG.COSMERE.skills[skillTestSkillId].label, + ) + : `${game.i18n!.localize('GENERIC.Custom')} ${game.i18n!.localize('GENERIC.Skill')}` + })`, skillTest: { ...options.skillTest, parts: ['@mod'].concat(options.skillTest?.parts ?? []), data: this.getSkillTestRollData( - skillTestSkillId, + skillTestSkillId ?? null, skillTestAttributeId, actor, ), @@ -674,6 +689,9 @@ export class CosmereItem< // If the dialog was closed, exit out of rolls if (!attackConfig) return null; + options.skillTest.temporaryModifiers = + attackConfig.skillTest.temporaryModifiers; + skillTestAttributeId = attackConfig.attribute; options.rollMode = attackConfig.rollMode; @@ -931,6 +949,9 @@ export class CosmereItem< rolls.push(...damageRolls); } + options.parts ??= this.system.activation.modifierFormula + ? [this.system.activation.modifierFormula] + : []; if (this.system.activation.type === ActivationType.SkillTest) { const roll = await this.roll({ ...options, @@ -1159,22 +1180,26 @@ export class CosmereItem< } protected getSkillTestRollData( - skillId: Skill, - attributeId: Attribute, + skillId: Nullable, + attributeId: Nullable, actor: CosmereActor, ): D20RollData { - const skill = actor.system.skills[skillId]; - const attribute = actor.system.attributes[attributeId]; + const skill = skillId + ? actor.system.skills[skillId] + : { attribute: null, rank: 0, mod: {} }; + const attribute = attributeId + ? actor.system.attributes[attributeId] + : { value: 0, bonus: 0 }; const mod = skill.rank + attribute.value + attribute.bonus; return { ...actor.getRollData(), mod, skill: { - id: skillId, + id: skillId ?? null, rank: skill.rank, mod: Derived.getValue(skill.mod) ?? 0, - attribute: attributeId, + attribute: attributeId ? attributeId : skill.attribute, }, attribute: attribute.value, }; @@ -1182,12 +1207,14 @@ export class CosmereItem< protected getDamageRollData( skillId: Skill | undefined, - attributeId: Attribute | undefined, + attributeId: Nullable | undefined, actor: CosmereActor, ): DamageRollData { const skill = skillId ? actor.system.skills[skillId] : undefined; const attribute = attributeId - ? actor.system.attributes[attributeId] + ? attributeId + ? actor.system.attributes[attributeId] + : { value: 0, bonus: 0 } : undefined; const mod = skill !== undefined || attribute !== undefined @@ -1204,7 +1231,7 @@ export class CosmereItem< id: skillId!, rank: skill.rank, mod: Derived.getValue(skill.mod) ?? 0, - attribute: attributeId!, + attribute: attributeId! ? attributeId : skill.attribute, } : undefined, attribute: attribute?.value, @@ -1230,7 +1257,7 @@ export namespace CosmereItem { * The attribute to be used with this item roll. * Used to roll the item with an alternate attribute. */ - attribute?: Attribute; + attribute?: Nullable; /** * Whether or not to generate a chat message for this roll. @@ -1278,6 +1305,11 @@ export namespace CosmereItem { */ parts?: string[]; + /** + * A dice formula stating any miscellanious other bonuses or negatives to the specific roll + */ + temporaryModifiers?: string; + /** * What advantage modifier to apply to the roll * @@ -1318,6 +1350,7 @@ export namespace CosmereItem { | 'skill' | 'attribute' | 'parts' + | 'temporaryModifiers' | 'opportunity' | 'complication' | 'plotDie' diff --git a/src/system/types/cosmere.ts b/src/system/types/cosmere.ts index 66e52bab..c886fca4 100644 --- a/src/system/types/cosmere.ts +++ b/src/system/types/cosmere.ts @@ -66,7 +66,7 @@ export const enum Resource { Investiture = 'inv', } -export const enum Skill { +export enum Skill { Agility = 'agi', Athletics = 'ath', HeavyWeapons = 'hwp', @@ -88,6 +88,7 @@ export const enum Skill { Persuasion = 'prs', Survival = 'sur', } +// export type Skill = (typeof Skills)[keyof typeof Skills]; export const enum DerivedStatistic { MovementRate = 'mvr', diff --git a/src/system/types/utils.ts b/src/system/types/utils.ts index fbeabd5a..c8be26e9 100644 --- a/src/system/types/utils.ts +++ b/src/system/types/utils.ts @@ -4,6 +4,12 @@ export { EmptyObject, } from '@league-of-foundry-developers/foundry-vtt-types/src/types/utils.mjs'; +// Constant to improve UI consistency +export const NONE = 'none'; + +// Simple utility type for easier null definitions, but general rule: only use it when you have one type that is nullable (i.e. prefer X | Y | null over Nullable) +export type Nullable = T | null; + export type SharedKeys = keyof T & keyof U; // NOTE: Using `any` in the below types as the resulting types don't rely on the `any`s diff --git a/src/system/utils/generic.ts b/src/system/utils/generic.ts index ba462df9..fc0b667a 100644 --- a/src/system/utils/generic.ts +++ b/src/system/utils/generic.ts @@ -7,6 +7,7 @@ import { TargetingOptions, } from '../settings'; import { AdvantageMode } from '../types/roll'; +import { NONE } from '../types/utils'; /** * Determine if the keys of a requested keybinding are pressed. @@ -59,6 +60,13 @@ export function hasKey( return key in obj; } +/** + * Converts entries from input forms that will include human-readable "none" into nulls for easier identification in code. + */ +export function getNullableFromFormInput(formField: string | null) { + return formField && formField !== NONE ? (formField as T) : null; +} + /** * Processes pressed keys and provided config values to determine final values for a roll, specifically: * if it should skip the configuration dialog, what advantage mode it is using, and if it has raised stakes. @@ -142,6 +150,22 @@ export function getConstantFromRoll(roll: Roll) { return constant; } +/** + * Converts a list of the various parts of a formula into a displayable string. + * + * @param {string[]} diceParts A parts array as provided from the foundry Roll API. + * @returns {string} The human readable display string for the formula (without terms aggregated). + */ +export function getFormulaDisplayString(diceParts: string[]) { + const joined = diceParts + .join(' + ') + .replace(/\+ -/g, '-') + .replace(/\+ \+/g, '+'); + return joined.endsWith(' + ') || joined.endsWith(' - ') + ? joined.substring(0, joined.length - 3) + : joined; +} + /** * Gets the current set of tokens that are selected or targeted (or both) depending on the chosen setting. * @returns {Set} A set of tokens that the system considers as current targets. diff --git a/src/templates/item/components/details-activation.hbs b/src/templates/item/components/details-activation.hbs index b08552e0..910e17da 100644 --- a/src/templates/item/components/details-activation.hbs +++ b/src/templates/item/components/details-activation.hbs @@ -169,6 +169,21 @@ {{/if}} +

+ +
+ +
+

+ {{localize "COSMERE.Item.Sheet.Activation.AdditionalFormulaDescription"}} +

+
diff --git a/src/templates/roll/dialogs/attack-config.hbs b/src/templates/roll/dialogs/attack-config.hbs index 211f7de9..017bd4c0 100644 --- a/src/templates/roll/dialogs/attack-config.hbs +++ b/src/templates/roll/dialogs/attack-config.hbs @@ -92,6 +92,11 @@
+
+ + +
+
+
+ + +
+