//============================================================================= // LWP_AiAdjustment.js //============================================================================= /*: * @target MV MZ * @plugindesc Allows finer control over the battle actions of enemies. * @author Logan Pickup * * @help * Provides some notetags to allow changing the way the enemies select * skills to use and the targets to use them on. It does this by tweaking * the existing skill ratings in an enemy's action patterns, and allowing * custom tgr (target rating) based on conditions to control target * selection. * * There is no separate list of skills with this plugin; it only modifies * the existing action pattern. * * How To Use * * Add the following to an enemy's "Note" section: * * modifiers... * * For skill, you can either put a skill ID, for example: * * Or you can put the skill name, for example: * * All the modifiers between and will * apply to this enemy's use of the skill. * * There are two kinds of modifiers: changes to "rating", which * affects how often the skill is selected by the enemy, and changes * to "tgr", which affect how the enemy chooses targets for the * skill. * * Rating (changing which skill is selected): * * Changes to rating always follow this pattern: * boost/nerf amount when condition * Use boost if you want to increase the chance of using this * skill, nerf to decrease the chance. Amount is the amount to * change the rating; "boost 2" will increase the rating by 2 * if its conditions happen, "nerf 5" will decrease the rating * by 5 if its conditions happen. Everything following the word * "when" is the condition; the boost or nerf doesn't do anything * unless this condition is true. * * Conditions can either examine a battler, or a switch or variable. * If the condition contains "me", "ally", or "enemy", then it will * trigger if any battlers in the battle match the condition. For * example, "ally.hp below 50%" will trigger if any ally has hp * below 50%. Use "all" to specify that all battlers in that category * must match, not just any one. A "property selector" will usually * follow immediately, and can be any property of battlers; for example: * .hp * .mp * .atk * ...etc. Only hp and mp allow checking against percentages. * Additionally, the following special properties are supported: * .state * Instead of a battler, you can compare switches and variables, * e.g. "variable 2" or "switch 1". * * The battler/switch/variable (the "subject") is then compared * to a value. These are the permitted comparisons: * zero: true if the subject is zero. * low: for hp/mp, true if below 33%. For other values, true if below 3. * high: for hp/mp, true if above 66%. For other values, true if above 5. * max: for hp/mp only, true if at maximum. * below/equal/above/not equal x: true based on comparing the subject to x. * "x" can be a plain number, a variable (e.g. "variable 3"), or a * percentage (for hp/mp only). * is/is not x: used when comparing states or switches. For states, * "x" is a state name or ID, and the condition is true if the * battler is currently affected by (or not affected by, for "is not") * the state. For switches, "x" is either "on" or "off". * is dead/is not dead: this is a special comparison is the only one * that can be used on a battler without selecting a property. * * Some examples: * boost 3 when me.hp low * nerf 10 when all enemy.state is Poisoned * boost 10 when ally is dead * * You can have as many boost/nerf lines as you like, to cover * multiple situations. Boost/nerf only affects which skill is * selected; it does not affect which target is selected. * * These ratings combine with the ratings in the action patterns * box; so if you have a monster with the skill "Heal" and rating 5, * and a boost 6 and a nerf 4 both apply, the final rating will be * 5 + 6 - 4 = 7, which is then used as normal by RPG Maker. * * TGR (changing which target is selected): * * Changing valid targets always has the pattern: * target rate only condition * The word "target" must always appear. * Rate is optional; if not present, it defaults to "2x". This * is the amount that tgr is modified by if the condition is true. * It can be any number followed by "x" for "times"; e.g. if the * rate is "2.5x", then if the condition is true the TGR of the * target will be 2.5 times greater than normal. Generally speaking, * doubling TGR will double the chance that this target is chosen. * "only" is optional; if present, only targets matching the * condition are valid, no other targets will be available for * selection. If absent, targets matching the condition have their * TGR modified but other targets may still be selected following the * normal rules. * Only one of rate or "only" may be present. * * The condition is the same as the condition for changing the skill * selection, except that the battler is always the target. Switches * and variables cannot be the subject of the condition here. Properties * can still be selected, for example the following: * target .hp low * doubles TGR for targets on low HP for this skill. * * There are two special conditions for TGR: lowest and highest. * These two will select the lowest/highest from within the valid * targets, so for example given a skill with scope "1 enemy", if * you have: * target .hp lowest * Then if there are 3 enemies, one on 2hp, one on 5hp, and one * dead with 0hp, then only the enemy with 2hp will have its TGR * boosted. * The dead enemy will not, because "1 enemy" is only allowed to * target living battlers. * * Longer example with comments: * // affects only skill ID 4 for this enemy * boost 2 ally.hp max // we want to choose this more often if everyone on the team is at max health * nerf 5 all enemy.state is Protected // don't use this skill if the enemy is protected * target 3x .hp low // go for the kill; prefer targetting low-hp enemies * */ (function() { const oldDataManager_isDatabaseLoaded = DataManager.isDatabaseLoaded; DataManager.isDatabaseLoaded = function() { let loaded = oldDataManager_isDatabaseLoaded.call(this); if (loaded) { processEnemyNotetags($dataEnemies); } return loaded; }; function findSkillByName(skillName) { let matchingSkills = $dataSkills.filter( skill => skill != null && skill.name.toLowerCase() === skillName.toLowerCase() ); if (matchingSkills.length === 0) { throw new Error("Could not find skill " + skillName); } return matchingSkills[0].id; } function findStateByName(stateName) { let matchingStates = $dataStates.filter( state => state != null && state.name.toLowerCase() === stateName.toLowerCase() ); if (matchingStates.length === 0) { throw new Error("Could not find state " + stateName); } return matchingStates[0].id; } function getEasySkillModifiers(note) { const easySkillModifiers = {}; const notedata = note; const re = /]+))\s*>(.*?)<\/skill-ai>/gms; let match = re.exec(notedata); while (match) { let skillId = (match[1] && parseInt(match[1])) || findSkillByName(match[2]); let script = match[3].split(/[\n\r]+/).filter(a => !/^\s*$/.test(a)); if (easySkillModifiers[skillId]) { easySkillModifiers[skillId] = easySkillModifiers[skillId].concat(script); } else { easySkillModifiers[skillId] = script; } match = re.exec(notedata); } return easySkillModifiers; } function processEnemyNotetags(enemies) { for (var i = 1; i < enemies.length; i++) { var enemy = enemies[i]; if (enemy.note) { enemy.easySkillModifiers = getEasySkillModifiers(enemy.note); } }; } function easyModifierActorPropertyProcessor(prop) { return (actor) => { if (!prop || /^\s*$/.test(prop)) return [actor, undefined]; switch (prop) { case 'hp': return [actor.hp, actor.mhp]; case 'mp': return [actor.mp, actor.mmp]; case 'state': let states = [actor.states().map(s => s.id), undefined]; return states; default: return [actor[prop], undefined]; } } } function easyModifiersLhs(self, expression) { const actorExpression = /\s*(?:(all)\s+)?(ally|enemy|me)(?:\.(hp|mp|state|[a-zA-Z]+))(?:\s+(.*))?/; let evaluator; let rhs; let actorMatch = actorExpression.exec(expression); if (actorMatch) { let all = /all/i.test(actorMatch[1]); let actorType = actorMatch[2]; let actorProperty = actorMatch[3]; let propertyAccessor = easyModifierActorPropertyProcessor(actorProperty); rhs = actorMatch[4]; evaluator = (rhsEvaluator) => { let actors = []; if (/^my|me$/i.test(actorType)) { actors = [self]; } else if (/ally/i.test(actorType)) { actors = self.friendsUnit().members(); } else { actors = self.opponentsUnit().members(); } let [min, max] = actors.reduce((incoming, actor) => { let props = propertyAccessor(actor); let min = incoming[0] === null ? props[0] : Math.min(props[0], incoming[0]); let max = incoming[0] === null ? props[0] : Math.max(props[0], incoming[0]); return [min, max]; }, [null, null]); if (all) { return actors.every(actor => { let [value, maxValue] = propertyAccessor(actor); return rhsEvaluator(value, maxValue, min, max); }); } else { return actors.some(actor => { let [value, maxValue] = propertyAccessor(actor); return rhsEvaluator(value, maxValue, min, max); }); } } } const switchExpression = /switch\s+(\d+)\s+(.*)/; let switchMatch = switchExpression.exec(expression); if (switchMatch) { let switchId = parseInt(switchMatch[1]); rhs = switchMatch[2]; evaluator = (rhsEvaluator) => { let value = $gameSwitches.value(switchId); return rhsEvaluator(value); } } const variableExpression = /variable\s+(\d+)\s+(.*)/; let variableMatch = variableExpression.exec(expression); if (variableMatch) { let variableId = parseInt(variableMatch[1]); rhs = variableMatch[2]; evaluator = (rhsEvaluator) => { let value = $gameVariables.value(variableId); } return rhsEvaluator(value); } return {evaluator, rhs}; } function easyModifiersRhs(rhs) { if (!rhs || rhs.replace(/^\s+|\s+$/g, '') === '') { return (value) => { return !!value; }; } const rhsExpression = /^\s*(zero|lowest|highest|low|high|max)|(below|equals?|not equals?|above)\s+(?:(\d+)|(\d+)%|variable\s+(\d+))\s*$/; let rhsMatch = rhsExpression.exec(rhs); if (rhsMatch) { let namedCategory = rhsMatch[1]; // only valid if a valid MAX value is also available, eg. hp/mhp if (namedCategory) { return (value, valueMax, groupMin, groupMax) => { switch (namedCategory) { case 'zero': return value === 0; case 'low': return valueMax ? value < valueMax / 3 : value < 3; case 'high': return valueMax ? value > 2 * valueMax / 3 : value > 5; case 'max': return value === valueMax; case 'lowest': return value === groupMin; case 'highest': return value === groupMax; } } } let inequality = rhsMatch[2].toLowerCase().replace(/s$/,''); let comparator; switch (inequality) { case 'below': comparator = (a, b) => a < b; break; case 'equal': comparator = (a, b) => a == b; break; case 'not equal': comparator = (a, b) => a != b; break; case 'above': comparator = (a, b) => a > b; break; } let constantNumber = rhsMatch[3]; if (constantNumber) { const parsedNumber = parseInt(constantNumber); return (value) => { return comparator(value, parsedNumber); } } let percentage = rhsMatch[4]; // only valid if a valid MAX value is also available, eg. hp/mhp if (percentage) { const parsedPercentage = parseInt(percentage) / 100; return (value, valueMax) => { return comparator(value / valueMax, parsedPercentage); } } let variableId = rhsMatch[5]; if (variableId) { return (value) => { let variableValue = $gameVariables.value(parseInt(variableId)); return comparator(value, variableValue); } } } const rhsSetExpression = /^\s*is(\s+not)?\s+(?:(\d+)|(on|off)|(.*))\s*$/; let rhsSetMatch = rhsSetExpression.exec(rhs); if (rhsSetMatch) { let not = /not/i.test(rhsSetMatch[1]); let stateId = rhsSetMatch[2]; if (stateId) { return (values) => { if (!values.includes) return (values === parseInt(stateId)) !== not return values.includes(parseInt(stateId)) !== not; }; } let stateName = rhsSetMatch[4]; if (stateName) { if (/^dead$/i.test(stateName)) { return (actor) => { return actor.isDeathStateAffected(); } } let stateId = findStateByName(stateName); return (values) => { if (!values.includes) return (values === stateId) !== not return values.includes(stateId) !== not; }; } let boolean = /on/i.test(rhsSetMatch[3]); return (value) => { return (value === boolean) !== not; }; } console.log("Could not parse RHS expression", rhs); throw new Error("Could not parse RHS expression"); } Game_Enemy.prototype.execEasyModifiersForRating = function(rating, easyModifiers) { for (line of easyModifiers) { const ratingRe = /(boost|nerf)\s+(\d+)\s+when\s+(.*)/i let match = ratingRe.exec(line); if (match) { let type = match[1]; let amount = parseInt(match[2]); if (/nerf/.test(type)) { amount = -amount; } let whenClause = match[3]; let {evaluator, rhs} = easyModifiersLhs(this, whenClause); let result = evaluator(easyModifiersRhs(rhs)); if (result) { rating = rating + amount; } } } return rating; } Game_Enemy.prototype.execEasyModifiersForTgr = function(easyModifiers, availableTargets) { let tgrs = availableTargets.map(target => target.tgr); for (line of easyModifiers) { const targetRe = /target\s+(?:(\d*\.?\d+)x\s+|(only)\s+)?(?:\.([a-zA-Z]+)\s+)?(.*)/i let match = targetRe.exec(line); if (match) { let multiplier = match[1]; let only = !!match[2]; let prop = match[3]; let propertyAccessor = easyModifierActorPropertyProcessor(prop); let rhs = match[4]; evaluator = (targets, tgrs, rhsEvaluator) => { let actors = targets; let [min, max] = actors.reduce((incoming, actor, i) => { let props = propertyAccessor(actor, {tgr: tgrs[i]}); let min = incoming[0] === null ? props[0] : Math.min(props[0], incoming[0]); let max = incoming[0] === null ? props[0] : Math.max(props[0], incoming[0]); return [min, max]; }, [null, null]); for (let i in targets) { let actor = targets[i]; let [value, maxValue] = propertyAccessor(actor, {tgr: tgrs[i]}); let result = rhsEvaluator(value, maxValue, min, max); console.log("Result for target condition " + line + " for target: " + result, actor, {value, maxValue, min, max}); if (result) { if (!only) { multiplier = multiplier || "2"; tgrs[i] *= parseFloat(multiplier); } } else if (only) { tgrs[i] = 0; } } } evaluator(availableTargets, tgrs, easyModifiersRhs(rhs)); } } return tgrs.map((tgr, i) => ({member: availableTargets[i], tgr})); } Game_Enemy.prototype.modifyActionRating = function(action) { let skillId = action.skillId; let rating = action.rating; if (this.enemy().easySkillModifiers && this.enemy().easySkillModifiers[skillId]) { easyModifiers = this.enemy().easySkillModifiers[skillId]; rating = this.execEasyModifiersForRating(action.rating, easyModifiers); } return rating; } const oldGame_Enemy_selectAllActions = Game_Enemy.prototype.selectAllActions; Game_Enemy.prototype.selectAllActions = function(actionList) { const modifiedRatingList = actionList.map( action => { return Object.assign({}, action, {rating: this.modifyActionRating(action)}); }); console.log("Modified action list for " + this.name(), modifiedRatingList); return oldGame_Enemy_selectAllActions.call(this, modifiedRatingList); }; const oldGame_Action_targetsForOpponents = Game_Action.prototype.targetsForOpponents; Game_Action.prototype.targetsForOpponents = function() { var targets = []; if (this.isForRandom()) { for (var i = 0; i < this.numTargets(); i++) { targets.push(this.opponentsUnit().randomTarget(this.makeTgrModifier())); } return targets; } else if (this.isForOne() && this._targetIndex < 0) { targets.push(this.opponentsUnit().randomTarget(this.makeTgrModifier())); return targets; } return oldGame_Action_targetsForOpponents.call(this); }; const oldGame_Action_targetsForFriends = Game_Action.prototype.targetsForFriends Game_Action.prototype.targetsForFriends = function() { if (this.isForOne() && !this.isForDeadFriend() && !this.isForUser()) { return [this.friendsUnit().randomTarget(this.makeTgrModifier())]; } return oldGame_Action_targetsForFriends.call(this); }; Game_Action.prototype.makeTgrModifier = function() { if (!this.isSkill()) return undefined; let skill = this.item(); if (this.subject().friendsUnit() !== $gameTroop) { return undefined; } let gameEnemy = this.subject(); if (!gameEnemy.enemy().easySkillModifiers) return undefined; let skillModifiers = gameEnemy.enemy().easySkillModifiers[skill.id]; if (!skillModifiers) return undefined; return (availableTargets) => { return gameEnemy.execEasyModifiersForTgr( skillModifiers, availableTargets); } } const oldGame_Action_decideRandomTarget = Game_Action.prototype.decideRandomTarget Game_Action.prototype.decideRandomTarget = function() { var target; if (this.isForFriend()) { target = this.friendsUnit().randomTarget(this.makeTgrModifier()); } else if (!this.isForDeadFriend()) { target = this.opponentsUnit().randomTarget(this.makeTgrModifier()); } if (target) { this._targetIndex = target.index(); } else { oldGame_Action_decideRandomTarget.call(this); } }; const oldGame_Unit_randomTarget = Game_Unit.prototype.randomTarget; Game_Unit.prototype.randomTarget = function(tgrModifier) { if (tgrModifier) { let membersWithModifiers = tgrModifier(this.aliveMembers()); console.log("Choosing at random with modified weights", membersWithModifiers); let tgrSum = membersWithModifiers.reduce(function(r, m) { return r + m.tgr; }, 0); let tgrRand = Math.random() * tgrSum; let target = null; membersWithModifiers.forEach(function(m) { tgrRand -= m.tgr; if (tgrRand <= 0 && !target) { target = m.member; } }); return target; } else { return oldGame_Unit_randomTarget.call(this); } }; ////////////////////////////////////////////////////////////// // TESTS ////////////////////////////////////////////////////////////// function easyModifiersRhsTest() { function test(rhsString, comparisonValue, maxValue, expected) { let result = easyModifiersRhs(rhsString)(comparisonValue, maxValue); if (result !== expected) { let formattedValue = maxValue ? (comparisonValue + " out of " + maxValue) : comparisonValue.toString(); console.log("Test failed! [" + formattedValue + " " + rhsString + "] should be " + expected); } } test("below 2", 1, undefined, true); test("below 2", 2, undefined, false); test("above 1", 2, undefined, true); test("above 1", 1, undefined, false); test("equal 1", 1, undefined, true); test("equal 1", 2, undefined, false); test("not equal 2", 1, undefined, true); test("not equal 2", 2, undefined, false); $gameVariables = { value: function(x) { return x; } } test("equal variable 2", 2, undefined, true); test("below 20%", 1, 6, true); test("below 20%", 1, 5, false); test("low", 1, 5, true); test("low", 4, 5, false); test("high", 4, 5, true); test("high", 1, 5, false); test("max", 5, 5, true); test("max", 4, 5, false); test("zero", 0, undefined, true); test("is 1", 1, undefined, true); test("is 1", 2, undefined, false); test("is not 1", 2, undefined, true); test("is not 1", 1, undefined, false); test("is 1", [0, 1, 2], undefined, true); test("is 1", [2, 3], undefined, false); test("is not 1", [2, 3], undefined, true); test("is not 1", [0, 1, 2], undefined, false); test("is on", true, undefined, true); test("is on", false, undefined, false); test("is off", false, undefined, true); test("is off", true, undefined, false); test("is not on", false, undefined, true); test("is not on", true, undefined, false); test("is not off", true, undefined, true); test("is not off", false, undefined, false); $dataStates = [null, {id: 1, name: "test state"}]; test("is test state", [0, 1, 2], undefined, true); test("is test state", [2, 3], undefined, false); test("is not test state", [2, 3], undefined, true); test("is not test state", [0, 1, 2], undefined, false); } easyModifiersRhsTest(); function easyModifiersLhsTest() { function test(rhsString, comparisonValue, maxValue, expected) { let result = easyModifiersRhs(rhsString)(comparisonValue, maxValue); if (result !== expected) { let formattedValue = maxValue ? (comparisonValue + " out of " + maxValue) : comparisonValue.toString(); console.log("Test failed! [" + formattedValue + " " + rhsString + "] should be " + expected); } } let self = { hp: 5, mhp: 8, charge: 1, opponentsUnit: function() { return {members: function() { return [{hp: 2, mhp: 2, charge: 0}, {hp: 12, mhp: 20, charge: 3}]; }} }, friendsUnit: function() { return {members: function() { return [{hp: 1, mhp: 2, charge: 1}, {hp: 10, mhp: 12, charge: 0}]; }} }, }; function testCall(expression, valueSelector, expectedArray) { let {evaluator, rhs} = easyModifiersLhs(self, expression); let calls = []; evaluator((value, valueMax, min, max) => { calls.push({value, valueMax, min, max}); return false; // forces all in sequence to be called }); let result = calls.map(call => call[valueSelector]); if (JSON.stringify(result) !== JSON.stringify(expectedArray)) { console.log("Call sequence of " + valueSelector + " for [" + expression + "] should be " + expectedArray + " but was " + result); } } testCall("ally.hp low", "value", [1, 10]); testCall("ally.hp low", "valueMax", [2, 12]); testCall("ally.hp low", "min", [1, 1]); testCall("ally.hp low", "max", [10, 10]); testCall("me.hp low", "value", [5]); testCall("me.hp low", "valueMax", [8]); testCall("me.hp low", "min", [5]); testCall("me.hp low", "max", [5]); testCall("enemy.charge low", "value", [0, 3]); testCall("enemy.charge low", "valueMax", [undefined, undefined]); testCall("enemy.charge low", "min", [0, 0]); testCall("enemy.charge low", "max", [3, 3]); function integrationTest(expression, expectedResult) { let {evaluator, rhs} = easyModifiersLhs(self, expression); let result = evaluator(easyModifiersRhs(rhs)); if (result !== expectedResult) { console.log("Test failed: [" + expression + "] should be " + expectedResult + ", was " + result); } } integrationTest("enemy.hp low", false); integrationTest("enemy.hp high", true); integrationTest("all enemy.hp high", false); integrationTest("me.charge", true); } easyModifiersLhsTest(); })();