From c8f27770b6e1f023e8c7f574f4fd9736ed4964b7 Mon Sep 17 00:00:00 2001 From: Mike Bloy Date: Sun, 12 May 2024 18:10:35 -0500 Subject: [PATCH] reorg part 2 --- .editorconfig | 4 - .eslintrc.cjs | 14 +- src/module.json | 6 +- src/module/api.js | 22 +- src/module/basePowers.js | 542 -------- src/module/module.js | 112 -- src/module/powers.js | 1637 ------------------------- src/module/powers/arcaneProtection.js | 59 + src/module/powers/banish.js | 86 ++ src/module/powers/basePowers.js | 618 ++++++++++ src/module/powers/powers.js | 1452 ++++++++++++++++++++++ src/module/preloadTemplates.js | 20 +- src/module/swade-mb-helpers.js | 50 +- src/templates/powerDialog.html | 27 + 14 files changed, 2295 insertions(+), 2354 deletions(-) delete mode 100644 src/module/basePowers.js delete mode 100644 src/module/module.js delete mode 100644 src/module/powers.js create mode 100644 src/module/powers/arcaneProtection.js create mode 100644 src/module/powers/banish.js create mode 100644 src/module/powers/basePowers.js create mode 100644 src/module/powers/powers.js create mode 100644 src/templates/powerDialog.html diff --git a/.editorconfig b/.editorconfig index 7130698..a05749c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,3 @@ -# SPDX-FileCopyrightText: 2022 Johannes Loher -# -# SPDX-License-Identifier: MIT - root = true [*] diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 2fa73ab..80a0326 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -1,8 +1,3 @@ -// SPDX-FileCopyrightText: 2022 Johannes Loher -// SPDX-FileCopyrightText: 2022 David Archibald -// -// SPDX-License-Identifier: MIT - module.exports = { parserOptions: { ecmaVersion: 2020, @@ -14,16 +9,13 @@ module.exports = { browser: true, }, - extends: [ - 'eslint:recommended', - '@typhonjs-fvtt/eslint-config-foundry.js/0.8.0', - 'plugin:prettier/recommended', - ], + extends: ['eslint:recommended', '@typhonjs-fvtt/eslint-config-foundry.js/0.8.0', 'plugin:prettier/recommended'], plugins: [], rules: { - // Specify any specific ESLint rules. + 'no-undef': 'error', + 'no-unused-vars': 'warning', }, overrides: [ diff --git a/src/module.json b/src/module.json index 95a73da..6faa777 100644 --- a/src/module.json +++ b/src/module.json @@ -8,10 +8,10 @@ } ], "url": "https://git.bloy.org/foundryvtt/swade-mb-helpers", - "version": "0.0.0", + "version": "2.4.3", "compatibility": { - "minimum": "10", - "verified": "10" + "minimum": "11", + "verified": "11" }, "scripts": [], "esmodules": [ diff --git a/src/module/api.js b/src/module/api.js index 7f04c0e..dde9932 100644 --- a/src/module/api.js +++ b/src/module/api.js @@ -1,23 +1,23 @@ -import { log, moduleHelpers } from './globals.js' -import { requestFearRollFromTokens, requestRollFromTokens } from './helpers.js' -import { powerEffects } from './powerEffects.js' -import { powers } from './powers.js' +import { log, moduleHelpers } from './globals.js'; +import { requestFearRollFromTokens, requestRollFromTokens } from './helpers.js'; +import { powerEffects } from './powerEffects.js'; +import { powers } from './powers/powers.js'; export class api { - static registerFunctions () { - log('SWADE MB Helpers initialized') - api.globals() + static registerFunctions() { + log('SWADE MB Helpers initialized'); + api.globals(); } - static globals () { - const moduleName = 'swade-mb-helpers' + static globals() { + const moduleName = 'swade-mb-helpers'; game.modules.get(moduleName).api = { rulesVersion: moduleHelpers.rulesVersion, fearTable: moduleHelpers.fearTableHelper, powerEffects, powers, requestRollFromTokens, - requestFearRollFromTokens - } + requestFearRollFromTokens, + }; } } diff --git a/src/module/basePowers.js b/src/module/basePowers.js deleted file mode 100644 index a9babda..0000000 --- a/src/module/basePowers.js +++ /dev/null @@ -1,542 +0,0 @@ -/* globals warpgate */ -import { moduleName } from './globals.js' - -const MAINTAIN_ICON = 'icons/magic/symbols/runes-star-blue.webp' - -export class PowerEffect { - constructor (token, targets) { - this.source = token - this.targets = targets - this.data = {} - } - - static async getStatus (label, name, favorite = true) { - const effect = deepClone( - CONFIG.statusEffects.find((se) => se.label === label) - ) - effect.name = 'name' in effect ? effect.name : effect.label - if (!('flags' in effect)) { - effect.flags = {} - } - if (favorite) { - if (!('swade' in effect.flags)) { - effect.flags.swade = {} - } - effect.flags.swade.favorite = true - } - effect.flags.core = { statusId: effect.id } - return effect - } - - createEffectDocument (icon, name, changes = null) { - if (changes === null) { - changes = [] - } - return { - icon, - name, - changes, - description: `

From ${this.source.name} casting ${this.name}

`, - duration: { rounds: 99 }, - flags: { - [moduleName]: { - powerEffect: true - }, - swade: { - loseTurnOnHold: false, - expiration: - CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.StartOfTurnAuto - } - } - } - } - - async applyActiveEffects (token, effectDocuments) { - const mutation = { - embedded: { ActiveEffect: {} } - } - const mutateOptions = { - permanent: true, - description: `${this.source.name} applying ${effectDocuments[effectDocuments.length - 1]?.name} to ${token.name}` - } - for (const effectDocument of effectDocuments) { - mutation.embedded.ActiveEffect[effectDocument.name] = effectDocument - } - await warpgate.mutate(token.document, mutation, {}, mutateOptions) - } - - get name () { - return 'Unknown Power' - } - - get effectName () { - return this.name - } - - get icon () { - return 'icons/magic/symbols/question-stone-yellow.webp' - } - - get duration () { - return 5 - } - - get basePowerPoints () { - return 0 - } - - get usePrimaryEffect () { - return true - } - - get hasAdditionalRecipients () { - return false - } - - get isDamaging () { - return false - } - - get additionalRecipientCost () { - return 0 - } - - get isTargeted () { - return false - } - - get oneTarget () { - return false - } - - get isRaisable () { - return true - } - - get hasAoe () { - return false - } - - get modifiers () { - const mods = [] - mods.push({ - name: 'Adaptable Caster', - id: 'adaptable', - value: 1, - epic: false, - effect: false - }) - mods.push({ - name: 'Fatigue', - id: 'fatigue', - value: 2, - epic: false, - effect: false - }) - mods.push({ - name: 'Glow', - id: 'glow', - value: 1, - epic: false, - effect: true, - icon: 'icons/magic/light/orb-shadow-blue.webp', - changes: [ - { - key: '@Skill{Stealth}[system.die.modifier]', - value: -2, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - }) - mods.push({ - name: 'Shroud', - id: 'shroud', - value: 1, - epic: false, - effect: true, - icon: 'icons/magic/perception/shadow-stealth-eyes-purple.webp', - changes: [ - { - key: '@Skill{Stealth}[system.die.modifier]', - value: 1, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - }) - if (this.isDamaging) { - mods.push({ - name: 'Heavy Weapon', - id: 'heavyweapon', - value: 2, - epic: false, - effect: false - }) - } - mods.push({ - name: 'Hinder', - id: 'hinder', - value: 1, - epic: false, - effect: true, - icon: 'icons/magic/control/debuff-chains-shackle-movement-red.webp', - changes: [ - { - key: 'system.stats.speed.value', - value: -2, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - }) - mods.push({ - name: 'Hurry', - id: 'hurry', - value: 1, - epic: false, - effect: true, - icon: 'icons/skills/movement/feet-winged-sandals-tan.webp', - changes: [ - { - key: 'system.stats.speed.value', - value: 2, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - }) - if (this.isDamaging) { - mods.push({ - name: 'Lingering Damage', - id: 'lingeringdamage', - value: 2, - epic: false, - effect: false - }) - } - if (this.hasAoe) { - mods.push({ - name: 'Selective', - id: 'selective', - value: 1, - epic: false, - effect: false - }) - } - return mods - } - - get menuData () { - return { - inputs: this.menuInputs, - buttons: this.menuButtons - } - } - - get menuInputs () { - const inputs = [ - { type: 'header', label: `${this.name} Effect` }, - { - type: 'info', - label: `Apply ${this.name} Effect (base cost ${this.basePowerPoints} pp)` - } - ] - if (this.isTargeted) { - let label = `Targets: ${this.targets.map((t) => t.name).join(',')}` - if (this.targets.length > 1 && this.hasAdditionalRecipients) { - label += - ` (${this.targets.length - 1} additional recipients ` + - `+${this.additionalRecipientCost} ea.)` - } - inputs.push({ type: 'info', label }) - } - for (const mod of this.modifiers) { - inputs.push({ - type: 'checkbox', - label: - `${mod.epic ? '⭐ ' : ''}${mod.name} ` + - `(${mod.value >= 0 ? '+' : ''}${mod.value})` - }) - } - if (this.isDamaging) { - inputs.push({ - type: 'select', - label: 'Armor Piercing', - options: [ - { html: 'None', value: 0, selected: true }, - { html: 'AP 2 (+1)', value: 1, selected: false }, - { html: 'AP 4 (+2)', value: 2, selected: false }, - { html: 'AP 6 (+3)', value: 3, selected: false } - ] - }) - } - inputs.push({ - type: 'select', - label: 'Range', - options: [ - { html: 'Normal Range', value: 0, selected: true }, - { html: 'Range ×2 (+1)', value: 1, selected: false }, - { html: 'Range ×3 (+2)', value: 2, selected: false } - ] - }) - return inputs - } - - get menuButtons () { - const data = [{ label: 'Apply', value: 'apply' }] - if (this.isRaisable) { - data.push({ label: 'Apply with Raise', value: 'raise' }) - } - data.push({ label: 'Cancel', value: 'cancel' }) - - return data - } - - get menuOptions () { - return { - title: `${this.name} Effect`, - defaultButton: 'Cancel', - options: {} - } - } - - async powerEffect () { - const { buttons, inputs } = await warpgate.menu( - this.menuData, - this.menuOptions - ) - if (buttons && buttons !== 'cancel') { - this.data.button = buttons - this.data.values = inputs - await this.parseValues() - await this.apply() - await this.sideEffects() - await this.chatMessage() - } - } - - async parseValues () { - this.data.rawValues = deepClone(this.data.values) - this.data.raise = this.data.button === 'raise' - for (let i = 0; i < 2; i++) { - this.data.values.shift() - } - if (this.isTargeted) { - this.data.values.shift() - } - this.data.mods = new Set() - for (const mod of this.modifiers) { - const modEnabled = this.data.values.shift() - if (modEnabled) { - this.data.mods.add(mod.id) - } - } - if (this.isDamaging) { - this.data.armorPiercing = this.data.values.shift() - } - this.data.range = this.data.values.shift() - } - - async createSecondaryEffects (maintId) { - const docs = [] - for (const mod of this.modifiers) { - if (this.data.mods.has(mod.id) && mod.effect) { - const doc = this.createEffectDocument(mod.icon, mod.name, mod.changes) - if (this.duration === 0 && !this.usePrimaryEffect) { - // set secondary effects of instant spells to expire on victim's next - // turn - doc.duration.rounds = 1 - doc.flags.swade.expiration = - CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.EndOfTurnAuto - } else { - doc.duration.seconds = 594 - doc.flags[moduleName].maintId = maintId - } - docs.push(doc) - } - } - return docs - } - - getPrimaryEffectChanges () { - const changes = [ - { - key: 'flags.swade-mb-helpers.powerAffected', - value: 1, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.OVERRIDE - } - ] - return changes - } - - get description () { - return '' - } - - async createPrimaryEffect (maintId) { - const doc = this.createEffectDocument( - this.icon, - this.effectName, - this.getPrimaryEffectChanges() - ) - doc.description += this.description - doc.flags[moduleName].maintId = maintId - doc.duration.seconds = 594 - return doc - } - - async createMaintainEffect (maintId) { - let icon = MAINTAIN_ICON - if (!this.usePrimaryEffect) { - icon = this.icon - } - const doc = this.createEffectDocument( - icon, - `Maintaining ${this.effectName}`, - [] - ) - doc.duration.rounds = this.duration - doc.description += this.description - doc.flags.swade.expiration = - CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.EndOfTurnPrompt - doc.flags.swade.loseTurnOnHold = true - doc.flags[moduleName].maintainingId = maintId - doc.flags[moduleName].targetIds = this.targets.map((t) => t.id) - return doc - } - - // eslint-disable-next-line no-unused-vars - async secondaryDocsForTarget (docs, target) { - return deepClone(docs) - } - - // eslint-disable-next-line no-unused-vars - async primaryDocForTarget (doc, target) { - const newDoc = deepClone(doc) - newDoc.flags[moduleName].maintainingId = doc.flags[moduleName].maintId - newDoc.flags[moduleName].targetIds = [target.id] - return newDoc - } - - async apply () { - const maintId = randomID() - const secondaryDocs = await this.createSecondaryEffects(maintId) - const primaryDoc = await this.createPrimaryEffect(maintId) - const maintainDoc = await this.createMaintainEffect(maintId) - if (this.isTargeted) { - for (const target of this.targets) { - const targetDocs = await this.secondaryDocsForTarget( - secondaryDocs, - target - ) - if (this.duration > 0 || this.usePrimaryEffect) { - targetDocs.push(await this.primaryDocForTarget(primaryDoc, target)) - } - if (targetDocs.length > 0) { - await this.applyActiveEffects(target, targetDocs) - } - } - } - if (this.duration > 0) { - await this.applyActiveEffects(this.source, [maintainDoc]) - } - } - - async sideEffects () { - if (this.data.mods.has('fatigue') && this.isTargeted) { - for (const target of this.targets) { - const actor = target.actor - const update = { - system: { - fatigue: { - value: actor.system.fatigue.value + 1 - } - } - } - if (actor.system.fatigue.value < actor.system.fatigue.max) { - await actor.update(update) - } - } - } - } - - get powerPoints () { - let total = this.basePowerPoints - for (const mod of this.modifiers) { - if (this.data.mods.has(mod.id)) { - total += mod.value - } - } - if (this.targets.length > 1 && this.hasAdditionalRecipients) { - total += (this.targets.length - 1) * this.additionalRecipientCost - } - total += this.data.range - if (this.isDamaging) { - total += this.data.armorPiercing - } - return total - } - - get chatMessageEffects () { - const list = [] - if (this.hasAdditionalRecipients && this.targets.length > 1) { - list.push(`${this.targets.length - 1} Additional Recipients`) - } - if (this.data.mods.has('adaptable')) { - list.push('Different Trapping (Adaptable Caster)') - } - if (this.data.mods.has('fatigue')) { - list.push('Fatigue (applied to targets') - } - if (this.data.mods.has('heavyweapon')) { - list.push('Heavy Weapon') - } - if (this.data.mods.has('lingeringdamage')) { - list.push('Lingering Damage') - } - if (this.data.mods.has('selective')) { - list.push('Selective') - } - if (this.isDamaging && this.data.armorPiercing > 0) { - list.push(`AP ${this.data.armorPiercing * 2}`) - } - if (this.data.range > 0) { - list.push(`Range ×${this.data.range + 1}`) - } - return list - } - - get chatMessageText () { - let text = `

