diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f667adb..519281ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Releases -## Version 4.0.4 [Hero System 6e (Unofficial) v2](https://github.com/dmdorman/hero6e-foundryvtt) +## Version 4.0.5 (So Far...) [Hero System 6e (Unofficial) v2](https://github.com/dmdorman/hero6e-foundryvtt) + +- OCV/OMCV bonuses are no longer active effects. They only work for a specific instant attack. [#1285](https://github.com/dmdorman/hero6e-foundryvtt/issues/1285) +- Fixed rare issue where adding some 6e powers to a 5e actor would prevent actor sheet from opening. +- Support for STR Minimum OCV penalty. [#384](https://github.com/dmdorman/hero6e-foundryvtt/issues/384) +- Support for STR 0 rolls including DCV and movement penalties. [#1401](https://github.com/dmdorman/hero6e-foundryvtt/issues/1401) +- Support for PRE 0 rolls. [#1403](https://github.com/dmdorman/hero6e-foundryvtt/issues/1403) +- Fixed issue where FLIGHT was impacting KB rolls even when FLIGHT was turned off. [#1400](https://github.com/dmdorman/hero6e-foundryvtt/issues/1400) +- Fixed issue where full END for an attack was used even when lowering Effective Strength. [#1399](https://github.com/dmdorman/hero6e-foundryvtt/issues/1399) +- AUTOMATION's can't use STUN in place of END. [#1398](https://github.com/dmdorman/hero6e-foundryvtt/issues/1398) + +## Version 4.0.4 - Penalty Skill Levels now default to checked after upload. [#1359](https://github.com/dmdorman/hero6e-foundryvtt/issues/1359) - Mental attacks vs Entangles without Mental Defense now default to targeting actor, not entangle by default. [#1295](https://github.com/dmdorman/hero6e-foundryvtt/issues/1295) diff --git a/module/actor/actor-sheet.mjs b/module/actor/actor-sheet.mjs index c7f45703..13d4529e 100644 --- a/module/actor/actor-sheet.mjs +++ b/module/actor/actor-sheet.mjs @@ -262,7 +262,7 @@ export class HeroSystemActorSheet extends ActorSheet { const pdContentsAttack = ` - `; + `; const pdAttack = await new HeroSystem6eItem( HeroSystem6eItem.itemDataFromXml(pdContentsAttack, defenseCalculationActor), { temporary: true, parent: defenseCalculationActor }, @@ -312,7 +312,7 @@ export class HeroSystemActorSheet extends ActorSheet { const edContentsAttack = ` - `; + `; const edAttack = await new HeroSystem6eItem( HeroSystem6eItem.itemDataFromXml(edContentsAttack, defenseCalculationActor), { temporary: true, parent: defenseCalculationActor }, @@ -363,7 +363,7 @@ export class HeroSystemActorSheet extends ActorSheet { - `; + `; const mdAttack = await new HeroSystem6eItem( HeroSystem6eItem.itemDataFromXml(mdContentsAttack, defenseCalculationActor), { temporary: true, parent: defenseCalculationActor }, @@ -411,7 +411,7 @@ export class HeroSystemActorSheet extends ActorSheet { - `; + `; const drainAttack = await new HeroSystem6eItem( HeroSystem6eItem.itemDataFromXml(drainContentsAttack, defenseCalculationActor), { temporary: true, parent: defenseCalculationActor }, diff --git a/module/actor/actor.mjs b/module/actor/actor.mjs index ef647707..465a6824 100644 --- a/module/actor/actor.mjs +++ b/module/actor/actor.mjs @@ -887,6 +887,13 @@ export class HeroSystem6eActor extends Actor { } await this.createEmbeddedDocuments("ActiveEffect", [activeEffect]); + + // If we have control of this token, re-acquire to update movement types + const myToken = this.getActiveTokens()?.[0]; + if (canvas.tokens.controlled.find((t) => t.id == myToken.id)) { + myToken.release(); + myToken.control(); + } return; } @@ -895,6 +902,88 @@ export class HeroSystem6eActor extends Actor { } else if (prevActiveEffect && prevActiveEffect.name != name) { await prevActiveEffect.update({ name: name }); } + + // At STR 0, halve the character’s Running, + // Leaping, Swimming, Swinging, Tunneling, and + // Flight based on muscle power (such as most types + // of wings). The GM may require the character to + // succeed with STR Rolls just to stand up, walk, and + // perform similar mundane exertions. + // At STR 0, halve the character’s DCV. + // For every 2x mass a character has above the + // standard human mass of 100 kg, the effects of STR + // 0 on movement and DCV occur 5 points of STR + // sooner. + const massMultiplier = this.items + .filter((o) => o.system.XMLID === "DENSITYINCREASE" && o.system.active) + .reduce((p, a) => p + parseInt(a.system.LEVELS), 0); + const minStr = massMultiplier * 5; + + const prevStr0ActiveEffect = this.effects.find((o) => o.flags?.str0); + if (this.system.characteristics.str.value <= minStr && !prevStr0ActiveEffect) { + const str0ActiveEffect = { + name: "STR0", + id: "STR0", + icon: `systems/${HEROSYS.module}/icons/encumbered.svg`, + changes: [ + { + key: "system.characteristics.dcv.value", + value: 0.5, + mode: CONST.ACTIVE_EFFECT_MODES.MULTIPLY, + }, + { + key: "system.characteristics.running.value", + value: 0.5, + mode: CONST.ACTIVE_EFFECT_MODES.MULTIPLY, + }, + { + key: "system.characteristics.leaping.value", + value: 0.5, + mode: CONST.ACTIVE_EFFECT_MODES.MULTIPLY, + }, + { + key: "system.characteristics.swimming.value", + value: 0.5, + mode: CONST.ACTIVE_EFFECT_MODES.MULTIPLY, + }, + { + key: "system.characteristics.swinging.value", + value: 0.5, + mode: CONST.ACTIVE_EFFECT_MODES.MULTIPLY, + }, + { + key: "system.characteristics.tunneling.value", + value: 0.5, + mode: CONST.ACTIVE_EFFECT_MODES.MULTIPLY, + }, + ], + origin: this.uuid, + // duration: { + // seconds: 3.154e7 * 100, // 100 years should be close to infinity + // }, + flags: { + str0: true, + }, + }; + + await this.createEmbeddedDocuments("ActiveEffect", [str0ActiveEffect]); + // If we have control of this token, re-acquire to update movement types + const myToken = this.getActiveTokens()?.[0]; + if (canvas.tokens.controlled.find((t) => t.id == myToken.id)) { + myToken.release(); + myToken.control(); + } + } else { + if (prevStr0ActiveEffect && this.system.characteristics.str.value > minStr) { + await prevStr0ActiveEffect.delete(); + // If we have control of this token, re-acquire to update movement types + const myToken = this.getActiveTokens()?.[0]; + if (canvas.tokens.controlled.find((t) => t.id == myToken.id)) { + myToken.release(); + myToken.control(); + } + } + } } async FullHealth() { diff --git a/module/item/item-attack-application.mjs b/module/item/item-attack-application.mjs index 4fc6b381..58836f72 100644 --- a/module/item/item-attack-application.mjs +++ b/module/item/item-attack-application.mjs @@ -109,6 +109,7 @@ export class ItemAttackFormApplication extends FormApplication { data.omcvMod ??= parseInt(item.system.ocv); //TODO: May need to make a distinction between OCV/OMCV data.dmcvMod ??= parseInt(item.system.dcv); data.effectiveStr ??= parseInt(data.str); + data.effectiveStr = Math.max(0, data.effectiveStr); data.effectiveLevels ??= parseInt(data.item.system.LEVELS); // Penalty Skill Levels @@ -138,7 +139,7 @@ export class ItemAttackFormApplication extends FormApplication { data.targetEntangle = data.entangleExists; // Mental attacks typically bypass entangles - if (item.attackDefenseVs === "MD" && entangles?.[0].flags.entangleDefense.rMD === 0) { + if (item.attackDefenseVs === "MD" && entangles?.[0]?.flags.entangleDefense.rMD === 0) { data.targetEntangle = false; } } @@ -328,8 +329,7 @@ export class ItemAttackFormApplication extends FormApplication { } } if (updates) { - const actualUpdates = await this.data.actor.updateEmbeddedDocuments("Item", updates); - console.log(actualUpdates); + await this.data.actor.updateEmbeddedDocuments("Item", updates); } // Take all the data we updated in the form and apply it. diff --git a/module/item/item-attack.mjs b/module/item/item-attack.mjs index 13a311fd..721c5152 100644 --- a/module/item/item-attack.mjs +++ b/module/item/item-attack.mjs @@ -11,7 +11,7 @@ import { } from "../utility/damage.mjs"; import { performAdjustment, renderAdjustmentChatCards } from "../utility/adjustment.mjs"; import { getRoundedDownDistanceInSystemUnits, getSystemDisplayUnits } from "../utility/units.mjs"; -import { HeroSystem6eItem, RequiresASkillRollCheck } from "../item/item.mjs"; +import { HeroSystem6eItem, RequiresASkillRollCheck, RequiresACharacteristicRollCheck } from "../item/item.mjs"; import { ItemAttackFormApplication } from "../item/item-attack-application.mjs"; import { DICE_SO_NICE_CUSTOM_SETS, HeroRoller } from "../utility/dice.mjs"; import { clamp } from "../utility/compatibility.mjs"; @@ -379,6 +379,65 @@ export async function AttackToHit(item, options) { const actor = item.actor; let effectiveItem = item; + // Make sure there are enough resources and consume them + const { + error: resourceError, + warning: resourceWarning, + resourcesRequired, + resourcesUsedDescription, + resourcesUsedDescriptionRenderedRoll, + } = await userInteractiveVerifyOptionallyPromptThenSpendResources(effectiveItem, { + ...options, + ...{ noResourceUse: false }, + }); + if (resourceError) { + return ui.notifications.error(resourceError); + } else if (resourceWarning) { + return ui.notifications.warn(resourceWarning); + } + + // STR 0 character must succeed with + // a STR Roll in order to perform any Action that uses STR, such + // as aiming an attack, pulling a trigger, or using a Power with the + // Gestures Limitation. + // Not all token types (base) will have STR + if ( + actor && + actor.system.characteristics.str && + (effectiveItem.system.usesStrength || effectiveItem.findModsByXmlid("GESTURES")) + ) { + if (parseInt(actor.system.characteristics.str.value) <= 0) { + if ( + !(await RequiresACharacteristicRollCheck( + actor, + "str", + `Actions that use STR or GESTURES require STR roll when at 0 STR`, + )) + ) { + await ui.notifications.warn(`${actor.name} failed STR 0 roll. Action with ${item.name} failed.`); + return; + } + } + } + + // PRE 0 + // At PRE 0, a character must attempt an PRE Roll to take any + // offensive action, or to remain in the face of anything even + // remotely threatening. + // Not all token types (base) will have PRE + if (actor && actor.system.characteristics.pre && parseInt(actor.system.characteristics.pre.value) <= 0) { + if ( + !(await RequiresACharacteristicRollCheck( + actor, + "pre", + `Offensive actions when at PRE 0 requires PRE roll, failure typically results in actor avoiding threats`, + )) + ) { + await ui.notifications.warn(`${actor.name} failed PRE 0 roll. Action with ${item.name} failed.`); + return; + } + } + // Create a temporary item based on effectiveLevels if (options?.effectiveLevels && parseInt(item.system.LEVELS) > 0) { options.effectiveLevels = parseInt(options.effectiveLevels) || 0; @@ -557,6 +616,16 @@ export async function AttackToHit(item, options) { dcv -= 4; } + // STRMINIMUM + const STRMINIMUM = item.findModsByXmlid("STRMINIMUM"); + if (STRMINIMUM) { + const strMinimumValue = parseInt(STRMINIMUM.OPTION_ALIAS.match(/\d+/)?.[0] || 0); + const extraStr = Math.max(0, parseInt(actor.system.characteristics.str.value)) - strMinimumValue; + if (extraStr < 0) { + heroRoller.addNumber(Math.floor(extraStr / 5), STRMINIMUM.ALIAS); + } + } + cvModifiers.forEach((cvModifier) => { if (cvModifier.cvMod.ocv) { heroRoller.addNumber(cvModifier.cvMod.ocv, cvModifier.name); @@ -601,23 +670,6 @@ export async function AttackToHit(item, options) { // (so we can be sneaky and not tell the target's DCV out loud). heroRoller.addDice(-3); - // Make sure there are enough resources and consume them - const { - error: resourceError, - warning: resourceWarning, - resourcesRequired, - resourcesUsedDescription, - resourcesUsedDescriptionRenderedRoll, - } = await userInteractiveVerifyOptionallyPromptThenSpendResources(item, { - ...options, - ...{ noResourceUse: false }, - }); - if (resourceError) { - return ui.notifications.error(resourceError); - } else if (resourceWarning) { - return ui.notifications.warn(resourceWarning); - } - const aoeModifier = item.getAoeModifier(); const aoeTemplate = game.scenes.current.templates.find((template) => template.flags.itemId === item.id) || @@ -804,7 +856,7 @@ export async function AttackToHit(item, options) { } } - if (!(await RequiresASkillRollCheck(item))) { + if (!(await item)) { const speaker = ChatMessage.getSpeaker({ actor: item.actor }); speaker["alias"] = item.actor.name; @@ -3163,13 +3215,23 @@ async function _calcKnockback(body, item, options, knockbackMultiplier) { // Target is in the air -1d6 // TODO: This is perhaps not the right check as they could just have the movement radio on. Consider a flying status // when more than 0m off the ground? This same effect should also be considered for gliding. - if (options.targetToken?.actor?.flags?.activeMovement === "flight") { - knockbackDice -= 1; - knockbackTags.push({ - value: "-1d6KB", - name: "target is in the air", - title: "Knockback Modifier", - }); + const activeMovement = options.targetToken?.actor?.flags?.activeMovement; + if (["flight", "gliding"].includes(activeMovement)) { + // Double check to make sure FLIGHT or GLIDING is still on + if ( + options.targetToken.actor.items.find( + (o) => o.system.XMLID === activeMovement.toUpperCase() && o.system.active, + ) + ) { + knockbackDice -= 1; + knockbackTags.push({ + value: "-1d6KB", + name: "target is in the air", + title: `Knockback Modifier ${options.targetToken?.actor?.flags?.activeMovement}`, + }); + } else { + console.warn(`${activeMovement} selected but that power is not active.`); + } } // TODO: Target Rolled With A Punch -1d6 @@ -3296,6 +3358,14 @@ export async function userInteractiveVerifyOptionallyPromptThenSpendResources(it } } else { if (resourcesRequired.end > actorEndurance && useResources) { + // Auotmation or other actor without STUN + const hasSTUN = getCharacteristicInfoArrayForActor(actor).find((o) => o.key === "STUN"); + if (!hasSTUN) { + return { + warning: `${item.name} needs ${resourcesRequired.end} END but ${actor.name} only has ${actorEndurance} END. This actor cannot use STUN for END.`, + }; + } + // Is the actor willing to use STUN to make up for the lack of END? const potentialStunCost = calculateRequiredStunDiceForLackOfEnd(actor, resourcesRequired.end); diff --git a/module/item/item.mjs b/module/item/item.mjs index 3f32d2dd..00da8687 100644 --- a/module/item/item.mjs +++ b/module/item/item.mjs @@ -623,6 +623,11 @@ export class HeroSystem6eItem extends Item { break; } + // DENSITYINCREASE can affect Encumbrance & Movements + if (this.system.XMLID === "DENSITYINCREASE") { + await this.actor.applyEncumbrancePenalty(); + } + // Charges expire // if (charges) { // // Find the active effect @@ -1414,6 +1419,25 @@ export class HeroSystem6eItem extends Item { item.flags.tags.dcv += `-5 Haymaker`; } + // STRMINIMUM + const STRMINIMUM = item.findModsByXmlid("STRMINIMUM"); + if (STRMINIMUM) { + const strMinimumValue = parseInt(STRMINIMUM.OPTION_ALIAS.match(/\d+/)?.[0] || 0); + const extraStr = + Math.max(0, parseInt(item.actor?.system.characteristics.str.value || 0)) - strMinimumValue; + if (extraStr < 0) { + const adjustment = Math.floor(extraStr / 5); + item.system.ocvEstimated = `${parseInt(item.system.ocvEstimated) + adjustment}`; + + if (item.flags.tags.ocv) { + item.flags.tags.ocv += "\n"; + } else { + item.flags.tags.ocv = ""; + } + item.flags.tags.ocv += `${adjustment.signedString()} ${STRMINIMUM.ALIAS}`; + } + } + item.system.phase = item.system.PHASE; } @@ -4868,6 +4892,11 @@ export class HeroSystem6eItem extends Item { return "PD"; } + // STRIKE + if (this.system.EFFECT?.includes("STR")) { + return "PD"; + } + if (this.system.XMLID === "TELEKINESIS") { return "PD"; } @@ -4881,7 +4910,11 @@ export class HeroSystem6eItem extends Item { return "KB"; } - console.error(`Unable to determine defense for ${this.name}`); + if (this.system.XMLID === "HANDTOHANDATTACK") { + return "PD"; + } + + console.warn(`Unable to determine defense for ${this.name}`); return "PD"; // Default } @@ -4891,7 +4924,7 @@ export class HeroSystem6eItem extends Item { // A backpack from MiscEquipment.hdp is a CUSTOMPOWER if (this.system.description.match(/can hold \d+kg/i)) return true; - return this.baseInfo.isContainer; + return this.baseInfo?.isContainer; } get killing() { @@ -4949,7 +4982,47 @@ export function getItem(id) { return null; } -export async function RequiresASkillRollCheck(item, event) { +export async function RequiresACharacteristicRollCheck(actor, characteristic, reasonText) { + console.log(characteristic, this); + const successValue = parseInt(actor?.system.characteristics[characteristic.toLowerCase()].roll) || 8; + const activationRoller = new HeroRoller().makeSuccessRoll(true, successValue).addDice(3); + await activationRoller.roll(); + let succeeded = activationRoller.getSuccess(); + const autoSuccess = activationRoller.getAutoSuccess(); + const total = activationRoller.getSuccessTotal(); + const margin = successValue - total; + + const flavor = `${reasonText ? `${reasonText}. ` : ``}${characteristic.toUpperCase()} roll ${successValue}- ${ + succeeded ? "succeeded" : "failed" + } by ${autoSuccess === undefined ? `${Math.abs(margin)}` : `rolling ${total}`}`; + let cardHtml = await activationRoller.render(flavor); + + // FORCE success + if (!succeeded && overrideCanAct) { + const overrideKeyText = game.keybindings.get(HEROSYS.module, "OverrideCanAct")?.[0].key; + ui.notifications.info(`${actor.name} succeeded roll because override key.`); + succeeded = true; + cardHtml += `

