import { moduleName, moduleHelpers, log } from '../globals.js'; import { firstOwner, addActiveEffectsToToken } from '../helpers.js'; import { templates } from '../preloadTemplates.js'; const MAINTAIN_ICONS = [ 'icons/magic/symbols/runes-star-blue.webp', 'icons/magic/symbols/runes-star-magenta.webp', 'icons/magic/symbols/runes-star-orange-purple.webp', 'icons/magic/symbols/runes-star-orange.webp', 'icons/magic/symbols/runes-star-pentagon-blue.webp', 'icons/magic/symbols/runes-star-pentagon-magenta.webp', 'icons/magic/symbols/runes-star-pentagon-orange-purple.webp', 'icons/magic/symbols/runes-star-pentagon-orange.webp', 'icons/magic/symbols/runes-triangle-blue.webp', 'icons/magic/symbols/runes-triangle-magenta.webp', 'icons/magic/symbols/runes-triangle-orange-purple.webp', 'icons/magic/symbols/runes-triangle-orange.webp', ]; function _hashCode(str) { let hash = 0; if (str.length === 0) { return hash; } for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); hash = (hash << 5) - hash + c; hash |= 0; } return Math.abs(hash); } export class PowerFormApplication extends FormApplication { constructor(powerEffect) { super(); this.powerEffect = powerEffect; } static get defaultOptions() { return mergeObject(super.defaultOptions, { classes: ['sheet', 'mbSwadeForm', 'mbSwadePowerEffectsForm'], popOut: true, template: templates['powerDialog.html'], id: ['mbSwadePowerEffectsApplication'], title: 'Power Effects', width: 400, }); } static sortMods(a, b) { if (a.isGlobal !== b.isGlobal) { return a.isGlobal ? -1 : 1; } if ((a.sortOrder ?? 0) !== (b.sortOrder ?? 0)) { return (a.sortOrder ?? 0) < (b.sortOrder ?? 0) ? -1 : 1; } if (a.type !== b.type) { return a.type === 'checkbox' ? -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.isNumber) { modifier.step = modifier?.step ?? 1; } 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, recipients: { cost: 0, number: 0, total: 0, }, extraDescription: this.powerEffect.extraDescription, targets: [], buttons: this.powerEffect.menuButtons, }; if (this.powerEffect.isTargeted) { if (this.powerEffect.oneTarget) { data.targets = [this.powerEffect.targets?.[0]?.name ?? 'No Target Selected!']; } else { data.targets = this.powerEffect.targets.map((t) => t.name); } } if (this.powerEffect.hasAdditionalRecipients && this.powerEffect.targets.length > 1) { data.recipients.cost = this.powerEffect.additionalRecipientCost; data.recipients.count = this.powerEffect.additionalRecipientCount; data.recipients.total = data.recipients.cost * data.recipients.count; data.recipients.epic = this.powerEffect.additionalRecipientsIsEpic; data.recipients.text = this.powerEffect.additionalRecipientText; } return data; } async _updateObject(ev, formData) { formData.submit = ev?.submitter?.value ?? 'cancel'; if (formData.submit !== 'cancel') { this.powerEffect.formData = formData; this.powerEffect.applyEffect(); } } } 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.statuses ??= []; effect.statuses.push(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: {}, flags: { [moduleName]: { powerEffect: true, }, swade: { loseTurnOnHold: false, expiration: CONFIG.SWADE.CONST.STATUS_EFFECT_EXPIRATION.StartOfTurnAuto, }, }, }; } async applyActiveEffects(token, effectDocuments) { const owner = firstOwner(token); await moduleHelpers.socket.executeAsUser( addActiveEffectsToToken, owner.id, token?.scene?.id ?? token.parent.id, token.id, effectDocuments, ); } get name() { return 'Unknown Power'; } get effectName() { return this.name; } get extraDescription() { return ''; } get icon() { return 'icons/magic/symbols/question-stone-yellow.webp'; } get duration() { return 5; } get basePowerPoints() { return 0; } get usePrimaryEffect() { return true; } get isDamaging() { return false; } get hasAdditionalRecipients() { return false; } get additionalRecipientsIsEpic() { return false; } get additionalRecipientText() { return 'Additional Recipients'; } get additionalRecipientCount() { if (!this.hasAdditionalRecipients) { return 0; } return Math.max(0, this.targets.length - 1); } get additionalRecipientCost() { return 0; } get isTargeted() { return false; } get oneTarget() { return false; } get hasRange() { return true; } 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: { name: '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: { name: '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, }); if (this.isDamaging) { mods.push({ name: 'Armor Piercing', id: 'ap', type: 'select', default: 'none', choices: { none: 'None', 2: 'AP 2', 4: 'AP 4', 6: 'AP 6' }, values: { none: 0, 2: 1, 4: 2, 6: 3 }, effects: { none: null, 2: null, 4: null, 6: null }, epic: false, isGlobal: true, }); 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: 'none', value: 1, epic: false, choices: { none: 'None', hinder: 'Hinder', hurry: 'Hurry' }, values: { none: 0, hinder: 1, hurry: 1 }, isGlobal: true, effects: { none: null, hinder: { name: '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: { name: '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, }); } if (this.hasRange) { mods.push({ type: 'select', default: 0, name: 'Range', id: 'range', choices: { normal: 'Normal Range', x2: 'Range ×2', x3: 'Range ×3', }, values: { normal: 0, x2: 1, x3: 2, }, isGlobal: true, effects: { normal: null, x2: null, x3: null }, }); } return mods; } 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; } render() { new PowerFormApplication(this).render(true); } async applyEffect() { await this.parseValues(); await this.apply(); await this.chatMessage(); await this.sideEffects(); } async parseValues() { this.data.raise = this.formData.submit === 'raise'; this.data.button = this.formData.submit; for (const mod of this.modifiers) { this.data[mod.id] = this.formData[mod.id]; } } enhanceSecondaryEffect(maintId, doc) { doc.statuses = doc.statuses ?? []; doc.statuses.push('powerEffect'); 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.flags[moduleName].maintId = maintId; if (moduleHelpers.useVAE) { doc.flags['visual-active-effects'] = { data: { inclusion: 1, }, }; } else { doc.duration.seconds = 594; } } return doc; } async createSecondaryEffects(maintId) { const docs = []; for (const mod of this.modifiers) { const modValue = this.data[mod.id]; if (modValue && (mod?.effect || (mod?.effects?.[modValue] ?? false))) { const icon = 'effects' in mod ? mod.effects[modValue].icon : mod.icon; const name = 'effects' in mod ? mod.effects[modValue].name : mod.name; const changes = 'effects' in mod ? mod.effects[modValue].changes : mod.changes; const doc = this.enhanceSecondaryEffect(maintId, this.createEffectDocument(icon, name, changes)); 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; } getMaintainEffectChanges() { const changes = [ { key: 'flags.swade-mb-helpers.powerMaintained', value: 1, priority: 0, mode: foundry.CONST.ACTIVE_EFFECT_MODES.OVERRIDE, }, ]; return changes; } get description() { return ''; } get primaryEffectButtons() { // button objects should have a label and a type. // type should have one of the following, with the associated additional // fields: // roll: // formula: dice formula eg '3d6 + 3' // flavor: flavor text (optional) // trait: // rollType: 'attribute' or 'skill // rollDesc: name or swid of the attribute or skill // flavor: flavor text (optional) // mods: list of mods { label, value, ignore } // damage: // formula: dice formula for example '1d4x[Blades]' // ap: optional, a positive integer or 0, armor piercing // flavor: flavor text (optional) // callback: // callback: the function callback to run, takes a token as an argument return []; } get maintEffectButtons() { // see the comment for primaryEffectButtons return []; } get basePrimaryEffect() { return this.createEffectDocument(this.icon, this.effectName, this.getPrimaryEffectChanges()); } async createPrimaryEffect(maintId) { const doc = this.basePrimaryEffect; if (moduleHelpers.useVAE) { doc.flags['visual-active-effects'] = { data: { content: this.description, inclusion: 1, }, }; } else { doc.description += this.description; } doc.statuses = doc.statuses ?? []; doc.statuses.push('powerEffect'); doc.flags[moduleName].maintId = maintId; const effectButtons = this.primaryEffectButtons; if (effectButtons.length > 0) { doc.flags[moduleName].buttons = effectButtons; } return doc; } async createMaintainEffect(maintId) { let icon = MAINTAIN_ICONS[_hashCode(this.name) % MAINTAIN_ICONS.length]; if (!this.usePrimaryEffect) { icon = this.icon; } const doc = this.createEffectDocument(icon, `Maintaining ${this.effectName}`, this.getMaintainEffectChanges()); doc.duration.rounds = this.duration; if (moduleHelpers.useVAE) { doc.flags['visual-active-effects'] = { data: { content: this.description } }; } else { 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; if (this.isTargeted) { doc.flags[moduleName].targetIds = this.targets.map((t) => t.id); } else { doc.flags[moduleName].targetIds = [this.source.id]; } doc.statuses = doc.statuses ?? []; doc.statuses.push('powerMaintainEffect'); const effectButtons = this.maintEffectButtons; if (effectButtons.length > 0) { doc.flags[moduleName].buttons = effectButtons; } return doc; } // eslint-disable-next-line no-unused-vars async secondaryDocsForTarget(docs, target) { return deepClone(docs); } 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 = foundry.utils.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); } } } else { const targetDocs = await this.secondaryDocsForTarget(secondaryDocs, this.source); if (targetDocs.length > 0) { await this.applyActiveEffects(this.source, targetDocs); } } if (this.duration > 0) { await this.applyActiveEffects(this.source, [maintainDoc]); } } async sideEffects() { if (this.data.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) { const modValue = this.data[mod.id]; if (modValue) { if ('values' in mod) { total += mod.values[modValue]; } else { total += mod.value; } } } total += this.additionalRecipientCost * this.additionalRecipientCount; return total; } get chatMessageEffects() { const list = []; if (this.hasAdditionalRecipients && this.targets.length > 1) { list.push(`${this.targets.length - 1} Additional Recipients`); } if (this.data.adaptable) { list.push('Different Trapping (Adaptable Caster)'); } if (this.data.fatigue) { list.push('Fatigue (applied to targets)'); } if (this.data.heavyweapon) { list.push('Heavy Weapon'); } if (this.data.lingeringdamage) { list.push('Lingering Damage'); } if (this.data.selective) { list.push('Selective'); } if (this.isDamaging && this.data.ap > 0) { list.push(`AP ${this.data.ap}`); } if (this.data.range ?? 'none' != 'none') { list.push(`Range ${this.data.range}`); } 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 += `