Cast ${this.name}` - if (this.isTargeted && this.targets.length > 0) { - text += ` on ${this.targets.map((t) => t.name).join(', ')}` - } - text += '

' - const desc = this.description - if (desc) { - text += `
Description${desc}
` - } - const effects = this.chatMessageEffects - if (effects.length > 0) { - text += - '
Other Effects:
' - } - return text - } - - async chatMessage () { - return ChatMessage.create( - { - flavor: `Calculated cost: ${this.powerPoints} pp`, - speaker: ChatMessage.getSpeaker(this.source.actor), - content: this.chatMessageText, - whisper: ChatMessage.getWhisperRecipients('GM', game.user.name) - }, - { chatBubble: false } - ) - } -} diff --git a/src/module/module.js b/src/module/module.js deleted file mode 100644 index efca7b0..0000000 --- a/src/module/module.js +++ /dev/null @@ -1,112 +0,0 @@ -/* globals ColorAdjustmentsSamplerShader */ -/* globals socketlib */ -/* globals VisionMode */ -/* globals warpgate */ -import { api } from './api.js' -import { requestTokenRoll } from './helpers.js' -import { preDamageRollModifiers, preTraitRollModifiers } from './rollHelpers.js' -import { shapeChangeOnDismiss } from './powerEffects.js' -import { log, moduleHelpers } from './globals.js' -import { powerEffectManagementHook } from './powers.js' - -function _checkModule (name) { - if (!game.modules.get(name)?.active && game.user.isGM) { - let action = 'install and activate' - if (game.modules.get(name)) action = 'activate' - ui.notifications.error( - `SWADE MB Helpers requires the ${name} module. Please ${action} it.`) - } -} - -Hooks.on('setup', api.registerFunctions) - -Hooks.on('init', () => { - log('INIT VISION') - CONFIG.Canvas.visionModes.basic = new VisionMode({ - id: 'basic', - label: 'VISION.ModeBasicVision', - canvas: { - shader: ColorAdjustmentsSamplerShader, - uniforms: { contrast: 0, saturation: -0.85, brightness: -1.0 } - }, - lighting: { - background: { visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED } - }, - vision: { - darkness: { adaptive: false }, - defaults: { attenuation: 0, contrast: 0, saturation: -0.85, brightness: -1.0 }, - preferred: true - } - }) - CONFIG.Canvas.visionModes.darkvision = new VisionMode({ - id: 'darkvision', - label: 'VISION.ModeDarkvision', - canvas: { - shader: ColorAdjustmentsSamplerShader, - uniforms: { contrast: 0, saturation: 0, brightness: 0.75, tint: [0.8, 0.8, 1.0] } - }, - lighting: { - background: { visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED }, - levels: { - [VisionMode.LIGHTING_LEVELS.DIM]: VisionMode.LIGHTING_LEVELS.BRIGHT - } - }, - vision: { - darkness: { adaptive: false }, - defaults: { attenuation: 0.1, contrast: 0, saturation: 0, brightness: 0.75 }, - preferred: false - } - }) - CONFIG.Canvas.visionModes.lowlight = new VisionMode({ - id: 'lowlight', - label: 'Low Light Vision', - canvas: { - shader: ColorAdjustmentsSamplerShader, - uniforms: { contrast: 0, saturation: -0.5, brightness: -0.2 } - }, - lighting: { - background: { visibility: VisionMode.LIGHTING_VISIBILITY.REQUIRED } - }, - vision: { - darkness: { adaptive: false }, - defaults: { attenuation: 0.1, contrast: 0, saturation: -0.5, brightness: -0.2 }, - preferred: false - } - }) - CONFIG.Canvas.visionModes.blindsense = new VisionMode({ - id: 'blindsense', - label: 'Blindsense', - canvas: { - shader: ColorAdjustmentsSamplerShader, - uniforms: { contrast: 0, saturation: -0.5, brightness: -0.2 } - }, - lighting: { - background: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED }, - coloration: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED }, - illumination: { visibility: VisionMode.LIGHTING_VISIBILITY.DISABLED } - }, - vision: { - darkness: { adaptive: false }, - defaults: { attenuation: 0.3, contrast: -0.5, saturation: 0.75, brightness: 0.5 }, - preferred: false - } - }) -}) - -Hooks.on('swadePreRollAttribute', preTraitRollModifiers) -Hooks.on('swadePreRollSkill', preTraitRollModifiers) -Hooks.on('swadeRollDamage', preDamageRollModifiers) -Hooks.on('deleteActiveEffect', powerEffectManagementHook) - -Hooks.on('ready', () => { - _checkModule('warpgate') - _checkModule('socketlib') - log('Initialized SWADE MB Helpers') - warpgate.event.watch(warpgate.EVENT.DISMISS, shapeChangeOnDismiss) -}) - -Hooks.on('socketlib.ready', () => { - const _socket = socketlib.registerModule('swade-mb-helpers') - _socket.register('requestTokenRoll', requestTokenRoll) - moduleHelpers._socket = _socket -}) diff --git a/src/module/powers.js b/src/module/powers.js deleted file mode 100644 index 47ae3d0..0000000 --- a/src/module/powers.js +++ /dev/null @@ -1,1637 +0,0 @@ -/* globals warpgate */ -import { moduleName } from './globals.js' -import { PowerEffect } from './basePowers.js' - -class ArcaneProtectionEffect extends PowerEffect { - get name () { - return 'Arcane Protection' - } - - get duration () { - return 5 - } - - get icon () { - return 'icons/magic/defensive/shield-barrier-flaming-pentagon-blue.webp' - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return 1 - } - - get isTargeted () { - return true - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Greater Arcane Protection', - id: 'greater', - value: 2, - epic: true, - effect: false - }) - return mods - } - - get _penaltyAmount () { - return ( - (this.data.raise ? -4 : -2) + (this.data.mods.has('greater') ? -2 : 0) - ) - } - - get description () { - let text = super.description - text += `

Hostile powers are at ${this._penaltyAmount} when - targeting or damaging the affected character.

` - return text - } - - get effectName () { - const greater = this.data.mods.has('greater') - const raise = this.data.raise - const amount = this._penaltyAmount - return `${greater ? 'Greater ' : ''}Arcane Protection (${raise ? 'major, ' : ''}${amount})` - } -} - -class BanishEffect extends PowerEffect { - get name () { - return 'Banish' - } - - get duration () { - return 0 - } - - get basePowerPoints () { - return 3 - } - - get usePrimaryEffect () { - return false - } - - get isTargeted () { - return true - } - - get isRaisable () { - return false - } - - get hasAoe () { - return true - } - - get menuInputs () { - const inputs = super.menuInputs - inputs.push({ - type: 'select', - label: '⭐ Area of Effect', - options: [ - { html: 'None', value: 0, selected: true }, - { html: 'Small Blast Template (+1)', value: 1, selected: false }, - { html: 'Medium Blast Template (+2)', value: 2, selected: false }, - { html: 'Large Blast Template (+3)', value: 3, selected: false } - ] - }) - return inputs - } - - get powerPoints () { - let total = super.powerPoints - total += this.data.aoe - return total - } - - get description () { - return ( - super.description + - ` -

An opposed roll of the caster's skill vs the target's Spirit. - Success: Shaken, each Raise: 1 Wound - Incapacitation results in banishment to home plane.