Succeeded roll because ${game.user.name} used ${overrideKeyText} key to override.

`; + } + + const token = actor.token; + const speaker = ChatMessage.getSpeaker({ actor: actor, token }); + speaker.alias = actor.name; + + const chatData = { + type: CONST.CHAT_MESSAGE_TYPES.ROLL, + rolls: activationRoller.rawRolls(), + user: game.user._id, + content: cardHtml, + speaker: speaker, + }; + + await ChatMessage.create(chatData); + + return succeeded; +} + +export async function RequiresASkillRollCheck(item) { // Toggles don't need a roll to turn off //if (item.system?.active === true) return true; @@ -5063,10 +5136,11 @@ export async function RequiresASkillRollCheck(item, event) { let cardHtml = await activationRoller.render(flavor); // FORCE success - if (!succeeded && event?.ctrlKey) { - ui.notifications.info(`${item.actor.name} succeeded roll because ${game.user.name} used CTRL key.`); + if (!succeeded && overrideCanAct) { + const overrideKeyText = game.keybindings.get(HEROSYS.module, "OverrideCanAct")?.[0].key; + ui.notifications.info(`${item.actor.name} succeeded roll because override key.`); succeeded = true; - cardHtml += `

Succeeded roll because ${game.user.name} used CTRL key.

