From c8f27770b6e1f023e8c7f574f4fd9736ed4964b7 Mon Sep 17 00:00:00 2001
From: Mike Bloy From ${this.source.name} casting ${this.name} Cast ${this.name}`
- if (this.isTargeted && this.targets.length > 0) {
- text += ` on ${this.targets.map((t) => t.name).join(', ')}`
- }
- text += ' Hostile powers are at ${this._penaltyAmount} when
- targeting or damaging the affected character. 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. 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 += ' 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 ${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. This is the raise effect which can be shaken off separately. '
- 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 += ' Meld into the ground. Move at ${this.data.raise ? 'full' : 'half'} pace. May not run. Can burrow through solid stone, concrete, etc This is the raise effect which can be shaken off separately. ${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 += ' 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. The blast covers a Cone or Stream template and does ${dmgDie}d6 damage The targets in the ${size} are ${effect}. 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. 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. 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. 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. 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 += ' '
- 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 += ' Attackers subtract -2 from ${this.data.affects}
- attacks when targeting this creature. 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. The recipient can also detect the presence and location
- of supernatural good or evil within range, regardless of line of sight.Description
${desc}Other Effects:
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 += `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 @@ +