- ` - ) - } - - get chatMessageEffects () { - const list = super.chatMessageEffects - switch (this.data.aoe) { - case 0: - break - case 1: - list.push('SBT') - break - case 2: - list.push('MBT') - break - case 3: - list.push('LBT') - break - } - return list - } - - async parseValues () { - await super.parseValues() - this.data.aoe = this.data.values.shift() - } -} - -class BarrierEffect extends PowerEffect { - get name () { - return 'Barrier' - } - - get duration () { - return 5 - } - - get icon () { - return 'icons/environment/settlement/fence-stone-brick.webp' - } - - get isTargeted () { - return false - } - - get isDamaging () { - return true - } - - get basePowerPoints () { - return 2 - } - - get usePrimaryEffect () { - return false - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Damage', - id: 'damage', - value: 1, - epic: false, - effect: false - }) - mods.push({ - name: 'Damage (immaterial trapping)', - id: 'damage', - value: 0, - epic: false, - effect: false - }) - mods.push({ - name: 'Deadly', - id: 'deadly', - value: 2, - epic: true, - effect: false - }) - mods.push({ - name: 'Hardened', - id: 'hardened', - value: 1, - epic: false, - effect: false - }) - mods.push({ - name: 'Shaped', - id: 'shaped', - value: 1, - epic: false, - effect: false - }) - mods.push({ - name: 'Size', - id: 'size', - value: 1, - epic: false, - effect: false - }) - return mods - } - - get _length () { - let height = 10 - if (this.data.raise) { - height *= 2 - } - if (this.data.mods.has('size')) { - height *= 2 - } - return `${height}" (${height * 2} yards)` - } - - get _height () { - return `${this.data.mods.has('size') ? '2" (4' : '1" (2'} yards` - } - - get _hardness () { - return ( - (this.data.raise ? 12 : 10) + (this.data.mods.has('hardened') ? 2 : 0) - ) - } - - get description () { - let text = super.description - text += `

A barrier ${this._height} tall and ${this._length} long, of hardness ${this._hardness}. ` - if (this.data.mods.has('deadly')) { - text += 'It does 2d6 damage to anyone who contacts it. ' - } else if (this.data.mods.has('damage')) { - text += 'It does 2d4 damage to anyone who contacts it. ' - } - if (this.data.mods.has('shaped')) { - text += 'It was shaped into a circle, square, or rectangle. ' - } - text += '

' - return text - } -} - -class BeastFriendEffect extends PowerEffect { - get name () { - return 'Beast Friend' - } - - get duration () { - return (this.data.mods.has('duration') ? 30 : 10) * 6 * 60 - } - - get icon () { - return 'icons/magic/nature/wolf-paw-glow-large-green.webp' - } - - get isTargeted () { - return true - } - - get usePrimaryEffect () { - return true - } - - get modifiers () { - const mods = super.modifiers - mods.push( - { - name: 'Bestiarium', - value: 2, - id: 'bestiarium', - epic: true, - effect: false - }, - { - name: 'Duration', - value: 1, - id: 'duration', - epic: false, - effect: false - }, - { - name: 'Mind Rider', - value: 1, - id: 'mindrider', - epic: false, - effect: false - } - ) - return mods - } - - get menuInputs () { - const inputs = super.menuInputs - const pp = Math.max( - this.targets - .map((t) => Math.max(t.actor.system.stats.size, 1)) - .reduce((a, b) => a + b, 0), - 1 - ) - inputs.push({ type: 'number', label: 'Base power points', options: pp }) - return inputs - } - - async parseValues () { - await super.parseValues() - this.data.basePP = this.data.values.shift() - } - - get basePowerPoints () { - return this?.data?.basePP || 2 - } - - get description () { - let text = super.description - if (this.data.raise) { - text += '

Creatures will overcome instincts to follow orders.' - } else { - text += '

Creatures obey simple commands, subject to their insticts.' - } - if (this.data.mods.has('bestiarium')) { - text += ' The caster may even effect magical beasts.' - } - return text - } -} - -class BlastEffect extends PowerEffect { - get name () { - return 'Blast' - } - - get icon () { - return 'icons/magic/fire/explosion-fireball-large-red-orange.webp' - } - - get duration () { - return 0 - } - - get isTargeted () { - return true - } - - get usePrimaryEffect () { - return false - } - - get isDamaging () { - return true - } - - get basePowerPoints () { - return 3 - } - - get hasAoe () { - return true - } - - get menuInputs () { - const inputs = super.menuInputs - inputs.push({ - type: 'select', - label: 'Area of Effect', - options: [ - { html: 'Small Blast Template (0)', value: 's', selected: false }, - { html: 'Medium Blast Template (0)', value: 'm', selected: true }, - { html: 'Large Blast Template (+1)', value: 'l', selected: false } - ] - }) - return inputs - } - - get modifiers () { - const mods = super.modifiers - mods.push( - { name: 'Damage', value: 2, id: 'damage', epic: false, effect: false }, - { - name: 'Greater Blast', - value: 4, - id: 'greater', - epic: true, - effect: false - } - ) - return mods - } - - get powerPoints () { - let total = super.powerPoints - total += this.data.aoe === 'l' ? 1 : 0 - return total - } - - async parseValues () { - await super.parseValues() - this.data.aoe = this.data.values.shift() - } - - get description () { - const dmgDie = - (this.data.mods.has('greater') - ? 4 - : this.data.mods.has('damage') - ? 3 - : 2) + (this.data.raise ? 1 : 0) - const size = - this.data.aoe === 'l' ? 'LBT' : this.data.aoe === 's' ? 'SBT' : 'MBT' - return ( - super.description + - ` -

The blast covers a ${size} and does ${dmgDie}d6 damage

` - ) - } -} - -class BlindEffect extends PowerEffect { - get name () { - return 'Blind' - } - - get icon () { - return 'icons/skills/wounds/injury-eyes-blood-red.webp' - } - - get duration () { - return 0 - } - - get isTargeted () { - return true - } - - get hasAoe () { - return true - } - - get basePowerPoints () { - return 2 - } - - getPrimaryEffectChanges () { - const changes = [ - { - key: 'system.stats.globalMods.trait', - value: -2, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - return changes - } - - get description () { - return ( - super.description + - `

${this.data.raise ? -4 : -2} penalty to all actions involving sight.

-

Shake off attempts at end of turns with a Vigor - ${this.data.mods.has('strong') ? '-2 ' : ''}roll as a free action. - Success removes 2 points of penalties. A raise removes the effect.

` - ) - } - - async createSecondaryEffects (maintId) { - const docs = await super.createSecondaryEffects(maintId) - if (this.data.raise) { - const strong = this.data.mods.has('strong') - const doc = this.createEffectDocument( - this.icon, - `Blinded (${strong ? 'Strong, ' : ''}Raise)`, - [ - { - key: 'system.stats.globalMods.trait', - value: -2, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - ) - doc.duration.seconds = 594 - doc.description = - this.description + - '

This is the raise effect which can be shaken off separately.

' - doc.flags[moduleName].maintId = maintId - docs.push(doc) - } - return docs - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Strong', - value: 1, - id: 'strong', - epic: false, - effect: false - }) - return mods - } - - get menuInputs () { - const inputs = super.menuInputs - inputs.push({ - type: 'select', - label: 'Area of Effect', - options: [ - { html: 'None', value: 0, selected: true }, - { html: 'Medium Blast Template (+2)', value: 2, selected: false }, - { html: 'Large Blast Template (+3)', value: 3, selected: false } - ] - }) - return inputs - } - - async parseValues () { - await super.parseValues() - this.data.aoe = this.data.values.shift() - } - - get powerPoints () { - let total = super.powerPoints - total += this.data.aoe - return total - } - - get effectName () { - const strong = this.data.mods.has('strong') - return `Blinded${strong ? ' (Strong)' : ''}` - } - - get chatMessageEffects () { - const list = super.chatMessageEffects - switch (this.data.aoe) { - case 2: - list.push('MBT') - break - case 3: - list.push('LBT') - break - } - return list - } -} - -class BoltEffect extends PowerEffect { - get name () { - return 'Bolt' - } - - get icon () { - return 'icons/magic/fire/explosion-fireball-large-red-orange.webp' - } - - get duration () { - return 0 - } - - get isTargeted () { - return true - } - - get isDamaging () { - return true - } - - get basePowerPoints () { - return 3 - } - - get usePrimaryEffect () { - return false - } - - get modifiers () { - const mods = super.modifiers - mods.push( - { name: 'Damage', value: 2, id: 'damage', epic: false, effect: false }, - { - name: 'Disintegrate', - value: 1, - id: 'disintigrate', - epic: true, - effect: false - }, - { - name: 'Greater Bolt', - value: 4, - id: 'greater', - epic: true, - effect: false - }, - { name: 'Rate of Fire', value: 2, id: 'rof', epic: true, effect: false } - ) - return mods - } - - get powerPoints () { - let total = super.powerPoints - total += this.data.aoe === 'l' ? 1 : 0 - return total - } - - get description () { - const dmgDie = - (this.data.mods.has('greater') - ? 4 - : this.data.mods.has('damage') - ? 3 - : 2) + (this.data.raise ? 1 : 0) - let desc = super.description + '

' - if (this.data.mods.has('rof')) { - desc += `Up to two bolts (RoF 2) do ${dmgDie}d6 damage each.` - } else { - desc += `The bolt does ${dmgDie}d6 damage.` - } - if (this.data.mods.has('disintegrate')) { - desc += - 'The bolt is disintegrating. If being used to break' + - ' something, the damage dice can Ace. A creature Incapacitated by a ' + - 'disintegrating bolt must make a Vigor roll or its body turns to dust' - } - desc += '

' - return desc - } -} - -class BurrowEffect extends PowerEffect { - get name () { - return 'Burrow' - } - - get duration () { - return 5 - } - - get icon () { - return 'icons/magic/earth/projectile-stone-landslide.webp' - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return 1 - } - - get basePowerPoints () { - return 1 - } - - get isTargeted () { - return true - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Power', - id: 'power', - value: 1, - epic: false, - effect: false - }) - return mods - } - - get effectName () { - return ( - `${this.name} ${this.data.mods.has('power') ? '[Power] ' : ''}` + - `(${this.data.raise ? 'full' : 'half'} pace)` - ) - } - - get description () { - let text = - super.description + - `

Meld into the ground. Move at ${this.data.raise ? 'full' : 'half'} pace. May not run.

` - if (this.data.mods.has('power')) { - text += '

Can burrow through solid stone, concrete, etc

' - } - return text - } -} - -class BoostLowerTraitEffect extends PowerEffect { - get name () { - return 'Boost/Lower Trait' - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return 2 - } - - get icon () { - return this?.data?.direction === 'Boost' - ? 'icons/magic/life/cross-embers-glow-yellow-purple.webp' - : 'icons/magic/movement/chevrons-down-yellow.webp' - } - - get duration () { - return this?.data?.direction === 'Boost' ? 5 : 0 - } - - get isTargeted () { - return true - } - - get basePowerPoints () { - return 3 - } - - getPrimaryEffectChanges () { - let modValue = '2' - if (this.data.raise && this.data.direction === 'Boost') { - modValue = '4' - } - modValue = (this.data.direction === 'Boost' ? '+' : '-') + modValue - const changes = [ - { - key: this.data.trait.diekey, - value: modValue, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - if (this.data.direction === 'Lower' && this.data.mods.has('greater')) { - changes.push({ - key: this.data.trait.modkey, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, - value: -2, - priority: 0 - }) - } - return changes - } - - async createSecondaryEffects (maintId) { - const docs = await super.createSecondaryEffects(maintId) - if (this.data.raise && this.data.direction === 'Lower') { - const name = 'major ' + this.effectName - const modValue = this.data.direction === 'Boost' ? '+2' : '-2' - const changes = [ - { - key: this.data.trait.diekey, - value: modValue, - priority: 0, - mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD - } - ] - const doc = this.createEffectDocument(this.icon, name, changes) - doc.duration.seconds = 594 - doc.description = - this.description + - '

This is the raise effect which can be shaken off separately.

' - doc.flags[moduleName].maintId = maintId - docs.push(doc) - } - return docs - } - - get effectName () { - let name = `${this.data.direction} ${this.data.trait.name}` - const nameMods = [] - if (this.data.mods.has('greater')) { - nameMods.push('Greater') - } - if (this.data.direction === 'Lower' && this.data.mods.has('strong')) { - nameMods.push('Strong') - } - if (nameMods.length > 0) { - name += ` (${nameMods.join(', ')})` - } - return name - } - - get description () { - let desc = super.description - const amount = `${this.data.raise ? 2 : 1} die type${this.data.raise ? 's' : ''}` - desc += `

${this.data.direction === 'Boost' ? 'Raise' : 'Lower'} the - target's ${this.data.trait.name} die type ${amount}.` - if (this.data.mods.has('greater')) { - if (this.data.direction === 'Boost') { - desc += ` Additionally, the target gains a free ${this.data.trait.name} - reroll once per ${this.data.raise ? 'action' : 'round'}.` - } else { - desc += ` Additionally, the target suffers a -2 penalty to their - ${this.data.trait.name} rolls.` - } - } - desc += '

' - if (this.data.direction === 'Lower') { - desc += `

At the end of the target's following turns, they attempt to shake off - the affect with a Spirit${this.data.mods.has('strong') ? ' -2' : ''} - roll as a free action. Success reduces the effect one die type. A raise - completely shakes off the effect.

` - } - return desc - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Greater Boost/Lower Trailt', - value: 2, - id: 'greater', - epic: true, - effect: false - }) - mods.push({ - name: 'Strong (lower only)', - value: 1, - id: 'strong', - epic: false, - effect: false - }) - return mods - } - - get menuInputs () { - const inputs = super.menuInputs - let traitOptions = ['Agility', 'Smarts', 'Spirit', 'Strength', 'Vigor'] - const allSkills = new Set() - const traits = {} - for (const traitName of traitOptions) { - const lower = traitName.toLowerCase() - traits[traitName] = { - name: traitName, - type: 'attribute', - modkey: `system.attributes.${lower}.die.modifier`, - diekey: `system.attributes.${lower}.die.sides` - } - } - for (const token of this.targets) { - const skills = token.actor.items.filter((item) => item.type === 'skill') - for (const skill of skills) { - const name = skill.name - traits[name] = { - name, - type: 'skill', - modkey: `@Skill{${name}}[system.die.modifier]`, - diekey: `@Skill{${name}}[system.die.sides]` - } - if (name !== 'Unskilled') { - allSkills.add(name) - } - } - } - traitOptions = traitOptions.concat(Array.from(allSkills).sort()) - this.data.traits = traits - inputs.push({ type: 'select', label: 'Trait', options: traitOptions }) - inputs.push({ type: 'info', label: 'Boost or Lower?' }) - inputs.push({ type: 'radio', label: 'Boost', options: ['isBoost', true] }) - inputs.push({ type: 'radio', label: 'Lower', options: ['isBoost', false] }) - return inputs - } - - async parseValues () { - await super.parseValues() - this.data.trait = this.data.traits[this.data.values.shift()] - this.data.values.shift() - this.data.direction = this.data.values.shift() ? 'Boost' : 'Lower' - } - - get powerPoints () { - const total = super.powerPoints - return total - } -} - -class BurstEffect extends PowerEffect { - get name () { - return 'Blast' - } - - get icon () { - return 'icons/magic/sonic/projectile-shock-wave-blue.webp' - } - - get duration () { - return 0 - } - - get isTargeted () { - return true - } - - get usePrimaryEffect () { - return false - } - - get isDamaging () { - return true - } - - get basePowerPoints () { - return 3 - } - - get hasAoe () { - return true - } - - get modifiers () { - const mods = super.modifiers - mods.push( - { name: 'Damage', value: 2, id: 'damage', epic: false, effect: false }, - { - name: 'Greater Burst', - value: 4, - id: 'greater', - epic: true, - effect: false - } - ) - return mods - } - - get description () { - const dmgDie = - (this.data.mods.has('greater') - ? 4 - : this.data.mods.has('damage') - ? 3 - : 2) + (this.data.raise ? 1 : 0) - return ( - super.description + - ` -

The blast covers a Cone or Stream template and does ${dmgDie}d6 damage

` - ) - } -} - -class ConfusionEffect extends PowerEffect { - get name () { - return 'Confusion' - } - - get icon () { - return 'icons/magic/control/hypnosis-mesmerism-swirl.webp' - } - - get duration () { - return 0 - } - - get isTargeted () { - return true - } - - get usePrimaryEffect () { - return false - } - - get basePowerPoints () { - return 2 - } - - get hasAoe () { - return true - } - - get menuInputs () { - const inputs = super.menuInputs - inputs.push({ - type: 'select', - label: 'Area of Effect', - options: [ - { html: 'Small Blast Template (0)', value: 's', selected: false }, - { html: 'Medium Blast Template (0)', value: 'm', selected: true }, - { html: 'Large Blast Template (+1)', value: 'l', selected: false } - ] - }) - return inputs - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Greater Confusion', - value: 2, - id: 'greater', - epic: true, - effect: false - }) - return mods - } - - get powerPoints () { - let total = super.powerPoints - total += this.data.aoe === 'l' ? 1 : 0 - return total - } - - get menuButtons () { - const data = [ - { label: 'Apply with Distracted', value: 'distracted' }, - { label: 'Apply with Vulnerable', value: 'vulnerable' }, - { label: 'Apply with both (raise)', value: 'raise' }, - { label: 'Cancel', value: 'cancel' } - ] - return data - } - - async parseValues () { - await super.parseValues() - this.data.distracted = - this.data.button === 'distracted' || this.data.button === 'raise' - this.data.vulnerable = - this.data.button === 'vulnerable' || this.data.button === 'raise' - this.data.aoe = this.data.values.shift() - } - - get description () { - const size = - this.data.aoe === 'l' ? 'LBT' : this.data.aoe === 's' ? 'SBT' : 'MBT' - let effect = 'Vulnerable' - if (this.data.raise) { - effect = 'both Distracted and Vulnerable' - } else if (this.data.distracted) { - effect = 'Distracted' - } - if (this.data.mods.has('Greater')) { - effect += ' as well as Shaken' - } - return ( - super.description + - ` -

The targets in the ${size} are ${effect}.

` - ) - } - - async createSecondaryEffects (maintId) { - const docs = await super.createSecondaryEffects(maintId) - if (this.data.distracted) { - PowerEffect.getStatus('SWADE.Distr', 'Distracted', false).then((v) => - docs.push(v) - ) - } - if (this.data.distracted) { - PowerEffect.getStatus('SWADE.Vuln', 'Vulnerable', false).then((v) => - docs.push(v) - ) - } - if (this.data.mods.has('greater')) { - PowerEffect.getStatus('SWADE.Shaken', 'Shaken', false).then((v) => - docs.push(v) - ) - } - return docs - } -} - -class CurseEffect extends PowerEffect { - get name () { - return 'Curse' - } - - get icon () { - return 'icons/magic/control/voodoo-doll-pain-damage-purple.webp' - } - - get duration () { - return 500 * 24 * 60 * 6 - } - - get isTargeted () { - return true - } - - get oneTarget () { - return true - } - - get isRaisable () { - return false - } - - get basePowerPoints () { - return 5 - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Turn to Stone', - value: 5, - id: 'turntostone', - epic: true, - effect: false - }) - return mods - } - - get description () { - let desc = super.description - desc += `

The victim must defend with a Spirit roll opposed by the - caster's arcane skill roll. Failure means the victim suffers a level - of Fatigue immediately.

` - if (this.data.mods.has('turntostone')) { - desc += `

On every following run the victim must make a Spirit roll - or take a level of Fatigue. When Incapacitated, the victim turns to - stone, with a Hardness equal to his Tougness.

` - } else { - desc += `

At sunset every day, the victim suffers a level of Fatigue. - When Incapacitated by this, he makes a Vigor roll each day to avoid - death.

` - } - desc += `

Breaking the curse: The curse can be lifted by - the original caster at will, and ends if the caster is slain. Dispel at -2 - also removes the curse, but each individual may only try once.

` - return desc - } -} - -class DamageFieldEffect extends PowerEffect { - get name () { - return 'Damage Field' - } - - get icon () { - return 'icons/magic/defensive/shield-barrier-blades-teal.webp' - } - - get duration () { - return 5 - } - - get basePowerPoints () { - return 4 - } - - get isTargeted () { - return true - } - - get oneTarget () { - return true - } - - get isRaisable () { - return false - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Area of Effect', - value: 2, - id: 'aoe', - epic: false, - effect: false - }) - mods.push({ - name: 'Damage', - value: 2, - id: 'damage', - epic: false, - effect: false - }) - mods.push({ - name: 'Greater Damage Field', - value: 4, - id: 'greater', - epic: true, - effect: false - }) - mods.push({ - name: 'Mobile', - value: 2, - id: 'mobile', - epic: false, - effect: false - }) - return mods - } - - get description () { - let desc = super.description - let area = 'all adjacent creatures' - let damage = '2d4' - if (this.data.mods.has('greater')) { - damage = '3d6 (heavy weapon)' - } else if (this.data.mods.has('damage')) { - damage = '2d6' - } - if (this.data.mods.has('aoe')) { - area = 'all creatures within a MBT' - } - desc += `

At the end of the recipient's turn, ${area} - automatically take ${damage} damage.` - if (this.data.mods.has('mobile')) { - desc += `The caster may detach the damage field from the recipient and - move it up to his Smarts die type each round, as a limited free action.` - } - desc += '

' - return desc - } - - getPrimaryEffectChanges () { - const base = 'flags.swade.auras.damagefield' - const priority = 0 - const mode = foundry.CONST.ACTIVE_EFFECT_MODES.OVERRIDE - const changes = [ - { key: `${base}.enabled`, value: true, priority, mode }, - { key: `${base}.walls`, value: true, priority, mode }, - { key: `${base}.color`, value: '#ffcc00', priority, mode }, - { key: `${base}.alpha`, value: 0.1, priority, mode }, - { - key: `${base}.radius`, - value: this.data.mods.has('aoe') ? 1.5 : 0.5, - priority, - mode - }, - { key: `${base}.visibleTo`, value: [-1, 0, 1], priority, mode } - ] - return changes - } -} - -class DarksightEffect extends PowerEffect { - get name () { - return 'Darksight' - } - - get icon () { - return 'icons/magic/perception/eye-ringed-glow-angry-small-teal.webp' - } - - get duration () { - return 600 - } - - get basePowerPoints () { - return 1 - } - - get isTargeted () { - return true - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return 1 - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Greater Darksight', - value: 2, - id: 'greater', - epic: true, - effect: false - }) - return mods - } - - get description () { - let desc = super.description - desc += '

' - if (this.data.mods.has('greater')) { - desc += `Can see in all darkness, ignoring all illumination penalties and - 4 points of penalties from invisible creatures` - } else if (this.data.raise) { - desc += - 'Can see in Pitch Darkness and ignore up to 6 points of illumination penalties' - } else { - desc += - 'Can see in darkness and ignore 4 points of illumination penalties' - } - desc += '

' - return desc - } - - get effectName () { - if (this.data.mods.has('greater')) { - return 'Greater Darksight' - } else if (this.data.raise) { - return 'Major Darksight' - } else { - return 'Darksignt' - } - } -} - -class DeflectionEffect extends PowerEffect { - get name () { - return 'Deflection' - } - - get icon () { - return 'icons/magic/defensive/shield-barrier-deflect-teal.webp' - } - - get duration () { - return 5 - } - - get basePowerPoints () { - return 2 - } - - get isTargeted () { - return true - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return 1 - } - - get menuButtons () { - const data = [ - { label: 'Melee', value: 'melee' }, - { label: 'Ranged', value: 'vulnerable' }, - { label: 'Raise (both)', value: 'raise' }, - { label: 'Cancel', value: 'cancel' } - ] - return data - } - - async parseValues () { - await super.parseValues() - this.data.affects = this.data.button === 'raise' ? 'all' : this.data.button - } - - get effectName () { - return `Deflection (${this.data.affects})` - } - - get description () { - return ( - super.description + - `

Attackers subtract -2 from ${this.data.affects} - attacks when targeting this creature.

` - ) - } -} - -class DetectConcealArcanaEffect extends PowerEffect { - get name () { - return 'Detect/Conceal Arcana' - } - - get icon () { - return this.data.detect - ? 'icons/magic/perception/third-eye-blue-red.webp' - : 'icons/magic/perception/silhouette-stealth-shadow.webp' - } - - get duration () { - return this.data.detect ? 5 : 600 - } - - get basePowerPoints () { - return 2 - } - - get isTargeted () { - return true - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return (this.data?.aoe || 0) > 0 ? 0 : 1 - } - - get modifiers () { - const mods = super.modifiers - mods.push({ - name: 'Alignment Sense (detect)', - value: 1, - id: 'alignment', - epic: false, - effect: false - }) - mods.push({ - name: 'Identify (detect)', - value: 1, - id: 'identify', - epic: false, - effect: false - }) - mods.push({ - name: 'Strong (conceal)', - value: 1, - id: 'strong', - epic: false, - effect: false - }) - return mods - } - - get hasAoe () { - return true - } - - get menuInputs () { - const inputs = super.menuInputs - inputs.push({ - type: 'select', - label: 'Area of Effect (conceal)', - options: [ - { html: 'None', value: 0, selected: true }, - { html: 'Medium Blast Template (+1)', value: 1, selected: false }, - { html: 'Large Blast Template (+2)', value: 2, selected: false } - ] - }) - inputs.push({ type: 'info', label: 'Detect or Conceal?' }) - inputs.push({ - type: 'radio', - label: 'Detect', - options: ['isDetect', true] - }) - inputs.push({ - type: 'radio', - label: 'Conceal', - options: ['isDetect', false] - }) - return inputs - } - - async parseValues () { - await super.parseValues() - this.data.aoe = this.data.values.shift() - this.data.values.shift() - this.data.detect = this.data.values.shift() - } - - get powerPoints () { - return super.powerPoints + this.data.aoe - } - - get effectName () { - return `${this.data.detect ? 'Detect' : 'Conceal'} Arcana` - } - - get description () { - let desc = super.description - if (this.data.detect) { - desc += `

The recipient can see and detect all supernatural persons, - objects, or effects in sight. This includes invisible foes, enchanted - objects, and so on.` - if (this.data.raise) { - desc += `Since this was major Detect Arcana, the type of enchantments - is also known.` - } - desc += `

If cast to learn more about a creature, the caster learns - active powers and arcane abilities.` - if (this.data.raise) { - desc += `As major Detect in this mode, the caster also learns any - Weaknesses common to that creature type.` - } - if (this.data.mods.has('identify')) { - desc += `

Items detected also give the recipient an idea of their - powers and how to activate them.

` - } - if (this.data.mods.has('alignment')) { - desc += `

The recipient can also detect the presence and location - of supernatural good or evil within range, regardless of line of sight.

` - } - desc += `

Invisible Creatures: The recipient may - also ignore ${this.data.raise ? 'all' : 'up to 4 points of'} penalties - when attacking invisible or magically concealed foes.

` - } else { - let area = 'one item or being' - if (this.data.aoe !== 0) { - area = `everything in a sphere the size of a - ${this.data.aoe === 1 ? 'Medium' : 'Large'} Blast Template` - } - desc += `

Conceal ${area} from the Detect Magic ability for - one hour. Attempts to detect arcana suffer a - ${(this.data.mods.has('strong') ? -2 : 0) + this.data.raise ? -2 : -4} penalty.` - } - return desc - } -} - -class DisguiseEffect extends PowerEffect { - get name () { - return 'Disguise' - } - - get icon () { - return 'icons/equipment/head/mask-carved-wood-white.webp' - } - - get duration () { - return 100 - } - - get basePowerPoints () { - return 2 - } - - get isTargeted () { - return true - } - - get hasAdditionalRecipients () { - return true - } - - get additionalRecipientCost () { - return 1 - } - - get modifiers () { - return [ - ...super.modifiers, - { name: 'Size', value: 1, id: 'size', epic: false, effect: false } - ] - } - - get description () { - const size = this.data.mods.has('size') - ? 'of the same size as' - : 'within two sizes of' - return ( - super.description + - ` -

Assume the appearance of another person ${size} the recipient. Anyone - who has cause to doubt the disguise may make a Notice roll at ${this.data.raise ? -4 : -4} - as a free action to see through the disguise.

` - ) - } -} - -const PowerClasses = { - 'arcane-protection': ArcaneProtectionEffect, - banish: BanishEffect, - barrier: BarrierEffect, - 'beast-friend': BeastFriendEffect, - blast: BlastEffect, - blind: BlindEffect, - bolt: BoltEffect, - 'boost-lower-trait': BoostLowerTraitEffect, - 'boost-trait': BoostLowerTraitEffect, - burrow: BurrowEffect, - burst: BurstEffect, - confusion: ConfusionEffect, - curse: CurseEffect, - 'damage-field': DamageFieldEffect, - darksight: DarksightEffect, - deflection: DeflectionEffect, - 'detect-conceal-arcana': DetectConcealArcanaEffect, - disguise: DisguiseEffect, - 'lower-trait': BoostLowerTraitEffect -} - -/* ---------------------------------------------------------------- */ - -export async function powerEffectManagementHook (effect, data, userId) { - if (game.user.id !== userId) { - return - } - const maintId = effect.getFlag(moduleName, 'maintainingId') - if (!maintId) { - return - } - const mutateOptions = { - permanent: true, - comparisonKeys: { - ActiveEffect: 'id' - } - } - const targetIds = effect.getFlag(moduleName, 'targetIds') || [] - for (const targetId of targetIds) { - const mutation = { - embedded: { ActiveEffect: {} } - } - const target = canvas.tokens.get(targetId) - if (!target) { - continue - } - const effects = target.actor.effects.filter( - (e) => e.getFlag(moduleName, 'maintId') === maintId - ) - for (const efct of effects) { - mutation.embedded.ActiveEffect[efct.id] = warpgate.CONST.DELETE - } - mutateOptions.description = `${effect.parent.name} is no longer ${effect.name} on ${target.name}` - await warpgate.mutate(target.document, mutation, {}, mutateOptions) - } -} - -export async function powers (options = {}) { - const token = 'token' in options ? options.token : null - if (token === undefined || token === null) { - ui.notifications.error('Please select one token to be the caster') - return - } - - const targets = 'targets' in options ? Array.from(options.targets) : [] - const item = 'item' in options ? options.item : null - const swid = options?.name || item?.system.swid || null - - if (swid in PowerClasses) { - const runner = new PowerClasses[swid](token, targets) - runner.powerEffect() - return - } - ui.notifications.error(`No power effect found for ${name}`) -} diff --git a/src/module/powers/arcaneProtection.js b/src/module/powers/arcaneProtection.js new file mode 100644 index 0000000..20444bc --- /dev/null +++ b/src/module/powers/arcaneProtection.js @@ -0,0 +1,59 @@ +import { PowerEffect } from './basePowers.js'; + +export class ArcaneProtectionEffect extends PowerEffect { + get name() { + return 'Arcane Protection'; + } + + get duration() { + return 5; + } + + get icon() { + return 'icons/magic/defensive/shield-barrier-flaming-pentagon-blue.webp'; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return 1; + } + + get isTargeted() { + return true; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Greater Arcane Protection', + id: 'greater', + value: 2, + type: 'checkbox', + default: false, + epic: true, + effect: false, + }); + return mods; + } + + get _penaltyAmount() { + return (this.data.raise ? -4 : -2) + (this.data.mods.has('greater') ? -2 : 0); + } + + get description() { + let text = super.description; + text += `

Hostile powers are at ${this._penaltyAmount} when + targeting or damaging the affected character.

`; + return text; + } + + get effectName() { + const greater = this.data.mods.has('greater'); + const raise = this.data.raise; + const amount = this._penaltyAmount; + return `${greater ? 'Greater ' : ''}Arcane Protection (${raise ? 'major, ' : ''}${amount})`; + } +} diff --git a/src/module/powers/banish.js b/src/module/powers/banish.js new file mode 100644 index 0000000..d2b6c99 --- /dev/null +++ b/src/module/powers/banish.js @@ -0,0 +1,86 @@ +import { PowerEffect } from './basePowers.js'; + +export class BanishEffect extends PowerEffect { + get name() { + return 'Banish'; + } + + get duration() { + return 0; + } + + get basePowerPoints() { + return 3; + } + + get usePrimaryEffect() { + return false; + } + + get isTargeted() { + return true; + } + + get isRaisable() { + return false; + } + + get hasAoe() { + return true; + } + + get menuInputs() { + const inputs = super.menuInputs; + inputs.push({ + type: 'select', + label: '⭐ Area of Effect', + options: [ + { html: 'None', value: 0, selected: true }, + { html: 'Small Blast Template (+1)', value: 1, selected: false }, + { html: 'Medium Blast Template (+2)', value: 2, selected: false }, + { html: 'Large Blast Template (+3)', value: 3, selected: false }, + ], + }); + return inputs; + } + + get powerPoints() { + let total = super.powerPoints; + total += this.data.aoe; + return total; + } + + get description() { + return ( + super.description + + ` +

An opposed roll of the caster's skill vs the target's Spirit. + Success: Shaken, each Raise: 1 Wound + Incapacitation results in banishment to home plane.

+ ` + ); + } + + get chatMessageEffects() { + const list = super.chatMessageEffects; + switch (this.data.aoe) { + case 0: + break; + case 1: + list.push('SBT'); + break; + case 2: + list.push('MBT'); + break; + case 3: + list.push('LBT'); + break; + } + return list; + } + + async parseValues() { + await super.parseValues(); + this.data.aoe = this.data.values.shift(); + } +} diff --git a/src/module/powers/basePowers.js b/src/module/powers/basePowers.js new file mode 100644 index 0000000..3a8d51a --- /dev/null +++ b/src/module/powers/basePowers.js @@ -0,0 +1,618 @@ +/* globals warpgate */ +import { templates } from '../preloadTemplates.js'; +import { moduleName } from '../globals.js'; + +const MAINTAIN_ICON = 'icons/magic/symbols/runes-star-blue.webp'; + +export class PowerFormApplication extends FormApplication { + constructor(powerEffect) { + super(); + this.powerEffect = powerEffect; + } + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + classes: ['sheet', 'mbSwadePowerEffectsForm'], + popOut: true, + template: templates['powerDialog.html'], + id: ['mbSwadePowerEffectsApplication'], + title: 'Power Effects', + width: 350, + }); + } + + static sortMods(a, b) { + if (a.isGlobal !== b.isGlobal) { + return a.isGlobal ? -1 : 1; + } + if (a.type !== b.type) { + return a.type === 'checkbox' ? -1 : 1; + } + if ((a.sortOrder ?? 0) !== (b.sortOrder ?? 0)) { + return (a.sortOrder ?? 0) < (b.sortOrder ?? 0) ? -1 : 1; + } + return a.name === b.name ? 0 : a.name < b.name ? -1 : 1; + } + + getData() { + let modifiers = deepClone(this.powerEffect.modifiers); + modifiers.sort(PowerFormApplication.sortMods); + for (const modifier of modifiers) { + modifier.isCheckbox = modifier.type === 'checkbox'; + modifier.isSelect = modifier.type === 'select'; + modifier.isRadio = modifier.type === 'radio'; + modifier.isNumber = modifier.type === 'number'; + modifier.isText = modifier.type === 'text'; + if (modifier.isSelect || modifier.isRadio) { + for (const choice in modifier.choices) { + let val = ''; + if (modifier.values[choice] !== 0) { + val = ` (${modifier.values[choice] > 0 ? '+' : ''}${modifier.values[choice]})`; + } + modifier.choices[choice] = `${modifier.choices[choice]}${val}`; + } + } + } + const data = { + name: this.powerEffect.name, + icon: this.powerEffect.icon, + basePowerPoints: this.powerEffect.basePowerPoints, + modifiers, + additionalRecipientCost: 0, + targets: [], + buttons: this.powerEffect.menuButtons, + }; + if (this.powerEffect.isTargeted) { + data.targets = this.powerEffect.targets.map((t) => t.name); + } + return data; + } + + async _updateObject(ev, formData) { + formData.submit = ev?.submitter?.value ?? 'cancel'; + console.log(ev, formData); + } +} + +export class PowerEffect { + constructor(token, targets) { + this.source = token; + this.targets = targets; + this.data = {}; + } + + static async getStatus(label, name, favorite = true) { + const effect = deepClone(CONFIG.statusEffects.find((se) => se.label === label)); + effect.name = 'name' in effect ? effect.name : effect.label; + if (!('flags' in effect)) { + effect.flags = {}; + } + if (favorite) { + if (!('swade' in effect.flags)) { + effect.flags.swade = {}; + } + effect.flags.swade.favorite = true; + } + effect.flags.core = { statusId: effect.id }; + return effect; + } + + createEffectDocument(icon, name, changes = null) { + if (changes === null) { + changes = []; + } + return { + icon, + name, + changes, + description: `

From ${this.source.name} casting ${this.name}

`, + duration: { rounds: 99 }, + flags: { + [moduleName]: { + powerEffect: true, + }, + swade: { + loseTurnOnHold: false, + expiration: CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.StartOfTurnAuto, + }, + }, + }; + } + + async applyActiveEffects(token, effectDocuments) { + const mutation = { + embedded: { ActiveEffect: {} }, + }; + const mutateOptions = { + permanent: true, + description: `${this.source.name} applying ${effectDocuments[effectDocuments.length - 1]?.name} to ${token.name}`, + }; + for (const effectDocument of effectDocuments) { + mutation.embedded.ActiveEffect[effectDocument.name] = effectDocument; + } + await warpgate.mutate(token.document, mutation, {}, mutateOptions); + } + + get name() { + return 'Unknown Power'; + } + + get effectName() { + return this.name; + } + + get icon() { + return 'icons/magic/symbols/question-stone-yellow.webp'; + } + + get duration() { + return 5; + } + + get basePowerPoints() { + return 0; + } + + get usePrimaryEffect() { + return true; + } + + get hasAdditionalRecipients() { + return false; + } + + get isDamaging() { + return false; + } + + get additionalRecipientCost() { + return 0; + } + + get isTargeted() { + return false; + } + + get oneTarget() { + return false; + } + + get isRaisable() { + return true; + } + + get hasAoe() { + return false; + } + + get modifiers() { + const mods = []; + mods.push({ + name: 'Adaptable Caster', + type: 'checkbox', + default: false, + id: 'adaptable', + value: 1, + epic: false, + effect: false, + isGlobal: true, + }); + mods.push({ + name: 'Fatigue', + type: 'checkbox', + default: false, + id: 'fatigue', + value: 2, + epic: false, + effect: false, + isGlobal: true, + }); + mods.push({ + name: 'Glow/Shroud', + id: 'glowshroud', + type: 'radio', + isGlobal: true, + default: 'none', + choices: { none: 'None', glow: 'Glow', shroud: 'Shroud' }, + values: { none: 0, glow: 1, shroud: 1 }, + effects: { + none: null, + glow: { + icon: 'icons/magic/light/orb-shadow-blue.webp', + changes: [ + { + key: '@Skill{Stealth}[system.die.modifier]', + value: -2, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ], + }, + shroud: { + icon: 'icons/magic/perception/shadow-stealth-eyes-purple.webp', + changes: [ + { + key: '@Skill{Stealth}[system.die.modifier]', + value: 1, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ], + }, + }, + epic: false, + effect: true, + }); + if (this.isDamaging) { + mods.push({ + name: 'Lingering Damage', + id: 'lingeringdamage', + type: 'checkbox', + default: false, + value: 2, + epic: false, + effect: false, + isGlobal: true, + }); + mods.push({ + name: 'Heavy Weapon', + id: 'heavyweapon', + value: 2, + epic: false, + effect: false, + type: 'checkbox', + default: false, + isGlobal: true, + }); + } + mods.push({ + name: 'Hinder/Hurry', + id: 'hinderhurry', + type: 'radio', + default: false, + value: 1, + epic: false, + effect: true, + choices: { none: 'None', hinder: 'Hinder', hurry: 'Hurry' }, + values: { none: 0, hinder: 1, hurry: 1 }, + isGlobal: true, + effects: { + none: null, + hinder: { + icon: 'icons/magic/control/debuff-chains-shackle-movement-red.webp', + changes: [ + { + key: 'system.stats.speed.value', + value: -2, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ], + }, + hurry: { + icon: 'icons/skills/movement/feet-winged-sandals-tan.webp', + changes: [ + { + key: 'system.stats.speed.value', + value: 2, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ], + }, + }, + }); + if (this.hasAoe) { + mods.push({ + type: 'checkbox', + default: false, + name: 'Selective', + id: 'selective', + value: 1, + epic: false, + effect: false, + isGlobal: true, + }); + } + return mods; + } + + get menuData() { + return { + inputs: this.menuInputs, + buttons: this.menuButtons, + }; + } + + get menuInputs() { + const inputs = [ + { type: 'header', label: `${this.name} Effect` }, + { + type: 'info', + label: `Apply ${this.name} Effect (base cost ${this.basePowerPoints} pp)`, + }, + ]; + if (this.isTargeted) { + let label = `Targets: ${this.targets.map((t) => t.name).join(',')}`; + if (this.targets.length > 1 && this.hasAdditionalRecipients) { + label += ` (${this.targets.length - 1} additional recipients ` + `+${this.additionalRecipientCost} ea.)`; + } + inputs.push({ type: 'info', label }); + } + for (const mod of this.modifiers) { + inputs.push({ + type: 'checkbox', + label: `${mod.epic ? '⭐ ' : ''}${mod.name} ` + `(${mod.value >= 0 ? '+' : ''}${mod.value})`, + }); + } + if (this.isDamaging) { + inputs.push({ + type: 'select', + label: 'Armor Piercing', + options: [ + { html: 'None', value: 0, selected: true }, + { html: 'AP 2 (+1)', value: 1, selected: false }, + { html: 'AP 4 (+2)', value: 2, selected: false }, + { html: 'AP 6 (+3)', value: 3, selected: false }, + ], + }); + } + inputs.push({ + type: 'select', + label: 'Range', + options: [ + { html: 'Normal Range', value: 0, selected: true }, + { html: 'Range ×2 (+1)', value: 1, selected: false }, + { html: 'Range ×3 (+2)', value: 2, selected: false }, + ], + }); + return inputs; + } + + get menuButtons() { + const data = [{ label: 'Apply', value: 'apply' }]; + if (this.isRaisable) { + data.push({ label: 'Apply with Raise', value: 'raise' }); + } + data.push({ label: 'Cancel', value: 'cancel' }); + + return data; + } + + get menuOptions() { + return { + title: `${this.name} Effect`, + defaultButton: 'Cancel', + options: {}, + }; + } + + render() { + let app = new PowerFormApplication(this).render(true); + } + + async applyEffect() { + log('Power Effect called'); + log(this); + } + + async powerEffect() { + const { buttons, inputs } = await warpgate.menu(this.menuData, this.menuOptions); + if (buttons && buttons !== 'cancel') { + this.data.button = buttons; + this.data.values = inputs; + await this.parseValues(); + await this.apply(); + await this.sideEffects(); + await this.chatMessage(); + } + } + + async parseValues() { + this.data.rawValues = deepClone(this.data.values); + this.data.raise = this.data.button === 'raise'; + for (let i = 0; i < 2; i++) { + this.data.values.shift(); + } + if (this.isTargeted) { + this.data.values.shift(); + } + this.data.mods = new Set(); + for (const mod of this.modifiers) { + const modEnabled = this.data.values.shift(); + if (modEnabled) { + this.data.mods.add(mod.id); + } + } + if (this.isDamaging) { + this.data.armorPiercing = this.data.values.shift(); + } + this.data.range = this.data.values.shift(); + } + + async createSecondaryEffects(maintId) { + const docs = []; + for (const mod of this.modifiers) { + if (this.data.mods.has(mod.id) && mod.effect) { + const doc = this.createEffectDocument(mod.icon, mod.name, mod.changes); + if (this.duration === 0 && !this.usePrimaryEffect) { + // set secondary effects of instant spells to expire on victim's next + // turn + doc.duration.rounds = 1; + doc.flags.swade.expiration = CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.EndOfTurnAuto; + } else { + doc.duration.seconds = 594; + doc.flags[moduleName].maintId = maintId; + } + docs.push(doc); + } + } + return docs; + } + + getPrimaryEffectChanges() { + const changes = [ + { + key: 'flags.swade-mb-helpers.powerAffected', + value: 1, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.OVERRIDE, + }, + ]; + return changes; + } + + get description() { + return ''; + } + + async createPrimaryEffect(maintId) { + const doc = this.createEffectDocument(this.icon, this.effectName, this.getPrimaryEffectChanges()); + doc.description += this.description; + doc.flags[moduleName].maintId = maintId; + doc.duration.seconds = 594; + return doc; + } + + async createMaintainEffect(maintId) { + let icon = MAINTAIN_ICON; + if (!this.usePrimaryEffect) { + icon = this.icon; + } + const doc = this.createEffectDocument(icon, `Maintaining ${this.effectName}`, []); + doc.duration.rounds = this.duration; + doc.description += this.description; + doc.flags.swade.expiration = CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.EndOfTurnPrompt; + doc.flags.swade.loseTurnOnHold = true; + doc.flags[moduleName].maintainingId = maintId; + doc.flags[moduleName].targetIds = this.targets.map((t) => t.id); + return doc; + } + + // eslint-disable-next-line no-unused-vars + async secondaryDocsForTarget(docs, target) { + return deepClone(docs); + } + + // eslint-disable-next-line no-unused-vars + async primaryDocForTarget(doc, target) { + const newDoc = deepClone(doc); + newDoc.flags[moduleName].maintainingId = doc.flags[moduleName].maintId; + newDoc.flags[moduleName].targetIds = [target.id]; + return newDoc; + } + + async apply() { + const maintId = randomID(); + const secondaryDocs = await this.createSecondaryEffects(maintId); + const primaryDoc = await this.createPrimaryEffect(maintId); + const maintainDoc = await this.createMaintainEffect(maintId); + if (this.isTargeted) { + for (const target of this.targets) { + const targetDocs = await this.secondaryDocsForTarget(secondaryDocs, target); + if (this.duration > 0 || this.usePrimaryEffect) { + targetDocs.push(await this.primaryDocForTarget(primaryDoc, target)); + } + if (targetDocs.length > 0) { + await this.applyActiveEffects(target, targetDocs); + } + } + } + if (this.duration > 0) { + await this.applyActiveEffects(this.source, [maintainDoc]); + } + } + + async sideEffects() { + if (this.data.mods.has('fatigue') && this.isTargeted) { + for (const target of this.targets) { + const actor = target.actor; + const update = { + system: { + fatigue: { + value: actor.system.fatigue.value + 1, + }, + }, + }; + if (actor.system.fatigue.value < actor.system.fatigue.max) { + await actor.update(update); + } + } + } + } + + get powerPoints() { + let total = this.basePowerPoints; + for (const mod of this.modifiers) { + if (this.data.mods.has(mod.id)) { + total += mod.value; + } + } + if (this.targets.length > 1 && this.hasAdditionalRecipients) { + total += (this.targets.length - 1) * this.additionalRecipientCost; + } + total += this.data.range; + if (this.isDamaging) { + total += this.data.armorPiercing; + } + return total; + } + + get chatMessageEffects() { + const list = []; + if (this.hasAdditionalRecipients && this.targets.length > 1) { + list.push(`${this.targets.length - 1} Additional Recipients`); + } + if (this.data.mods.has('adaptable')) { + list.push('Different Trapping (Adaptable Caster)'); + } + if (this.data.mods.has('fatigue')) { + list.push('Fatigue (applied to targets'); + } + if (this.data.mods.has('heavyweapon')) { + list.push('Heavy Weapon'); + } + if (this.data.mods.has('lingeringdamage')) { + list.push('Lingering Damage'); + } + if (this.data.mods.has('selective')) { + list.push('Selective'); + } + if (this.isDamaging && this.data.armorPiercing > 0) { + list.push(`AP ${this.data.armorPiercing * 2}`); + } + if (this.data.range > 0) { + list.push(`Range ×${this.data.range + 1}`); + } + return list; + } + + get chatMessageText() { + let text = `

Cast ${this.name}`; + if (this.isTargeted && this.targets.length > 0) { + text += ` on ${this.targets.map((t) => t.name).join(', ')}`; + } + text += '

'; + const desc = this.description; + if (desc) { + text += `
Description${desc}
`; + } + const effects = this.chatMessageEffects; + if (effects.length > 0) { + text += '
Other Effects:
'; + } + return text; + } + + async chatMessage() { + return ChatMessage.create( + { + flavor: `Calculated cost: ${this.powerPoints} pp`, + speaker: ChatMessage.getSpeaker(this.source.actor), + content: this.chatMessageText, + whisper: ChatMessage.getWhisperRecipients('GM', game.user.name), + }, + { chatBubble: false }, + ); + } +} diff --git a/src/module/powers/powers.js b/src/module/powers/powers.js new file mode 100644 index 0000000..d61ecbc --- /dev/null +++ b/src/module/powers/powers.js @@ -0,0 +1,1452 @@ +/* globals warpgate */ +import { moduleName } from '../globals.js'; +import { PowerEffect } from './basePowers.js'; +import { ArcaneProtectionEffect } from './arcaneProtection.js'; +import { BanishEffect } from './banish.js'; + +class BarrierEffect extends PowerEffect { + get name() { + return 'Barrier'; + } + + get duration() { + return 5; + } + + get icon() { + return 'icons/environment/settlement/fence-stone-brick.webp'; + } + + get isTargeted() { + return false; + } + + get isDamaging() { + return true; + } + + get basePowerPoints() { + return 2; + } + + get usePrimaryEffect() { + return false; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Damage', + id: 'damage', + value: 1, + epic: false, + effect: false, + }); + mods.push({ + name: 'Damage (immaterial trapping)', + id: 'damage', + value: 0, + epic: false, + effect: false, + }); + mods.push({ + name: 'Deadly', + id: 'deadly', + value: 2, + epic: true, + effect: false, + }); + mods.push({ + name: 'Hardened', + id: 'hardened', + value: 1, + epic: false, + effect: false, + }); + mods.push({ + name: 'Shaped', + id: 'shaped', + value: 1, + epic: false, + effect: false, + }); + mods.push({ + name: 'Size', + id: 'size', + value: 1, + epic: false, + effect: false, + }); + return mods; + } + + get _length() { + let height = 10; + if (this.data.raise) { + height *= 2; + } + if (this.data.mods.has('size')) { + height *= 2; + } + return `${height}" (${height * 2} yards)`; + } + + get _height() { + return `${this.data.mods.has('size') ? '2" (4' : '1" (2'} yards`; + } + + get _hardness() { + return (this.data.raise ? 12 : 10) + (this.data.mods.has('hardened') ? 2 : 0); + } + + get description() { + let text = super.description; + text += `

A barrier ${this._height} tall and ${this._length} long, of hardness ${this._hardness}. `; + if (this.data.mods.has('deadly')) { + text += 'It does 2d6 damage to anyone who contacts it. '; + } else if (this.data.mods.has('damage')) { + text += 'It does 2d4 damage to anyone who contacts it. '; + } + if (this.data.mods.has('shaped')) { + text += 'It was shaped into a circle, square, or rectangle. '; + } + text += '

'; + return text; + } +} + +class BeastFriendEffect extends PowerEffect { + get name() { + return 'Beast Friend'; + } + + get duration() { + return (this.data.mods.has('duration') ? 30 : 10) * 6 * 60; + } + + get icon() { + return 'icons/magic/nature/wolf-paw-glow-large-green.webp'; + } + + get isTargeted() { + return true; + } + + get usePrimaryEffect() { + return true; + } + + get modifiers() { + const mods = super.modifiers; + mods.push( + { + name: 'Bestiarium', + value: 2, + id: 'bestiarium', + epic: true, + effect: false, + }, + { + name: 'Duration', + value: 1, + id: 'duration', + epic: false, + effect: false, + }, + { + name: 'Mind Rider', + value: 1, + id: 'mindrider', + epic: false, + effect: false, + }, + ); + return mods; + } + + get menuInputs() { + const inputs = super.menuInputs; + const pp = Math.max( + this.targets.map((t) => Math.max(t.actor.system.stats.size, 1)).reduce((a, b) => a + b, 0), + 1, + ); + inputs.push({ type: 'number', label: 'Base power points', options: pp }); + return inputs; + } + + async parseValues() { + await super.parseValues(); + this.data.basePP = this.data.values.shift(); + } + + get basePowerPoints() { + return this?.data?.basePP || 2; + } + + get description() { + let text = super.description; + if (this.data.raise) { + text += '

Creatures will overcome instincts to follow orders.'; + } else { + text += '

Creatures obey simple commands, subject to their insticts.'; + } + if (this.data.mods.has('bestiarium')) { + text += ' The caster may even effect magical beasts.'; + } + return text; + } +} + +class BlastEffect extends PowerEffect { + get name() { + return 'Blast'; + } + + get icon() { + return 'icons/magic/fire/explosion-fireball-large-red-orange.webp'; + } + + get duration() { + return 0; + } + + get isTargeted() { + return true; + } + + get usePrimaryEffect() { + return false; + } + + get isDamaging() { + return true; + } + + get basePowerPoints() { + return 3; + } + + get hasAoe() { + return true; + } + + get menuInputs() { + const inputs = super.menuInputs; + inputs.push({ + type: 'select', + label: 'Area of Effect', + options: [ + { html: 'Small Blast Template (0)', value: 's', selected: false }, + { html: 'Medium Blast Template (0)', value: 'm', selected: true }, + { html: 'Large Blast Template (+1)', value: 'l', selected: false }, + ], + }); + return inputs; + } + + get modifiers() { + const mods = super.modifiers; + mods.push( + { name: 'Damage', value: 2, id: 'damage', epic: false, effect: false }, + { + name: 'Greater Blast', + value: 4, + id: 'greater', + epic: true, + effect: false, + }, + ); + return mods; + } + + get powerPoints() { + let total = super.powerPoints; + total += this.data.aoe === 'l' ? 1 : 0; + return total; + } + + async parseValues() { + await super.parseValues(); + this.data.aoe = this.data.values.shift(); + } + + get description() { + const dmgDie = + (this.data.mods.has('greater') ? 4 : this.data.mods.has('damage') ? 3 : 2) + (this.data.raise ? 1 : 0); + const size = this.data.aoe === 'l' ? 'LBT' : this.data.aoe === 's' ? 'SBT' : 'MBT'; + return ( + super.description + + ` +

The blast covers a ${size} and does ${dmgDie}d6 damage

` + ); + } +} + +class BlindEffect extends PowerEffect { + get name() { + return 'Blind'; + } + + get icon() { + return 'icons/skills/wounds/injury-eyes-blood-red.webp'; + } + + get duration() { + return 0; + } + + get isTargeted() { + return true; + } + + get hasAoe() { + return true; + } + + get basePowerPoints() { + return 2; + } + + getPrimaryEffectChanges() { + const changes = [ + { + key: 'system.stats.globalMods.trait', + value: -2, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ]; + return changes; + } + + get description() { + return ( + super.description + + `

${this.data.raise ? -4 : -2} penalty to all actions involving sight.

+

Shake off attempts at end of turns with a Vigor + ${this.data.mods.has('strong') ? '-2 ' : ''}roll as a free action. + Success removes 2 points of penalties. A raise removes the effect.

` + ); + } + + async createSecondaryEffects(maintId) { + const docs = await super.createSecondaryEffects(maintId); + if (this.data.raise) { + const strong = this.data.mods.has('strong'); + const doc = this.createEffectDocument(this.icon, `Blinded (${strong ? 'Strong, ' : ''}Raise)`, [ + { + key: 'system.stats.globalMods.trait', + value: -2, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ]); + doc.duration.seconds = 594; + doc.description = this.description + '

This is the raise effect which can be shaken off separately.

'; + doc.flags[moduleName].maintId = maintId; + docs.push(doc); + } + return docs; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Strong', + value: 1, + id: 'strong', + epic: false, + effect: false, + }); + return mods; + } + + get menuInputs() { + const inputs = super.menuInputs; + inputs.push({ + type: 'select', + label: 'Area of Effect', + options: [ + { html: 'None', value: 0, selected: true }, + { html: 'Medium Blast Template (+2)', value: 2, selected: false }, + { html: 'Large Blast Template (+3)', value: 3, selected: false }, + ], + }); + return inputs; + } + + async parseValues() { + await super.parseValues(); + this.data.aoe = this.data.values.shift(); + } + + get powerPoints() { + let total = super.powerPoints; + total += this.data.aoe; + return total; + } + + get effectName() { + const strong = this.data.mods.has('strong'); + return `Blinded${strong ? ' (Strong)' : ''}`; + } + + get chatMessageEffects() { + const list = super.chatMessageEffects; + switch (this.data.aoe) { + case 2: + list.push('MBT'); + break; + case 3: + list.push('LBT'); + break; + } + return list; + } +} + +class BoltEffect extends PowerEffect { + get name() { + return 'Bolt'; + } + + get icon() { + return 'icons/magic/fire/explosion-fireball-large-red-orange.webp'; + } + + get duration() { + return 0; + } + + get isTargeted() { + return true; + } + + get isDamaging() { + return true; + } + + get basePowerPoints() { + return 3; + } + + get usePrimaryEffect() { + return false; + } + + get modifiers() { + const mods = super.modifiers; + mods.push( + { name: 'Damage', value: 2, id: 'damage', epic: false, effect: false }, + { + name: 'Disintegrate', + value: 1, + id: 'disintigrate', + epic: true, + effect: false, + }, + { + name: 'Greater Bolt', + value: 4, + id: 'greater', + epic: true, + effect: false, + }, + { name: 'Rate of Fire', value: 2, id: 'rof', epic: true, effect: false }, + ); + return mods; + } + + get powerPoints() { + let total = super.powerPoints; + total += this.data.aoe === 'l' ? 1 : 0; + return total; + } + + get description() { + const dmgDie = + (this.data.mods.has('greater') ? 4 : this.data.mods.has('damage') ? 3 : 2) + (this.data.raise ? 1 : 0); + let desc = super.description + '

'; + if (this.data.mods.has('rof')) { + desc += `Up to two bolts (RoF 2) do ${dmgDie}d6 damage each.`; + } else { + desc += `The bolt does ${dmgDie}d6 damage.`; + } + if (this.data.mods.has('disintegrate')) { + desc += + 'The bolt is disintegrating. If being used to break' + + ' something, the damage dice can Ace. A creature Incapacitated by a ' + + 'disintegrating bolt must make a Vigor roll or its body turns to dust'; + } + desc += '

'; + return desc; + } +} + +class BurrowEffect extends PowerEffect { + get name() { + return 'Burrow'; + } + + get duration() { + return 5; + } + + get icon() { + return 'icons/magic/earth/projectile-stone-landslide.webp'; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return 1; + } + + get basePowerPoints() { + return 1; + } + + get isTargeted() { + return true; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Power', + id: 'power', + value: 1, + epic: false, + effect: false, + }); + return mods; + } + + get effectName() { + return ( + `${this.name} ${this.data.mods.has('power') ? '[Power] ' : ''}` + `(${this.data.raise ? 'full' : 'half'} pace)` + ); + } + + get description() { + let text = + super.description + + `

Meld into the ground. Move at ${this.data.raise ? 'full' : 'half'} pace. May not run.

`; + if (this.data.mods.has('power')) { + text += '

Can burrow through solid stone, concrete, etc

'; + } + return text; + } +} + +class BoostLowerTraitEffect extends PowerEffect { + get name() { + return 'Boost/Lower Trait'; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return 2; + } + + get icon() { + return this?.data?.direction === 'Boost' + ? 'icons/magic/life/cross-embers-glow-yellow-purple.webp' + : 'icons/magic/movement/chevrons-down-yellow.webp'; + } + + get duration() { + return this?.data?.direction === 'Boost' ? 5 : 0; + } + + get isTargeted() { + return true; + } + + get basePowerPoints() { + return 3; + } + + getPrimaryEffectChanges() { + let modValue = '2'; + if (this.data.raise && this.data.direction === 'Boost') { + modValue = '4'; + } + modValue = (this.data.direction === 'Boost' ? '+' : '-') + modValue; + const changes = [ + { + key: this.data.trait.diekey, + value: modValue, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ]; + if (this.data.direction === 'Lower' && this.data.mods.has('greater')) { + changes.push({ + key: this.data.trait.modkey, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + value: -2, + priority: 0, + }); + } + return changes; + } + + async createSecondaryEffects(maintId) { + const docs = await super.createSecondaryEffects(maintId); + if (this.data.raise && this.data.direction === 'Lower') { + const name = 'major ' + this.effectName; + const modValue = this.data.direction === 'Boost' ? '+2' : '-2'; + const changes = [ + { + key: this.data.trait.diekey, + value: modValue, + priority: 0, + mode: foundry.CONST.ACTIVE_EFFECT_MODES.ADD, + }, + ]; + const doc = this.createEffectDocument(this.icon, name, changes); + doc.duration.seconds = 594; + doc.description = this.description + '

This is the raise effect which can be shaken off separately.

'; + doc.flags[moduleName].maintId = maintId; + docs.push(doc); + } + return docs; + } + + get effectName() { + let name = `${this.data.direction} ${this.data.trait.name}`; + const nameMods = []; + if (this.data.mods.has('greater')) { + nameMods.push('Greater'); + } + if (this.data.direction === 'Lower' && this.data.mods.has('strong')) { + nameMods.push('Strong'); + } + if (nameMods.length > 0) { + name += ` (${nameMods.join(', ')})`; + } + return name; + } + + get description() { + let desc = super.description; + const amount = `${this.data.raise ? 2 : 1} die type${this.data.raise ? 's' : ''}`; + desc += `

${this.data.direction === 'Boost' ? 'Raise' : 'Lower'} the + target's ${this.data.trait.name} die type ${amount}.`; + if (this.data.mods.has('greater')) { + if (this.data.direction === 'Boost') { + desc += ` Additionally, the target gains a free ${this.data.trait.name} + reroll once per ${this.data.raise ? 'action' : 'round'}.`; + } else { + desc += ` Additionally, the target suffers a -2 penalty to their + ${this.data.trait.name} rolls.`; + } + } + desc += '

'; + if (this.data.direction === 'Lower') { + desc += `

At the end of the target's following turns, they attempt to shake off + the affect with a Spirit${this.data.mods.has('strong') ? ' -2' : ''} + roll as a free action. Success reduces the effect one die type. A raise + completely shakes off the effect.

`; + } + return desc; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Greater Boost/Lower Trailt', + value: 2, + id: 'greater', + epic: true, + effect: false, + }); + mods.push({ + name: 'Strong (lower only)', + value: 1, + id: 'strong', + epic: false, + effect: false, + }); + return mods; + } + + get menuInputs() { + const inputs = super.menuInputs; + let traitOptions = ['Agility', 'Smarts', 'Spirit', 'Strength', 'Vigor']; + const allSkills = new Set(); + const traits = {}; + for (const traitName of traitOptions) { + const lower = traitName.toLowerCase(); + traits[traitName] = { + name: traitName, + type: 'attribute', + modkey: `system.attributes.${lower}.die.modifier`, + diekey: `system.attributes.${lower}.die.sides`, + }; + } + for (const token of this.targets) { + const skills = token.actor.items.filter((item) => item.type === 'skill'); + for (const skill of skills) { + const name = skill.name; + traits[name] = { + name, + type: 'skill', + modkey: `@Skill{${name}}[system.die.modifier]`, + diekey: `@Skill{${name}}[system.die.sides]`, + }; + if (name !== 'Unskilled') { + allSkills.add(name); + } + } + } + traitOptions = traitOptions.concat(Array.from(allSkills).sort()); + this.data.traits = traits; + inputs.push({ type: 'select', label: 'Trait', options: traitOptions }); + inputs.push({ type: 'info', label: 'Boost or Lower?' }); + inputs.push({ type: 'radio', label: 'Boost', options: ['isBoost', true] }); + inputs.push({ type: 'radio', label: 'Lower', options: ['isBoost', false] }); + return inputs; + } + + async parseValues() { + await super.parseValues(); + this.data.trait = this.data.traits[this.data.values.shift()]; + this.data.values.shift(); + this.data.direction = this.data.values.shift() ? 'Boost' : 'Lower'; + } + + get powerPoints() { + const total = super.powerPoints; + return total; + } +} + +class BurstEffect extends PowerEffect { + get name() { + return 'Blast'; + } + + get icon() { + return 'icons/magic/sonic/projectile-shock-wave-blue.webp'; + } + + get duration() { + return 0; + } + + get isTargeted() { + return true; + } + + get usePrimaryEffect() { + return false; + } + + get isDamaging() { + return true; + } + + get basePowerPoints() { + return 3; + } + + get hasAoe() { + return true; + } + + get modifiers() { + const mods = super.modifiers; + mods.push( + { name: 'Damage', value: 2, id: 'damage', epic: false, effect: false }, + { + name: 'Greater Burst', + value: 4, + id: 'greater', + epic: true, + effect: false, + }, + ); + return mods; + } + + get description() { + const dmgDie = + (this.data.mods.has('greater') ? 4 : this.data.mods.has('damage') ? 3 : 2) + (this.data.raise ? 1 : 0); + return ( + super.description + + ` +

The blast covers a Cone or Stream template and does ${dmgDie}d6 damage

` + ); + } +} + +class ConfusionEffect extends PowerEffect { + get name() { + return 'Confusion'; + } + + get icon() { + return 'icons/magic/control/hypnosis-mesmerism-swirl.webp'; + } + + get duration() { + return 0; + } + + get isTargeted() { + return true; + } + + get usePrimaryEffect() { + return false; + } + + get basePowerPoints() { + return 2; + } + + get hasAoe() { + return true; + } + + get menuInputs() { + const inputs = super.menuInputs; + inputs.push({ + type: 'select', + label: 'Area of Effect', + options: [ + { html: 'Small Blast Template (0)', value: 's', selected: false }, + { html: 'Medium Blast Template (0)', value: 'm', selected: true }, + { html: 'Large Blast Template (+1)', value: 'l', selected: false }, + ], + }); + return inputs; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Greater Confusion', + value: 2, + id: 'greater', + epic: true, + effect: false, + }); + return mods; + } + + get powerPoints() { + let total = super.powerPoints; + total += this.data.aoe === 'l' ? 1 : 0; + return total; + } + + get menuButtons() { + const data = [ + { label: 'Apply with Distracted', value: 'distracted' }, + { label: 'Apply with Vulnerable', value: 'vulnerable' }, + { label: 'Apply with both (raise)', value: 'raise' }, + { label: 'Cancel', value: 'cancel' }, + ]; + return data; + } + + async parseValues() { + await super.parseValues(); + this.data.distracted = this.data.button === 'distracted' || this.data.button === 'raise'; + this.data.vulnerable = this.data.button === 'vulnerable' || this.data.button === 'raise'; + this.data.aoe = this.data.values.shift(); + } + + get description() { + const size = this.data.aoe === 'l' ? 'LBT' : this.data.aoe === 's' ? 'SBT' : 'MBT'; + let effect = 'Vulnerable'; + if (this.data.raise) { + effect = 'both Distracted and Vulnerable'; + } else if (this.data.distracted) { + effect = 'Distracted'; + } + if (this.data.mods.has('Greater')) { + effect += ' as well as Shaken'; + } + return ( + super.description + + ` +

The targets in the ${size} are ${effect}.

` + ); + } + + async createSecondaryEffects(maintId) { + const docs = await super.createSecondaryEffects(maintId); + if (this.data.distracted) { + PowerEffect.getStatus('SWADE.Distr', 'Distracted', false).then((v) => docs.push(v)); + } + if (this.data.distracted) { + PowerEffect.getStatus('SWADE.Vuln', 'Vulnerable', false).then((v) => docs.push(v)); + } + if (this.data.mods.has('greater')) { + PowerEffect.getStatus('SWADE.Shaken', 'Shaken', false).then((v) => docs.push(v)); + } + return docs; + } +} + +class CurseEffect extends PowerEffect { + get name() { + return 'Curse'; + } + + get icon() { + return 'icons/magic/control/voodoo-doll-pain-damage-purple.webp'; + } + + get duration() { + return 500 * 24 * 60 * 6; + } + + get isTargeted() { + return true; + } + + get oneTarget() { + return true; + } + + get isRaisable() { + return false; + } + + get basePowerPoints() { + return 5; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Turn to Stone', + value: 5, + id: 'turntostone', + epic: true, + effect: false, + }); + return mods; + } + + get description() { + let desc = super.description; + desc += `

The victim must defend with a Spirit roll opposed by the + caster's arcane skill roll. Failure means the victim suffers a level + of Fatigue immediately.

`; + if (this.data.mods.has('turntostone')) { + desc += `

On every following run the victim must make a Spirit roll + or take a level of Fatigue. When Incapacitated, the victim turns to + stone, with a Hardness equal to his Tougness.

`; + } else { + desc += `

At sunset every day, the victim suffers a level of Fatigue. + When Incapacitated by this, he makes a Vigor roll each day to avoid + death.

`; + } + desc += `

Breaking the curse: The curse can be lifted by + the original caster at will, and ends if the caster is slain. Dispel at -2 + also removes the curse, but each individual may only try once.

`; + return desc; + } +} + +class DamageFieldEffect extends PowerEffect { + get name() { + return 'Damage Field'; + } + + get icon() { + return 'icons/magic/defensive/shield-barrier-blades-teal.webp'; + } + + get duration() { + return 5; + } + + get basePowerPoints() { + return 4; + } + + get isTargeted() { + return true; + } + + get oneTarget() { + return true; + } + + get isRaisable() { + return false; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Area of Effect', + value: 2, + id: 'aoe', + epic: false, + effect: false, + }); + mods.push({ + name: 'Damage', + value: 2, + id: 'damage', + epic: false, + effect: false, + }); + mods.push({ + name: 'Greater Damage Field', + value: 4, + id: 'greater', + epic: true, + effect: false, + }); + mods.push({ + name: 'Mobile', + value: 2, + id: 'mobile', + epic: false, + effect: false, + }); + return mods; + } + + get description() { + let desc = super.description; + let area = 'all adjacent creatures'; + let damage = '2d4'; + if (this.data.mods.has('greater')) { + damage = '3d6 (heavy weapon)'; + } else if (this.data.mods.has('damage')) { + damage = '2d6'; + } + if (this.data.mods.has('aoe')) { + area = 'all creatures within a MBT'; + } + desc += `

At the end of the recipient's turn, ${area} + automatically take ${damage} damage.`; + if (this.data.mods.has('mobile')) { + desc += `The caster may detach the damage field from the recipient and + move it up to his Smarts die type each round, as a limited free action.`; + } + desc += '

'; + return desc; + } + + getPrimaryEffectChanges() { + const base = 'flags.swade.auras.damagefield'; + const priority = 0; + const mode = foundry.CONST.ACTIVE_EFFECT_MODES.OVERRIDE; + const changes = [ + { key: `${base}.enabled`, value: true, priority, mode }, + { key: `${base}.walls`, value: true, priority, mode }, + { key: `${base}.color`, value: '#ffcc00', priority, mode }, + { key: `${base}.alpha`, value: 0.1, priority, mode }, + { + key: `${base}.radius`, + value: this.data.mods.has('aoe') ? 1.5 : 0.5, + priority, + mode, + }, + { key: `${base}.visibleTo`, value: [-1, 0, 1], priority, mode }, + ]; + return changes; + } +} + +class DarksightEffect extends PowerEffect { + get name() { + return 'Darksight'; + } + + get icon() { + return 'icons/magic/perception/eye-ringed-glow-angry-small-teal.webp'; + } + + get duration() { + return 600; + } + + get basePowerPoints() { + return 1; + } + + get isTargeted() { + return true; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return 1; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Greater Darksight', + value: 2, + id: 'greater', + epic: true, + effect: false, + }); + return mods; + } + + get description() { + let desc = super.description; + desc += '

'; + if (this.data.mods.has('greater')) { + desc += `Can see in all darkness, ignoring all illumination penalties and + 4 points of penalties from invisible creatures`; + } else if (this.data.raise) { + desc += 'Can see in Pitch Darkness and ignore up to 6 points of illumination penalties'; + } else { + desc += 'Can see in darkness and ignore 4 points of illumination penalties'; + } + desc += '

'; + return desc; + } + + get effectName() { + if (this.data.mods.has('greater')) { + return 'Greater Darksight'; + } else if (this.data.raise) { + return 'Major Darksight'; + } else { + return 'Darksignt'; + } + } +} + +class DeflectionEffect extends PowerEffect { + get name() { + return 'Deflection'; + } + + get icon() { + return 'icons/magic/defensive/shield-barrier-deflect-teal.webp'; + } + + get duration() { + return 5; + } + + get basePowerPoints() { + return 2; + } + + get isTargeted() { + return true; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return 1; + } + + get menuButtons() { + const data = [ + { label: 'Melee', value: 'melee' }, + { label: 'Ranged', value: 'vulnerable' }, + { label: 'Raise (both)', value: 'raise' }, + { label: 'Cancel', value: 'cancel' }, + ]; + return data; + } + + async parseValues() { + await super.parseValues(); + this.data.affects = this.data.button === 'raise' ? 'all' : this.data.button; + } + + get effectName() { + return `Deflection (${this.data.affects})`; + } + + get description() { + return ( + super.description + + `

Attackers subtract -2 from ${this.data.affects} + attacks when targeting this creature.

` + ); + } +} + +class DetectConcealArcanaEffect extends PowerEffect { + get name() { + return 'Detect/Conceal Arcana'; + } + + get icon() { + return this.data.detect + ? 'icons/magic/perception/third-eye-blue-red.webp' + : 'icons/magic/perception/silhouette-stealth-shadow.webp'; + } + + get duration() { + return this.data.detect ? 5 : 600; + } + + get basePowerPoints() { + return 2; + } + + get isTargeted() { + return true; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return (this.data?.aoe || 0) > 0 ? 0 : 1; + } + + get modifiers() { + const mods = super.modifiers; + mods.push({ + name: 'Alignment Sense (detect)', + value: 1, + id: 'alignment', + epic: false, + effect: false, + }); + mods.push({ + name: 'Identify (detect)', + value: 1, + id: 'identify', + epic: false, + effect: false, + }); + mods.push({ + name: 'Strong (conceal)', + value: 1, + id: 'strong', + epic: false, + effect: false, + }); + return mods; + } + + get hasAoe() { + return true; + } + + get menuInputs() { + const inputs = super.menuInputs; + inputs.push({ + type: 'select', + label: 'Area of Effect (conceal)', + options: [ + { html: 'None', value: 0, selected: true }, + { html: 'Medium Blast Template (+1)', value: 1, selected: false }, + { html: 'Large Blast Template (+2)', value: 2, selected: false }, + ], + }); + inputs.push({ type: 'info', label: 'Detect or Conceal?' }); + inputs.push({ + type: 'radio', + label: 'Detect', + options: ['isDetect', true], + }); + inputs.push({ + type: 'radio', + label: 'Conceal', + options: ['isDetect', false], + }); + return inputs; + } + + async parseValues() { + await super.parseValues(); + this.data.aoe = this.data.values.shift(); + this.data.values.shift(); + this.data.detect = this.data.values.shift(); + } + + get powerPoints() { + return super.powerPoints + this.data.aoe; + } + + get effectName() { + return `${this.data.detect ? 'Detect' : 'Conceal'} Arcana`; + } + + get description() { + let desc = super.description; + if (this.data.detect) { + desc += `

The recipient can see and detect all supernatural persons, + objects, or effects in sight. This includes invisible foes, enchanted + objects, and so on.`; + if (this.data.raise) { + desc += `Since this was major Detect Arcana, the type of enchantments + is also known.`; + } + desc += `

If cast to learn more about a creature, the caster learns + active powers and arcane abilities.`; + if (this.data.raise) { + desc += `As major Detect in this mode, the caster also learns any + Weaknesses common to that creature type.`; + } + if (this.data.mods.has('identify')) { + desc += `

Items detected also give the recipient an idea of their + powers and how to activate them.

`; + } + if (this.data.mods.has('alignment')) { + desc += `

The recipient can also detect the presence and location + of supernatural good or evil within range, regardless of line of sight.

`; + } + desc += `

Invisible Creatures: The recipient may + also ignore ${this.data.raise ? 'all' : 'up to 4 points of'} penalties + when attacking invisible or magically concealed foes.

`; + } else { + let area = 'one item or being'; + if (this.data.aoe !== 0) { + area = `everything in a sphere the size of a + ${this.data.aoe === 1 ? 'Medium' : 'Large'} Blast Template`; + } + desc += `

Conceal ${area} from the Detect Magic ability for + one hour. Attempts to detect arcana suffer a + ${(this.data.mods.has('strong') ? -2 : 0) + this.data.raise ? -2 : -4} penalty.`; + } + return desc; + } +} + +class DisguiseEffect extends PowerEffect { + get name() { + return 'Disguise'; + } + + get icon() { + return 'icons/equipment/head/mask-carved-wood-white.webp'; + } + + get duration() { + return 100; + } + + get basePowerPoints() { + return 2; + } + + get isTargeted() { + return true; + } + + get hasAdditionalRecipients() { + return true; + } + + get additionalRecipientCost() { + return 1; + } + + get modifiers() { + return [...super.modifiers, { name: 'Size', value: 1, id: 'size', epic: false, effect: false }]; + } + + get description() { + const size = this.data.mods.has('size') ? 'of the same size as' : 'within two sizes of'; + return ( + super.description + + ` +

Assume the appearance of another person ${size} the recipient. Anyone + who has cause to doubt the disguise may make a Notice roll at ${this.data.raise ? -4 : -4} + as a free action to see through the disguise.

` + ); + } +} + +const PowerClasses = { + 'arcane-protection': ArcaneProtectionEffect, + banish: BanishEffect, + barrier: BarrierEffect, + 'beast-friend': BeastFriendEffect, + blast: BlastEffect, + blind: BlindEffect, + bolt: BoltEffect, + 'boost-lower-trait': BoostLowerTraitEffect, + 'boost-trait': BoostLowerTraitEffect, + burrow: BurrowEffect, + burst: BurstEffect, + confusion: ConfusionEffect, + curse: CurseEffect, + 'damage-field': DamageFieldEffect, + darksight: DarksightEffect, + deflection: DeflectionEffect, + 'detect-conceal-arcana': DetectConcealArcanaEffect, + disguise: DisguiseEffect, + 'lower-trait': BoostLowerTraitEffect, +}; + +/* ---------------------------------------------------------------- */ + +export async function powerEffectManagementHook(effect, data, userId) { + if (game.user.id !== userId) { + return; + } + const maintId = effect.getFlag(moduleName, 'maintainingId'); + if (!maintId) { + return; + } + const mutateOptions = { + permanent: true, + comparisonKeys: { + ActiveEffect: 'id', + }, + }; + const targetIds = effect.getFlag(moduleName, 'targetIds') || []; + for (const targetId of targetIds) { + const mutation = { + embedded: { ActiveEffect: {} }, + }; + const target = canvas.tokens.get(targetId); + if (!target) { + continue; + } + const effects = target.actor.effects.filter((e) => e.getFlag(moduleName, 'maintId') === maintId); + for (const efct of effects) { + mutation.embedded.ActiveEffect[efct.id] = warpgate.CONST.DELETE; + } + mutateOptions.description = `${effect.parent.name} is no longer ${effect.name} on ${target.name}`; + await warpgate.mutate(target.document, mutation, {}, mutateOptions); + } +} + +export async function powers(options = {}) { + const token = 'token' in options ? options.token : null; + if (token === undefined || token === null) { + ui.notifications.error('Please select one token to be the caster'); + return; + } + + const targets = 'targets' in options ? Array.from(options.targets) : []; + const item = 'item' in options ? options.item : null; + const swid = options?.name || item?.system.swid || null; + + if (swid in PowerClasses) { + const runner = new PowerClasses[swid](token, targets); + runner.render(); + return; + } + ui.notifications.error(`No power effect found for ${name}`); +} diff --git a/src/module/preloadTemplates.js b/src/module/preloadTemplates.js index 9c90119..dcb5296 100644 --- a/src/module/preloadTemplates.js +++ b/src/module/preloadTemplates.js @@ -1,11 +1,15 @@ -// SPDX-FileCopyrightText: 2022 Johannes Loher -// -// SPDX-License-Identifier: MIT +const _templatePaths = ['powerDialog.html']; export async function preloadTemplates() { - const templatePaths = [ - // Add paths to "modules/swade-mb-helpers/templates" - ]; - - return loadTemplates(templatePaths); + return loadTemplates(_templatePaths.map((f) => `modules/swade-mb-helpers/templates/${f}`)); } + +function templateMap() { + const templates = {}; + for (const file of _templatePaths) { + templates[file] = `modules/swade-mb-helpers/templates/${file}`; + } + return templates; +} + +export const templates = templateMap(); diff --git a/src/module/swade-mb-helpers.js b/src/module/swade-mb-helpers.js index a3d1f04..8392539 100644 --- a/src/module/swade-mb-helpers.js +++ b/src/module/swade-mb-helpers.js @@ -5,13 +5,13 @@ // Import JavaScript modules import { registerSettings } from './settings.js'; import { preloadTemplates } from './preloadTemplates.js'; -import { api } from './api.js' -import { initVisionModes } from './visionModes.js' -import { requestTokenRoll } from './helpers.js' -import { preDamageRollModifiers, preTraitRollModifiers } from './rollHelpers.js' -import { shapeChangeOnDismiss } from './powerEffects.js' -import { log, moduleHelpers } from './globals.js' -import { powerEffectManagementHook } from './powers.js' +import { api } from './api.js'; +import { initVisionModes } from './visionModes.js'; +import { requestTokenRoll } from './helpers.js'; +import { preDamageRollModifiers, preTraitRollModifiers } from './rollHelpers.js'; +import { shapeChangeOnDismiss } from './powerEffects.js'; +import { log, moduleHelpers } from './globals.js'; +import { powerEffectManagementHook } from './powers/powers.js'; // Initialize module Hooks.once('init', async () => { @@ -30,36 +30,34 @@ Hooks.once('init', async () => { // Setup module Hooks.once('setup', async () => { - api.registerFunctions() + api.registerFunctions(); // Do anything after initialization but before // ready }); // When ready Hooks.once('ready', async () => { - _checkModule('warpgate') - _checkModule('socketlib') - log('Initialized SWADE MB Helpers') - warpgate.event.watch(warpgate.EVENT.DISMISS, shapeChangeOnDismiss) + _checkModule('warpgate'); + _checkModule('socketlib'); + log('Initialized SWADE MB Helpers'); + warpgate.event.watch(warpgate.EVENT.DISMISS, shapeChangeOnDismiss); }); - -function _checkModule (name) { +function _checkModule(name) { if (!game.modules.get(name)?.active && game.user.isGM) { - let action = 'install and activate' - if (game.modules.get(name)) action = 'activate' - ui.notifications.error( - `SWADE MB Helpers requires the ${name} module. Please ${action} it.`) + let action = 'install and activate'; + if (game.modules.get(name)) action = 'activate'; + ui.notifications.error(`SWADE MB Helpers requires the ${name} module. Please ${action} it.`); } } -Hooks.on('swadePreRollAttribute', preTraitRollModifiers) -Hooks.on('swadePreRollSkill', preTraitRollModifiers) -Hooks.on('swadeRollDamage', preDamageRollModifiers) -Hooks.on('deleteActiveEffect', powerEffectManagementHook) +Hooks.on('swadePreRollAttribute', preTraitRollModifiers); +Hooks.on('swadePreRollSkill', preTraitRollModifiers); +Hooks.on('swadeRollDamage', preDamageRollModifiers); +Hooks.on('deleteActiveEffect', powerEffectManagementHook); Hooks.once('socketlib.ready', () => { - const _socket = socketlib.registerModule('swade-mb-helpers') - _socket.register('requestTokenRoll', requestTokenRoll) - moduleHelpers._socket = _socket -}) + const _socket = socketlib.registerModule('swade-mb-helpers'); + _socket.register('requestTokenRoll', requestTokenRoll); + moduleHelpers._socket = _socket; +}); diff --git a/src/templates/powerDialog.html b/src/templates/powerDialog.html new file mode 100644 index 0000000..417d539 --- /dev/null +++ b/src/templates/powerDialog.html @@ -0,0 +1,27 @@ +
+
+  +
+

{{name}} Effect

+

Apply the affects of {{name}}.

+
+
+

+ {{#if targets.length}} Targets: {{#each targets}}{{#if @index}}, {{/if}}{{this}}{{/each}} {{/if}} +

+ {{#each modifiers}} +
+ {{#if isCheckbox}} + + + {{/if}} {{#if isRadio}} + + {{radioBoxes id choices checked=default}} {{/if}} +
+ {{/each}} + +