`; + cardHtml += `

Succeeded roll because ${game.user.name} used ${overrideKeyText} key to override.

`; } const actor = item.actor; diff --git a/module/utility/attack.mjs b/module/utility/attack.mjs index 7ae05b0a..c0cb79fd 100644 --- a/module/utility/attack.mjs +++ b/module/utility/attack.mjs @@ -8,7 +8,10 @@ export class Attack { const actor = action.system.actor; Attack.removeActionActiveEffects(actor); cvModifiers.forEach((cvModifier) => { - Attack.makeActionActiveEffect(action, cvModifier); + // Do not create an AE for OCV as it only works for an instant, no need to keep track of it. + if (!cvModifier.cvMod.ocv) { + Attack.makeActionActiveEffect(action, cvModifier); + } }); } // discontinue any effects for the action diff --git a/module/utility/util.mjs b/module/utility/util.mjs index 18a34932..0d0ea4e1 100644 --- a/module/utility/util.mjs +++ b/module/utility/util.mjs @@ -109,7 +109,13 @@ export function getCharacteristicInfoArrayForActor(actor) { const isCharOrMovePowerForActor = _isNonIgnoredCharacteristicsAndMovementPowerForActor(actor); const powerList = actor?.system?.is5e ? CONFIG.HERO.powers5e : CONFIG.HERO.powers6e; - const powers = powerList.filter(isCharOrMovePowerForActor); + let powers = powerList.filter(isCharOrMovePowerForActor); + const AUTOMATION = actor.items.find((o) => o.system.XMLID === "AUTOMATON"); + if (AUTOMATION && powers.find((o) => o.key === "STUN" || o.key === "EGO" || o.key === "OMCV" || o.key === "DMCV")) { + console.warn("Wrong actor type", actor); + // TODO: change actor type to AUTOMATION or whatever is appropriate? + powers = powers.filter((o) => o.key !== "STUN" && o.key !== "EGO" && o.key !== "OMCV" && o.key !== "DMCV"); + } return powers; } diff --git a/templates/attack/item-attack-application.hbs b/templates/attack/item-attack-application.hbs index 192b5387..669abb5b 100644 --- a/templates/attack/item-attack-application.hbs +++ b/templates/attack/item-attack-application.hbs @@ -222,7 +222,7 @@
{{!-- --}} - {{numberInput effectiveStr name="effectiveStr" step=1 min=1 max=999}} + {{numberInput effectiveStr name="effectiveStr" step=1 min=0 max=999}}