Index: binaries/data/config/default.cfg =================================================================== --- binaries/data/config/default.cfg +++ binaries/data/config/default.cfg @@ -327,21 +327,23 @@ stop = "H" ; Stop the current action backtowork = "Y" ; The unit will go back to work unload = "U" ; Unload garrisoned units when a building/mechanical unit is selected -unloadturrets = "U" ; Unload turreted units. -leaveturret = "U" ; Leave turret point. +unloadturrets = "U" ; Unload turreted units. +leaveturret = "U" ; Leave turret point. move = "" ; Modifier to move to a point instead of another action (e.g. gather) -attack = Ctrl ; Modifier to attack instead of another action (e.g. capture) +attacknocapture = Ctrl ; Modifier to attack instead of another action (e.g. capture) attackmove = Ctrl ; Modifier to attackmove when clicking on a point attackmoveUnit = "Ctrl+Q" ; Modifier to attackmove targeting only units when clicking on a point +attackprojectile = Alt ; Modifier to attack using a projectile (generally long ranged attacks) +attacknoprojectile= "I" ; Modifier to attack without using a projectile (generally short ranged attacks) garrison = Ctrl ; Modifier to garrison when clicking on building -occupyturret = Ctrl ; Modifier to occupy a turret when clicking on a turret holder. +occupyturret = Ctrl ; Modifier to occupy a turret when clicking on a turret holder. autorallypoint = Ctrl ; Modifier to set the rally point on the building itself guard = "G" ; Modifier to escort/guard when clicking on unit/building patrol = "P" ; Modifier to patrol a unit repair = "J" ; Modifier to repair when clicking on building/mechanical unit queue = Shift ; Modifier to queue unit orders instead of replacing pushorderfront = "" ; Modifier to push unit orders to the front instead of replacing. -orderone = Alt ; Modifier to order only one entity in selection. +orderone = "O" ; Modifier to order only one entity in selection. batchtrain = Shift ; Modifier to train units in batches massbarter = Shift ; Modifier to barter bunch of resources masstribute = Shift ; Modifier to tribute bunch of resources @@ -371,7 +373,7 @@ menu.toggle = "F10" ; Toggle in-game menu diplomacy.toggle = "Ctrl+H" ; Toggle in-game diplomacy page barter.toggle = "Ctrl+B" ; Toggle in-game barter/trade page -objectives.toggle = "Ctrl+O" ; Toggle in-game objectives page +objectives.toggle = "Ctrl+N" ; Toggle in-game objectives page tutorial.toggle = "Ctrl+P" ; Toggle in-game tutorial panel [hotkey.session.savedgames] Index: binaries/data/mods/public/art/actors/units/persians/infantry_spearman_c2.xml =================================================================== --- binaries/data/mods/public/art/actors/units/persians/infantry_spearman_c2.xml +++ binaries/data/mods/public/art/actors/units/persians/infantry_spearman_c2.xml @@ -77,6 +77,11 @@ + + + + + player_trans_spec_helmet.xml Index: binaries/data/mods/public/art/textures/cursors/action-attack-projectile.txt =================================================================== --- /dev/null +++ binaries/data/mods/public/art/textures/cursors/action-attack-projectile.txt @@ -0,0 +1 @@ +1 1 Index: binaries/data/mods/public/art/textures/cursors/action-attack.txt =================================================================== --- binaries/data/mods/public/art/textures/cursors/action-attack.txt +++ binaries/data/mods/public/art/textures/cursors/action-attack.txt @@ -1 +0,0 @@ -1 1 Index: binaries/data/mods/public/art/textures/cursors/action-capture.txt =================================================================== --- binaries/data/mods/public/art/textures/cursors/action-capture.txt +++ binaries/data/mods/public/art/textures/cursors/action-capture.txt @@ -1 +0,0 @@ -1 1 Index: binaries/data/mods/public/globalscripts/AttackEffects.js =================================================================== --- binaries/data/mods/public/globalscripts/AttackEffects.js +++ binaries/data/mods/public/globalscripts/AttackEffects.js @@ -24,8 +24,10 @@ this.effectReceivers.push({ "type": data.code, + "cmp": data.cmp, "IID": data.IID, - "method": data.method + "method": data.method, + "getRelativeEffectMethod": data.getRelativeEffectMethod }); } @@ -35,8 +37,10 @@ effectsDataObj[b.type] ); this.effectReceivers.sort(effSort); + this.effectCodes = this.effectReceivers.map(receiver => receiver.type); deepfreeze(this.effectReceivers); + deepfreeze(this.effectCodes); } /** @@ -46,4 +50,20 @@ { return this.effectReceivers; } + + /** + * @return {string[]} - List of the possible effect codes. + */ + Codes() + { + return this.effectCodes; + } + + /** + * @return {Object} - Get the data from the given effect data. + */ + GetReceiverFromCode(type) + { + return this.effectReceivers.find(receiver => receiver.type == type); + } } Index: binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js =================================================================== --- binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js +++ binaries/data/mods/public/globalscripts/tests/test_AttackEffects.js @@ -1,31 +1,50 @@ -let effects = { +const effects = { "eff_A": { "code": "a", "name": "A", "order": "2", + "cmp": "A", "IID": "IID_A", - "method": "doA" + "method": "doA", + "getRelativeEffectMethod": "getA" }, "eff_B": { "code": "b", "name": "B", "order": "1", + "cmp": "B", "IID": "IID_B", - "method": "doB" + "method": "doB", + "getRelativeEffectMethod": "getB" } }; Engine.ListDirectoryFiles = () => Object.keys(effects); Engine.ReadJSONFile = (file) => effects[file]; -let attackEffects = new AttackEffects(); +const attackEffects = new AttackEffects(); TS_ASSERT_UNEVAL_EQUALS(attackEffects.Receivers(), [{ "type": "b", + "cmp": "B", "IID": "IID_B", - "method": "doB" + "method": "doB", + "getRelativeEffectMethod": "getB" }, { "type": "a", + "cmp": "A", "IID": "IID_A", - "method": "doA" + "method": "doA", + "getRelativeEffectMethod": "getA" }]); + +TS_ASSERT_UNEVAL_EQUALS(attackEffects.Codes(), ["b", "a"]); + +TS_ASSERT_UNEVAL_EQUALS(attackEffects.GetReceiverFromCode("b"), { + "type": "b", + "cmp": "B", + "IID": "IID_B", + "method": "doB", + "getRelativeEffectMethod": "getB" +}); +TS_ASSERT_UNEVAL_EQUALS(attackEffects.GetReceiverFromCode("c"), undefined); Index: binaries/data/mods/public/gui/common/tooltips.js =================================================================== --- binaries/data/mods/public/gui/common/tooltips.js +++ binaries/data/mods/public/gui/common/tooltips.js @@ -439,10 +439,6 @@ let tooltips = []; for (let attackType in template.attack) { - // Slaughter is used to kill animals, so do not show it. - if (attackType == "Slaughter") - continue; - let attackTypeTemplate = template.attack[attackType]; let attackLabel = sprintf(headerFont(translate("%(attackType)s")), { "attackType": translateWithContext(attackTypeTemplate.attackName.context || "Name of an attack, usually the weapon.", attackTypeTemplate.attackName.name) Index: binaries/data/mods/public/gui/hotkeys/spec/ingame.json =================================================================== --- binaries/data/mods/public/gui/hotkeys/spec/ingame.json +++ binaries/data/mods/public/gui/hotkeys/spec/ingame.json @@ -43,7 +43,7 @@ "name": "Force move", "desc": "Modifier to move to a point instead of another action (e.g. gather)." }, - "session.attack": { + "session.attacknocapture": { "name": "Force attack", "desc": "Modifier to attack instead of another action (e.g. capture)." }, @@ -55,6 +55,14 @@ "name": "Attack Move (unit only)", "desc": "Modifier to attackmove targeting only units when clicking on a point." }, + "session.attackprojectile": { + "name": "Force projectile", + "desc": "Modifier to attack using a projectile (generally long ranged attacks)." + }, + "session.attacknoprojectile": { + "name": "Forbid projectile", + "desc": "Modifier to attack without using a projectile (generally short ranged attacks)." + }, "session.garrison": { "name": "Garrison", "desc": "Modifier to garrison when clicking on building." Index: binaries/data/mods/public/gui/manual/intro.txt =================================================================== --- binaries/data/mods/public/gui/manual/intro.txt +++ binaries/data/mods/public/gui/manual/intro.txt @@ -113,9 +113,11 @@ hotkey.selection.singleselection – Modifier to select units individually, opposed to per formation. Right Click with a structure(s) selected – Set a rally point for units created/ungarrisoned from that structure hotkey.session.garrison + Right Click with unit(s) selected – Garrison (If the cursor is over an own or allied structure) - hotkey.session.attack + Right Click with unit(s) selected – Attack (instead of capture or gather) + hotkey.session.attacknocapture + Right Click with unit(s) selected – Attack (instead of capture or gather) hotkey.session.attackmove + Right Click with unit(s) selected – Attack move (by default all enemy units and structures along the way are targeted) hotkey.session.attackmoveUnit + Right Click with unit(s) selected – Attack move, only units along the way are targeted + hotkey.session.attackprojectile + attack command – Modifier to attack using a projectile (generally long ranged attacks) + hotkey.session.attacknoprojectile + attack command – Modifier to attack without using a projectile (generally short ranged attacks) hotkey.session.snaptoedges + Mouse Move near structures – Align the new structure with an existing nearby structure hotkey.session.flare + Right Click – Send a flare to your allies Index: binaries/data/mods/public/gui/session/unit_actions.js =================================================================== --- binaries/data/mods/public/gui/session/unit_actions.js +++ binaries/data/mods/public/gui/session/unit_actions.js @@ -104,12 +104,24 @@ else targetClasses = { "attack": ["Unit", "Structure"] }; + const noCaptureHotkeyPressed = Engine.HotkeyIsPressed("session.attacknocapture"); + const projectileHotkeyPressed = Engine.HotkeyIsPressed("session.attackprojectile"); + const noProjectileHotkeyPressed = Engine.HotkeyIsPressed("session.attacknoprojectile"); + Engine.PostNetworkCommand({ "type": "attack-walk", "entities": selection, "x": target.x, "z": target.z, "targetClasses": targetClasses, + "ignoreAttackEffects": { + "ApplyStatus": !noCaptureHotkeyPressed, + "Capture": noCaptureHotkeyPressed, + "Damage": !noCaptureHotkeyPressed + + }, + "projectile": projectileHotkeyPressed && !noProjectileHotkeyPressed ? "required" : + !projectileHotkeyPressed && noProjectileHotkeyPressed ? "disallowed" : null, "queued": queued, "pushFront": pushFront, "formation": g_AutoFormation.getNull() @@ -147,64 +159,28 @@ "specificness": 30, }, - "capture": - { - "execute": function(target, action, selection, queued, pushFront) - { - Engine.PostNetworkCommand({ - "type": "attack", - "entities": selection, - "target": action.target, - "allowCapture": true, - "queued": queued, - "pushFront": pushFront, - "formation": g_AutoFormation.getNull() - }); - - Engine.GuiInterfaceCall("PlaySound", { - "name": "order_attack", - "entity": action.firstAbleEntity - }); - - return true; - }, - "getActionInfo": function(entState, targetState) - { - if (!entState.attack || !targetState || !targetState.capturePoints) - return false; - - return { - "possible": Engine.GuiInterfaceCall("CanAttack", { - "entity": entState.id, - "target": targetState.id, - "types": ["Capture"] - }) - }; - }, - "actionCheck": function(target, selection) - { - let actionInfo = getActionInfo("capture", target, selection); - return actionInfo.possible && { - "type": "capture", - "cursor": "action-capture", - "target": target, - "firstAbleEntity": actionInfo.entity - }; - }, - "specificness": 9, - }, - "attack": { "execute": function(target, action, selection, queued, pushFront) { + const noCaptureHotkeyPressed = Engine.HotkeyIsPressed("session.attacknocapture"); + const projectileHotkeyPressed = Engine.HotkeyIsPressed("session.attackprojectile"); + const noProjectileHotkeyPressed = Engine.HotkeyIsPressed("session.attacknoprojectile"); + Engine.PostNetworkCommand({ "type": "attack", "entities": selection, "target": action.target, + "ignoreAttackEffects": { + "ApplyStatus": !noCaptureHotkeyPressed, + "Capture": noCaptureHotkeyPressed, + "Damage": !noCaptureHotkeyPressed + + }, + "projectile": projectileHotkeyPressed && !noProjectileHotkeyPressed ? "required" : + !projectileHotkeyPressed && noProjectileHotkeyPressed ? "disallowed" : null, "queued": queued, "pushFront": pushFront, - "allowCapture": false, "formation": g_AutoFormation.getNull() }); @@ -217,33 +193,37 @@ }, "getActionInfo": function(entState, targetState) { - if (!entState.attack || !targetState || !targetState.hitpoints) + if (!entState.attack || !targetState) return false; return { "possible": Engine.GuiInterfaceCall("CanAttack", { "entity": entState.id, - "target": targetState.id, - "types": ["!Capture"] + "target": targetState.id }) }; }, - "hotkeyActionCheck": function(target, selection) - { - return Engine.HotkeyIsPressed("session.attack") && - this.actionCheck(target, selection); - }, "actionCheck": function(target, selection) { let actionInfo = getActionInfo("attack", target, selection); + if (!actionInfo.possible) + return false; + return actionInfo.possible && { "type": "attack", - "cursor": "action-attack", + "cursor": getAttackCursor(actionInfo.entity, target), "target": target, "firstAbleEntity": actionInfo.entity }; }, - "specificness": 10, + "hotkeyActionCheck": function(target, selection) + { + return (Engine.HotkeyIsPressed("session.attacknocapture") || + Engine.HotkeyIsPressed("session.attackprojectile") || + Engine.HotkeyIsPressed("session.attacknoprojectile")) && + this.actionCheck(target, selection); + }, + "specificness": 9, }, "call-to-arms": { @@ -254,14 +234,26 @@ targetClasses = { "attack": ["Unit"] }; else targetClasses = { "attack": ["Unit", "Structure"] }; + + const noCaptureHotkeyPressed = Engine.HotkeyIsPressed("session.attacknocapture"); + const projectileHotkeyPressed = Engine.HotkeyIsPressed("session.attackprojectile"); + const noProjectileHotkeyPressed = Engine.HotkeyIsPressed("session.attacknoprojectile"); + Engine.PostNetworkCommand({ "type": "call-to-arms", "entities": selection, "target": target, "targetClasses": targetClasses, + "ignoreAttackEffects": { + "ApplyStatus": !noCaptureHotkeyPressed, + "Capture": noCaptureHotkeyPressed, + "Damage": !noCaptureHotkeyPressed + + }, + "projectile": projectileHotkeyPressed && !noProjectileHotkeyPressed ? "required" : + !projectileHotkeyPressed && noProjectileHotkeyPressed ? "disallowed" : null, "queued": queued, "pushFront": pushFront, - "allowCapture": true, "formation": g_AutoFormation.getNull() }); return true; @@ -275,7 +267,10 @@ const actionInfo = getActionInfo("call-to-arms", target, selection); return actionInfo.possible && { "type": "call-to-arms", - "cursor": "action-attack", + "cursor": typeof target == "number" && Engine.GuiInterfaceCall("CanAttack", { + "entity": actionInfo.entity, + "target": target + }) ? getAttackCursor(actionInfo.entity, target) : "action-attack-move", "target": target, "firstAbleEntity": actionInfo.entity }; @@ -297,6 +292,10 @@ { "execute": function(target, action, selection, queued, pushFront) { + const noCaptureHotkeyPressed = Engine.HotkeyIsPressed("session.attacknocapture"); + const projectileHotkeyPressed = Engine.HotkeyIsPressed("session.attackprojectile"); + const noProjectileHotkeyPressed = Engine.HotkeyIsPressed("session.attacknoprojectile"); + Engine.PostNetworkCommand({ "type": "patrol", "entities": selection, @@ -304,8 +303,15 @@ "z": target.z, "target": action.target, "targetClasses": { "attack": g_PatrolTargets }, + "ignoreAttackEffects": { + "ApplyStatus": !noCaptureHotkeyPressed, + "Capture": noCaptureHotkeyPressed, + "Damage": !noCaptureHotkeyPressed + + }, + "projectile": projectileHotkeyPressed && !noProjectileHotkeyPressed ? "required" : + !projectileHotkeyPressed && noProjectileHotkeyPressed ? "disallowed" : null, "queued": queued, - "allowCapture": false, "formation": g_AutoFormation.getDefault() }); @@ -1080,6 +1086,18 @@ data.command = "attack-walk"; data.targetClasses = targetClasses; + + const noCaptureHotkeyPressed = Engine.HotkeyIsPressed("session.attacknocapture"); + const projectileHotkeyPressed = Engine.HotkeyIsPressed("session.attackprojectile"); + const noProjectileHotkeyPressed = Engine.HotkeyIsPressed("session.attacknoprojectile"); + data.ignoreAttackEffects = { + "ApplyStatus": !noCaptureHotkeyPressed, + "Capture": noCaptureHotkeyPressed, + "Damage": !noCaptureHotkeyPressed + + }; + data.projectile = projectileHotkeyPressed && !noProjectileHotkeyPressed ? "required" : + !projectileHotkeyPressed && noProjectileHotkeyPressed ? "disallowed" : null; cursor = "action-attack-move"; } @@ -1198,7 +1216,28 @@ { data.target = targetState.id; data.command = "attack"; - cursor = "action-attack"; + + const noCaptureHotkeyPressed = Engine.HotkeyIsPressed("session.attacknocapture"); + const projectileHotkeyPressed = Engine.HotkeyIsPressed("session.attackprojectile"); + const noProjectileHotkeyPressed = Engine.HotkeyIsPressed("session.attacknoprojectile"); + data.ignoreAttackEffects = { + "ApplyStatus": !noCaptureHotkeyPressed, + "Capture": noCaptureHotkeyPressed, + "Damage": !noCaptureHotkeyPressed + + }; + data.projectile = projectileHotkeyPressed && !noProjectileHotkeyPressed ? "required" : + !projectileHotkeyPressed && noProjectileHotkeyPressed ? "disallowed" : null; + + // We don't check for canAttack here since we cannot know what entities will use the rallypoint. + if (!noCaptureHotkeyPressed) + cursor = "action-attack-capture"; + + else if (!noProjectileHotkeyPressed && projectileHotkeyPressed) + cursor = "action-attack-projectile"; + else + // TODO: In case there is no projectile preference we might want another cursor. Needs a new cursor and some code here. + cursor = "action-attack-noprojectile"; } return { @@ -1913,6 +1952,26 @@ g_MiniMapPanel.flare(target, playerID); } +function getAttackCursor(ent, target) +{ + if (!Engine.HotkeyIsPressed("session.attacknocapture") && Engine.GuiInterfaceCall("CanAttack", { + "entity": ent, + "target": target, + "ignoreAttackEffects": { "Damage": true, "ApplyStatus": true } + })) + return "action-attack-capture"; + + if (!Engine.HotkeyIsPressed("session.attacknoprojectile") && Engine.HotkeyIsPressed("session.attackprojectile") && Engine.GuiInterfaceCall("CanAttack", { + "entity": ent, + "target": target, + "projectile": "required" + })) + return "action-attack-projectile"; + + // TODO: In case there is no projectile preference we might want another cursor. Needs a new cursor and some code here. + return "action-attack-noprojectile"; +} + function getCommandInfo(command, entStates) { return entStates && g_EntityCommands[command] && Index: binaries/data/mods/public/maps/random/danubius_triggers.js =================================================================== --- binaries/data/mods/public/maps/random/danubius_triggers.js +++ binaries/data/mods/public/maps/random/danubius_triggers.js @@ -436,8 +436,10 @@ "type": "attack", "entities": attackers, "target": closestTarget, - "queued": true, - "allowCapture": false + "ignoreAttackEffects": { + "Capture": true + }, + "queued": true }); let patrolTargets = shuffleArray(this.GetTriggerPoints(triggerPointRef)).slice(0, patrolCount); @@ -454,8 +456,10 @@ "targetClasses": { "attack": targetClass }, - "queued": true, - "allowCapture": false + "ignoreAttackEffects": { + "Capture": true + }, + "queued": true }); } }; Index: binaries/data/mods/public/maps/random/jebel_barkal_triggers.js =================================================================== --- binaries/data/mods/public/maps/random/jebel_barkal_triggers.js +++ binaries/data/mods/public/maps/random/jebel_barkal_triggers.js @@ -476,8 +476,10 @@ "targetClasses": { "attack": jebelBarkal_cityPatrolGroup_balancing.targetClasses() }, - "queued": true, - "allowCapture": false + "ignoreAttackEffects": { + "Capture": true + }, + "queued": true }); } } @@ -578,8 +580,10 @@ "targetClasses": { "attack": spawnPointBalancing.targetClasses() }, - "queued": true, - "allowCapture": false + "ignoreAttackEffects": { + "Capture": true + }, + "queued": true }); } } Index: binaries/data/mods/public/maps/random/polar_sea_triggers.js =================================================================== --- binaries/data/mods/public/maps/random/polar_sea_triggers.js +++ binaries/data/mods/public/maps/random/polar_sea_triggers.js @@ -98,6 +98,9 @@ "type": "attack", "entities": attackers[spawnPoint], "target": target, + "ignoreAttackEffects": { + "Capture": true + }, "queued": true }); } Index: binaries/data/mods/public/maps/random/survivalofthefittest_triggers.js =================================================================== --- binaries/data/mods/public/maps/random/survivalofthefittest_triggers.js +++ binaries/data/mods/public/maps/random/survivalofthefittest_triggers.js @@ -251,7 +251,9 @@ "x": targetPos.x, "z": targetPos.y, "targetClasses": undefined, - "allowCapture": false, + "ignoreAttackEffects": { + "Capture": true + }, "queued": true }); Index: binaries/data/mods/public/maps/skirmishes/gallic_fields_3p.js =================================================================== --- binaries/data/mods/public/maps/skirmishes/gallic_fields_3p.js +++ binaries/data/mods/public/maps/skirmishes/gallic_fields_3p.js @@ -23,7 +23,7 @@ cmd.type = "attack-walk"; cmd.entities = intruders[origin]; cmd.targetClasses = { "attack": ["Unit", "Structure"] }; - cmd.allowCapture = false; + cmd.ignoreAttackEffects = { "Capture": true }; cmd.queued = true; ProcessCommand(0, cmd); } Index: binaries/data/mods/public/maps/tutorials/introductory_tutorial.js =================================================================== --- binaries/data/mods/public/maps/tutorials/introductory_tutorial.js +++ binaries/data/mods/public/maps/tutorials/introductory_tutorial.js @@ -412,7 +412,9 @@ "x": position.x, "z": position.y, "targetClasses": { "attack": ["Unit"] }, - "allowCapture": false, + "ignoreAttackEffects": { + "Capture": true + }, "queued": false }); }; Index: binaries/data/mods/public/simulation/ai/common-api/attackeffects.js =================================================================== --- /dev/null +++ binaries/data/mods/public/simulation/ai/common-api/attackeffects.js @@ -0,0 +1 @@ +AttackEffects = new AttackEffects(); Index: binaries/data/mods/public/simulation/ai/common-api/entity.js =================================================================== --- binaries/data/mods/public/simulation/ai/common-api/entity.js +++ binaries/data/mods/public/simulation/ai/common-api/entity.js @@ -237,23 +237,30 @@ }; }, - "attackStrengths": function(type) { - let attackDamageTypes = this.get("Attack/" + type + "/Damage"); - if (!attackDamageTypes) - return undefined; + "attackStrengths": function(type, againstClassList = [], civ = undefined) { + // TODO: StatusEffect. + let strengths = {}; + + const multiplier = this.getMultiplierAgainst(type, againstClassList, civ); + const attackStrengthsDamage = this.get("Attack/" + type + "/Damage"); + if (attackStrengthsDamage) + { + strengths.Damage = {}; + for (let damageType in attackStrengthsDamage) + strengths.Damage[damageType] = +attackStrengthsDamage[damageType] * multiplier; + } - let damage = {}; - for (let damageType in attackDamageTypes) - damage[damageType] = +attackDamageTypes[damageType]; + strengths.Capture = +this.get("Attack/" + type + "/Capture") * multiplier || 0; - return damage; + return strengths; }, - "captureStrength": function() { - if (!this.get("Attack/Capture")) - return undefined; + "captureStrength": function(againstClassList = [], civ = undefined) { + let strength = 0; + for (let type in this.get("Attack")) + strength = Math.max(strength, +(this.get("Attack/"+ type + "/Capture") || 0) * this.getMultiplierAgainst(type, againstClassList, civ)); - return +this.get("Attack/Capture/Capture") || 0; + return strength; }, "attackTimes": function(type) { @@ -311,25 +318,25 @@ return target.hasClasses(mcounter); }, - // returns, if it exists, the multiplier from each attack against a given class - "getMultiplierAgainst": function(type, againstClass) { - if (!this.get("Attack/" + type +"")) - return undefined; + // Returns the multiplier from each attack against a given class and civ. + "getMultiplierAgainst": function(type, againstClassList, civ) { + let multiplier = 1; + if (!this.get("Attack/" + type)) + return multiplier; - let bonuses = this.get("Attack/" + type + "/Bonuses"); + const bonuses = this.get("Attack/" + type + "/Bonuses"); if (bonuses) - { - for (let b in bonuses) + for (const bonus in bonuses) { - let bonusClasses = this.get("Attack/" + type + "/Bonuses/" + b + "/Classes"); - if (!bonusClasses) + if (civ && bonus.Civ && civ != bonus.Civ) continue; - for (let bcl of bonusClasses.split(" ")) - if (bcl == againstClass) - return +this.get("Attack/" + type + "/Bonuses/" + b + "/Multiplier"); + + const bonusClasses = this.get("Attack/" + type + "/Bonuses/" + bonus + "/Classes"); + if (MatchesClassList(againstClassList, this.get("Attack/" + type + "/Bonuses/" + bonus + "/Classes"))) + multiplier *= +this.get("Attack/" + type + "/Bonuses/" + bonus + "/Multiplier"); } - } - return 1; + + return multiplier; }, "buildableEntities": function(civ) { @@ -542,19 +549,28 @@ "isTurretHolder": function() { return this.get("TurretHolder") !== undefined; }, /** - * returns true if the tempalte can capture the given target entity + * returns true if the template can capture the given target entity * if no target is given, returns true if the template has the Capture attack */ "canCapture": function(target) { - if (!this.get("Attack/Capture")) + const attack = this.get("Attack") + if (!attack) + return false; + const captureTypes = Object.keys(attack).filter(type => !this.get("Attack/" + type + "/Capture")) + if (!captureTypes.length) return false; if (!target) return true; if (!target.get("Capturable")) return false; - let restrictedClasses = this.get("Attack/Capture/RestrictedClasses/_string"); - return !restrictedClasses || !target.hasClasses(restrictedClasses); + for (const type of captureTypes) + { + const restrictedClasses = this.get("Attack/" + type + "/RestrictedClasses/_string"); + if (!restrictedClasses || !target.hasClasses(restrictedClasses)) + return true; + } + return false; }, "isCapturable": function() { return this.get("Capturable") !== undefined; }, @@ -761,8 +777,6 @@ for (let type in attack) { - if (type == "Slaughter") - continue; let restrictedClasses = this.get("Attack/" + type + "/RestrictedClasses/_string"); if (!restrictedClasses || !MatchesClassList([aClass], restrictedClasses)) return true; @@ -772,25 +786,34 @@ /** * Derived from Attack.js' similary named function. + * TODO: more general wantedTypes, see attack.js. * @return {boolean} - Whether an entity can attack a given target. */ - "canAttackTarget": function(target, allowCapture) + "canAttackTarget": function(target, mustBeInRange = false, ignoreAttackEffects = {}, wantedTypes = [], projectile = undefined) { - let attackTypes = this.get("Attack"); - if (!attackTypes) + if (target.isInvulnerable()) return false; - let canCapture = allowCapture && this.canCapture(target); - let health = target.get("Health"); - if (!health) - return canCapture; + let types = Object.keys(this.get("Attack")); + if (wantedTypes.length) + types = types.filter(type => wantedTypes.includes(type)); - for (let type in attackTypes) + // TODO: Care about the range. + for (const type of types) { - if (type == "Capture" ? !canCapture : target.isInvulnerable()) + const attackStrengths = this.attackStrengths(type); + if (Object.keys(attackStrengths).every(attackEffect => + !attackStrengths[attackEffect] || + // TODO: Storing and getting the component name from the json shouldn't be done. + !target.get(AttackEffects.GetReceiverFromCode(attackEffect).cmp) || + ignoreAttackEffects[attackEffect])) continue; - let restrictedClasses = this.get("Attack/" + type + "/RestrictedClasses/_string"); + const templateProjectile = this.get("Attack/" + type + "/Projectile"); + if ((projectile == "required" && !templateProjectile) || (projectile == "disallowed" && templateProjectile)) + continue; + + const restrictedClasses = this.get("Attack/" + type + "/RestrictedClasses/_string"); if (!restrictedClasses || !target.hasClasses(restrictedClasses)) return true; } @@ -808,8 +831,19 @@ return this; }, - "attackMove": function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false) { - Engine.PostCommand(PlayerID, { "type": "attack-walk", "entities": [this.id()], "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "queued": queued, "pushFront": pushFront }); + "attackMove": function(x, z, targetClasses, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = null, queued = false, pushFront = false) { + Engine.PostCommand(PlayerID, { + "type": "attack-walk", + "entities": [this.id()], + "x": x, + "z": z, + "targetClasses": targetClasses, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile, + "queued": queued, + "pushFront": pushFront + }); return this; }, @@ -850,8 +884,17 @@ return this; }, - "attack": function(unitId, allowCapture = true, queued = false, pushFront = false) { - Engine.PostCommand(PlayerID, { "type": "attack", "entities": [this.id()], "target": unitId, "allowCapture": allowCapture, "queued": queued, "pushFront": pushFront }); + "attack": function(unitId, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = null, queued = false, pushFront = false) { + Engine.PostCommand(PlayerID, { + "type": "attack", + "entities": [this.id()], + "target": unitId, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile, + "queued": queued, + "pushFront": pushFront + }); return this; }, @@ -925,7 +968,7 @@ }, "tradeRoute": function(target, source) { - Engine.PostCommand(PlayerID, { "type": "setup-trade-route", "entities": [this.id()], "target": target.id(), "source": source.id(), "route": undefined, "queued": false, "pushFront": false }); + Engine.PostCommand(PlayerID, { "type": "setup-trade-route", "entities": [this.id()], "target": target.id(), "source": source.id(), "route": null, "queued": false, "pushFront": false }); return this; }, Index: binaries/data/mods/public/simulation/ai/common-api/entitycollection.js =================================================================== --- binaries/data/mods/public/simulation/ai/common-api/entitycollection.js +++ binaries/data/mods/public/simulation/ai/common-api/entitycollection.js @@ -170,7 +170,7 @@ return this; }; -m.EntityCollection.prototype.attackMove = function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false) +m.EntityCollection.prototype.attackMove = function(x, z, targetClasses, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = null, queued = false, pushFront = false) { Engine.PostCommand(PlayerID, { "type": "attack-walk", @@ -178,7 +178,9 @@ "x": x, "z": z, "targetClasses": targetClasses, - "allowCapture": allowCapture, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile, "queued": queued, "pushFront": pushFront }); @@ -229,12 +231,15 @@ return this; }; -m.EntityCollection.prototype.attack = function(unitId, queued = false, pushFront = false) +m.EntityCollection.prototype.attack = function(unitId, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = null, queued = false, pushFront = false) { Engine.PostCommand(PlayerID, { "type": "attack", "entities": this.toIdArray(), "target": unitId, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile, "queued": queued, "pushFront": pushFront }); Index: binaries/data/mods/public/simulation/ai/common-api/filters.js =================================================================== --- binaries/data/mods/public/simulation/ai/common-api/filters.js +++ binaries/data/mods/public/simulation/ai/common-api/filters.js @@ -77,8 +77,8 @@ "dynamicProperties": [] }), - "byCanAttackTarget": target => ({ - "func": ent => ent.canAttackTarget(target), + "byCanAttackTarget": (target, mustBeInRange = false, ignoreAttackEffects = {}, wantedTypes = [], projectile = undefined) => ({ + "func": ent => ent.canAttackTarget(target, mustBeInRange, ignoreAttackEffects, wantedTypes, projectile), "dynamicProperties": [] }), Index: binaries/data/mods/public/simulation/ai/petra/attackManager.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/attackManager.js +++ binaries/data/mods/public/simulation/ai/petra/attackManager.js @@ -180,7 +180,7 @@ let access = PETRA.getLandAccess(gameState, ent); for (let struct of gameState.getEnemyStructures().values()) { - if (!ent.canAttackTarget(struct, PETRA.allowCapture(gameState, ent, struct))) + if (!ent.canAttackTarget(struct, false, PETRA.ignoreAttackEffects(gameState, ent, struct))) continue; let structPos = struct.position(); @@ -225,7 +225,7 @@ attackingUnits.add(ent.id()); if (dist > range) ent.move(x, z); - ent.attack(struct.id(), false, dist > range); + ent.attack(struct.id(), { "Capture": true }, [], undefined, dist > range); break; } } Index: binaries/data/mods/public/simulation/ai/petra/attackPlan.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/attackPlan.js +++ binaries/data/mods/public/simulation/ai/petra/attackPlan.js @@ -1338,16 +1338,17 @@ if (PETRA.isSiegeUnit(ent)) // needed as mauryan elephants are not filtered out continue; - let allowCapture = PETRA.allowCapture(gameState, ent, attacker); - if (!ent.canAttackTarget(attacker, allowCapture)) + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ent, attacker); + if (!ent.canAttackTarget(attacker, false, ignoreAttackEffects)) continue; - ent.attack(attacker.id(), allowCapture); + ent.attack(attacker.id(), ignoreAttackEffects); ent.setMetadata(PlayerID, "lastAttackPlanUpdateTime", time); } + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ourUnit, attacker); // And if this attacker is a non-ranged siege unit and our unit also, attack it - if (PETRA.isSiegeUnit(attacker) && attacker.hasClass("Melee") && ourUnit.hasClass("Melee") && ourUnit.canAttackTarget(attacker, PETRA.allowCapture(gameState, ourUnit, attacker))) + if (PETRA.isSiegeUnit(attacker) && attacker.hasClass("Melee") && ourUnit.hasClass("Melee") && ourUnit.canAttackTarget(attacker, false, ignoreAttackEffects)) { - ourUnit.attack(attacker.id(), PETRA.allowCapture(gameState, ourUnit, attacker)); + ourUnit.attack(attacker.id(), ignoreAttackEffects); ourUnit.setMetadata(PlayerID, "lastAttackPlanUpdateTime", time); } } @@ -1364,10 +1365,10 @@ let collec = this.unitCollection.filter(API3.Filters.byClass("Melee")).filterNearest(ourUnit.position(), 5); for (let ent of collec.values()) { - let allowCapture = PETRA.allowCapture(gameState, ent, attacker); - if (!ent.canAttackTarget(attacker, allowCapture)) + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ent, attacker); + if (!ent.canAttackTarget(attacker, false, ignoreAttackEffects)) continue; - ent.attack(attacker.id(), allowCapture); + ent.attack(attacker.id(), ignoreAttackEffects); ent.setMetadata(PlayerID, "lastAttackPlanUpdateTime", time); } } @@ -1377,8 +1378,8 @@ let collec = this.unitCollection.filterNearest(ourUnit.position(), 2); for (let ent of collec.values()) { - let allowCapture = PETRA.allowCapture(gameState, ent, attacker); - if (PETRA.isSiegeUnit(ent) || !ent.canAttackTarget(attacker, allowCapture)) + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ent, attacker); + if (PETRA.isSiegeUnit(ent) || !ent.canAttackTarget(attacker, false, ignoreAttackEffects)) continue; let orderData = ent.unitAIOrderData(); if (orderData && orderData.length && orderData[0].target) @@ -1389,7 +1390,7 @@ if (target && !target.hasClasses(["Structure", "Support"])) continue; } - ent.attack(attacker.id(), allowCapture); + ent.attack(attacker.id(), ignoreAttackEffects); ent.setMetadata(PlayerID, "lastAttackPlanUpdateTime", time); } // Then the unit under attack: abandon its target (if it was a structure or a support) and retaliate @@ -1406,10 +1407,10 @@ continue; } } - let allowCapture = PETRA.allowCapture(gameState, ourUnit, attacker); - if (ourUnit.canAttackTarget(attacker, allowCapture)) + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ourUnit, attacker); + if (ourUnit.canAttackTarget(attacker, false, ignoreAttackEffects)) { - ourUnit.attack(attacker.id(), allowCapture); + ourUnit.attack(attacker.id(), ignoreAttackEffects); ourUnit.setMetadata(PlayerID, "lastAttackPlanUpdateTime", time); } } @@ -1562,7 +1563,7 @@ if (siegeUnit) { let mStruct = enemyStructures.filter(enemy => { - if (!enemy.position() || !ent.canAttackTarget(enemy, PETRA.allowCapture(gameState, ent, enemy))) + if (!enemy.position() || !ent.canAttackTarget(enemy, false, PETRA.ignoreAttackEffects(gameState, ent, enemy))) return false; if (API3.SquareVectorDistance(enemy.position(), ent.position()) > range) return false; @@ -1592,11 +1593,11 @@ return valb - vala; }); if (mStruct[0].hasClass("Gate")) - ent.attack(mStruct[0].id(), PETRA.allowCapture(gameState, ent, mStruct[0])); + ent.attack(mStruct[0].id(), PETRA.ignoreAttackEffects(gameState, ent, mStruct[0])); else { let rand = randIntExclusive(0, mStruct.length * 0.2); - ent.attack(mStruct[rand].id(), PETRA.allowCapture(gameState, ent, mStruct[rand])); + ent.attack(mStruct[rand].id(), PETRA.ignoreAttackEffects(gameState, ent, mStruct[rand])); } } else @@ -1614,7 +1615,7 @@ { const nearby = !ent.hasClasses(["FastMoving", "Ranged"]); let mUnit = enemyUnits.filter(enemy => { - if (!enemy.position() || !ent.canAttackTarget(enemy, PETRA.allowCapture(gameState, ent, enemy))) + if (!enemy.position() || !ent.canAttackTarget(enemy, false, PETRA.ignoreAttackEffects(gameState, ent, enemy))) return false; if (enemy.hasClass("Animal")) return false; @@ -1654,12 +1655,12 @@ return valb - vala; }); let rand = randIntExclusive(0, mUnit.length * 0.1); - ent.attack(mUnit[rand].id(), PETRA.allowCapture(gameState, ent, mUnit[rand])); + ent.attack(mUnit[rand].id(), PETRA.ignoreAttackEffects(gameState, ent, mUnit[rand])); } // This may prove dangerous as we may be blocked by something we // cannot attack. See similar behaviour at #5741. - else if (this.isBlocked && ent.canAttackTarget(this.target, false)) - ent.attack(this.target.id(), false); + else if (this.isBlocked && ent.canAttackTarget(this.target, false, { "Capture": true })) + ent.attack(this.target.id(), { "Capture": true }); else if (API3.SquareVectorDistance(this.targetPos, ent.position()) > 2500) { let targetClasses = targetClassesUnit; @@ -1679,7 +1680,7 @@ let mStruct = enemyStructures.filter(enemy => { if (this.isBlocked && enemy.id() != this.target.id()) return false; - if (!enemy.position() || !ent.canAttackTarget(enemy, PETRA.allowCapture(gameState, ent, enemy))) + if (!enemy.position() || !ent.canAttackTarget(enemy, false, PETRA.ignoreAttackEffects(gameState, ent, enemy))) return false; if (API3.SquareVectorDistance(enemy.position(), ent.position()) > range) return false; @@ -1703,11 +1704,11 @@ return valb - vala; }); if (mStruct[0].hasClass("Gate")) - ent.attack(mStruct[0].id(), false); + ent.attack(mStruct[0].id(), { "Capture": true }); else { let rand = randIntExclusive(0, mStruct.length * 0.2); - ent.attack(mStruct[rand].id(), PETRA.allowCapture(gameState, ent, mStruct[rand])); + ent.attack(mStruct[rand].id(), PETRA.ignoreAttackEffects(gameState, ent, mStruct[rand])); } } else if (needsUpdate) // really nothing let's try to help our nearest unit @@ -1727,12 +1728,12 @@ if (dist > distmin) return; distmin = dist; - if (!ent.canAttackTarget(target, PETRA.allowCapture(gameState, ent, target))) + if (!ent.canAttackTarget(target, false, PETRA.ignoreAttackEffects(gameState, ent, target))) return; attacker = target; }); if (attacker) - ent.attack(attacker.id(), PETRA.allowCapture(gameState, ent, attacker)); + ent.attack(attacker.id(), PETRA.ignoreAttackEffects(gameState, ent, attacker)); } } } @@ -1784,10 +1785,10 @@ if (ent.getMetadata(PlayerID, "transport") !== undefined) continue; - let allowCapture = PETRA.allowCapture(gameState, ent, attacker); - if (!ent.isIdle() || !ent.canAttackTarget(attacker, allowCapture)) + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ent, attacker); + if (!ent.isIdle() || !ent.canAttackTarget(attacker, false, ignoreAttackEffects)) continue; - ent.attack(attacker.id(), allowCapture); + ent.attack(attacker.id(), ignoreAttackEffects); } break; } Index: binaries/data/mods/public/simulation/ai/petra/defenseArmy.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/defenseArmy.js +++ binaries/data/mods/public/simulation/ai/petra/defenseArmy.js @@ -312,7 +312,7 @@ if (!eEnt || !eEnt.position()) // probably can't happen. continue; - if (!ent.canAttackTarget(eEnt, PETRA.allowCapture(gameState, ent, eEnt))) + if (!ent.canAttackTarget(eEnt, false, PETRA.ignoreAttackEffects(gameState, ent, eEnt))) continue; if (eEnt.hasClass("Unit") && eEnt.unitAIOrderData() && eEnt.unitAIOrderData().length && @@ -358,7 +358,7 @@ { this.assignedTo[entID] = idFoe; this.assignedAgainst[idFoe].push(entID); - ent.attack(idFoe, PETRA.allowCapture(gameState, ent, foeEnt), queued); + ent.attack(idFoe, PETRA.ignoreAttackEffects(gameState, ent, foeEnt), [], undefined, queued); } else gameState.ai.HQ.navalManager.requireTransport(gameState, ent, ownIndex, foeIndex, foePosition); @@ -571,11 +571,15 @@ let orderData = ent.unitAIOrderData(); if (!orderData.length && !ent.getMetadata(PlayerID, "transport")) this.assignUnit(gameState, entId); - else if (orderData.length && orderData[0].target && orderData[0].attackType && orderData[0].attackType === "Capture") + else if (orderData.length && orderData[0].target && orderData[0].attackType) { - let target = gameState.getEntityById(orderData[0].target); - if (target && !PETRA.allowCapture(gameState, ent, target)) - ent.attack(orderData[0].target, false); + const target = gameState.getEntityById(orderData[0].target); + if (!target) + continue; + + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ent, target); + if (!ent.canAttackTarget(target, false, ignoreAttackEffects, [orderData[0].attackType])) + ent.attack(orderData[0].target, ignoreAttackEffects); } } Index: binaries/data/mods/public/simulation/ai/petra/defenseManager.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/defenseManager.js +++ binaries/data/mods/public/simulation/ai/petra/defenseManager.js @@ -457,7 +457,7 @@ // Do not assign defender if it cannot attack at least part of the attacking army. if (!armiesNeeding[a].army.foeEntities.some(eEnt => { let eEntID = gameState.getEntityById(eEnt); - return ent.canAttackTarget(eEntID, PETRA.allowCapture(gameState, ent, eEntID)); + return ent.canAttackTarget(eEntID, false, PETRA.ignoreAttackEffects(gameState, ent, eEntID)); })) continue; @@ -702,7 +702,7 @@ if (allAttacked[entId]) continue; let ent = gameState.getEntityById(entId); - if (!ent || !ent.position() || !ent.canAttackTarget(attacker, PETRA.allowCapture(gameState, ent, attacker))) + if (!ent || !ent.position() || !ent.canAttackTarget(attacker, false, PETRA.ignoreAttackEffects(gameState, ent, attacker))) continue; // Check that the unit is still attacking the structure (since the last played turn). let state = ent.unitAIState(); @@ -721,13 +721,13 @@ if (minEnt) { capturableTarget.ents.delete(minEnt.id()); - minEnt.attack(attacker.id(), PETRA.allowCapture(gameState, minEnt, attacker)); + minEnt.attack(attacker.id(), PETRA.ignoreAttackEffects(gameState, minEnt, attacker)); } } } - let allowCapture = PETRA.allowCapture(gameState, target, attacker); - if (target.canAttackTarget(attacker, allowCapture)) - target.attack(attacker.id(), allowCapture); + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, target, attacker); + if (target.canAttackTarget(attacker, false, ignoreAttackEffects)) + target.attack(attacker.id(), ignoreAttackEffects); } } }; Index: binaries/data/mods/public/simulation/ai/petra/entityExtend.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/entityExtend.js +++ binaries/data/mods/public/simulation/ai/petra/entityExtend.js @@ -12,7 +12,7 @@ }; /** returns some sort of DPS * health factor. If you specify a class, it'll use the modifiers against that class too. */ -PETRA.getMaxStrength = function(ent, debugLevel, DamageTypeImportance, againstClass) +PETRA.getMaxStrength = function(ent, debugLevel, DamageTypeImportance, againstClassList = [], civ = undefined) { let strength = 0; let attackTypes = ent.attackTypes(); @@ -22,21 +22,22 @@ for (let type of attackTypes) { + // TODO: Nuke this check in a reasonable fashion. if (type == "Slaughter") continue; - let attackStrength = ent.attackStrengths(type); - for (let str in attackStrength) + const attackStrengths = ent.attackStrengths(type, againstClassList, civ); + for (const damageType in attackStrengths.Damage) { - let val = parseFloat(attackStrength[str]); - if (againstClass) - val *= ent.getMultiplierAgainst(type, againstClass); - if (DamageTypeImportance[str]) - strength += DamageTypeImportance[str] * val / damageTypes.length; + let val = parseFloat(attackStrengths.Damage[damageType]); + if (DamageTypeImportance[damageType]) + strength += DamageTypeImportance[damageType] * val / damageTypes.length; else if (debugLevel > 0) API3.warn("Petra: " + str + " unknown attackStrength in getMaxStrength (please add " + str + " to config.js)."); } + // TODO: Capture, Statuseffect. + let attackRange = ent.attackRange(type); if (attackRange) strength += attackRange.max * 0.0125; @@ -156,16 +157,20 @@ PETRA.getSeaAccess(gameState, ent); }; -/** Decide if we should try to capture (returns true) or destroy (return false) */ -PETRA.allowCapture = function(gameState, ent, target) +/** + * Decide which attackEffects we should ignore when choosing the attack type. + */ +PETRA.ignoreAttackEffects = function(gameState, ent, target) { if (!target.isCapturable() || !ent.canCapture(target)) - return false; - if (target.isInvulnerable()) - return true; + return { "Capture": true }; + // always try to recapture capture points from an allied, except if it's decaying if (gameState.isPlayerAlly(target.owner())) - return !target.decaying(); + { + const ignoreCapture = target.decaying(); + return { "Damage": !ignoreCapture, "Capture": ignoreCapture, "ApplyStatus": !ignoreCapture }; + } let antiCapture = target.defaultRegenRate(); if (target.isGarrisonHolder() && target.garrisoned()) @@ -174,44 +179,29 @@ antiCapture -= target.territoryDecayRate(); let capture; - let capturableTargets = gameState.ai.HQ.capturableTargets; + const capturableTargets = gameState.ai.HQ.capturableTargets; if (!capturableTargets.has(target.id())) { - capture = ent.captureStrength() * PETRA.getAttackBonus(ent, target, "Capture"); + capture = ent.captureStrength(target.classes(), target.civ()); capturableTargets.set(target.id(), { "strength": capture, "ents": new Set([ent.id()]) }); } else { - let capturable = capturableTargets.get(target.id()); + const capturable = capturableTargets.get(target.id()); if (!capturable.ents.has(ent.id())) { - capturable.strength += ent.captureStrength() * PETRA.getAttackBonus(ent, target, "Capture"); + capturable.strength += ent.captureStrength(target.classes(), target.civ()); capturable.ents.add(ent.id()); } capture = capturable.strength; } - capture *= 1 / (0.1 + 0.9*target.healthLevel()); - let sumCapturePoints = target.capturePoints().reduce((a, b) => a + b); - if (target.hasDefensiveFire() && target.isGarrisonHolder() && target.garrisoned()) - return capture > antiCapture + sumCapturePoints/50; - return capture > antiCapture + sumCapturePoints/80; -}; + capture *= 1 / (0.1 + 0.9 * target.healthLevel()); + const sumCapturePoints = target.capturePoints().reduce((a, b) => a + b); + const ignoreCapture = target.hasDefensiveFire() && target.isGarrisonHolder() && target.garrisoned() ? + capture < antiCapture + sumCapturePoints / 50 : + capture < antiCapture + sumCapturePoints / 80; -PETRA.getAttackBonus = function(ent, target, type) -{ - let attackBonus = 1; - if (!ent.get("Attack/" + type) || !ent.get("Attack/" + type + "/Bonuses")) - return attackBonus; - let bonuses = ent.get("Attack/" + type + "/Bonuses"); - for (let key in bonuses) - { - let bonus = bonuses[key]; - if (bonus.Civ && bonus.Civ !== target.civ()) - continue; - if (!bonus.Classes || target.hasClasses(bonus.Classes)) - attackBonus *= bonus.Multiplier; - } - return attackBonus; + return { "Damage": !ignoreCapture, "Capture": ignoreCapture, "ApplyStatus": !ignoreCapture }; }; /** Makes the worker deposit the currently carried resources at the closest accessible dropsite */ Index: binaries/data/mods/public/simulation/ai/petra/headquarters.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/headquarters.js +++ binaries/data/mods/public/simulation/ai/petra/headquarters.js @@ -522,8 +522,8 @@ } else if (param[0] == "siegeStrength") { - aValue += PETRA.getMaxStrength(a[1], gameState.ai.Config.debug, gameState.ai.Config.DamageTypeImportance, "Structure") * param[1]; - bValue += PETRA.getMaxStrength(b[1], gameState.ai.Config.debug, gameState.ai.Config.DamageTypeImportance, "Structure") * param[1]; + aValue += PETRA.getMaxStrength(a[1], gameState.ai.Config.debug, gameState.ai.Config.DamageTypeImportance, ["Structure"]) * param[1]; + bValue += PETRA.getMaxStrength(b[1], gameState.ai.Config.debug, gameState.ai.Config.DamageTypeImportance, ["Structure"]) * param[1]; } else if (param[0] == "speed") { @@ -2080,13 +2080,13 @@ continue; if (!this.capturableTargets.has(targetId)) this.capturableTargets.set(targetId, { - "strength": ent.captureStrength() * PETRA.getAttackBonus(ent, target, "Capture"), + "strength": ent.captureStrength(target.classes(), target.civ()), "ents": new Set([ent.id()]) }); else { let capturableTarget = this.capturableTargets.get(target.id()); - capturableTarget.strength += ent.captureStrength() * PETRA.getAttackBonus(ent, target, "Capture"); + capturableTarget.strength += ent.captureStrength(target.classes(), target.civ()); capturableTarget.ents.add(ent.id()); } } @@ -2094,17 +2094,15 @@ for (let [targetId, capturableTarget] of this.capturableTargets) { let target = gameState.getEntityById(targetId); - let allowCapture; for (let entId of capturableTarget.ents) { let ent = gameState.getEntityById(entId); - if (allowCapture === undefined) - allowCapture = PETRA.allowCapture(gameState, ent, target); + const ignoreAttackEffects = PETRA.ignoreAttackEffects(gameState, ent, target); let orderData = ent.unitAIOrderData(); if (!orderData || !orderData.length || !orderData[0].attackType) continue; - if ((orderData[0].attackType == "Capture") !== allowCapture) - ent.attack(targetId, allowCapture); + if (!ent.canAttackTarget(target, false, ignoreAttackEffects, [orderData[0].attackType])) + ent.attack(targetId, ignoreAttackEffects); } } Index: binaries/data/mods/public/simulation/ai/petra/victoryManager.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/victoryManager.js +++ binaries/data/mods/public/simulation/ai/petra/victoryManager.js @@ -659,7 +659,7 @@ if (!attack) continue; for (let ent of attack.unitCollection.values()) - capture += ent.captureStrength() * PETRA.getAttackBonus(ent, relic, "Capture"); + capture += ent.captureStrength(relic.classes(), relic.civ()); } // No need to make a new attack if already enough units if (capture > sumCapturePoints / 50) @@ -689,7 +689,7 @@ let expedition = []; for (let ent of units.values()) { - capture += ent.captureStrength() * PETRA.getAttackBonus(ent, relic, "Capture"); + capture += ent.captureStrength(relic.classes(), relic.civ()); expedition.push(ent); if (capture > sumCapturePoints / 25) break; Index: binaries/data/mods/public/simulation/ai/petra/worker.js =================================================================== --- binaries/data/mods/public/simulation/ai/petra/worker.js +++ binaries/data/mods/public/simulation/ai/petra/worker.js @@ -179,11 +179,11 @@ let orderData = ent.unitAIOrderData()[0]; if (orderData && orderData.target && orderData.attackType && orderData.attackType == "Capture") { - // If we are here, an enemy structure must have targeted one of our workers - // and UnitAI sent it fight back with allowCapture=true + // If we are here, an enemy structure must have targeted one of our workers and + // UnitAI sent it to fight back. Make sure the correct attack values are ignored. let target = gameState.getEntityById(orderData.target); if (target && target.owner() > 0 && !gameState.isPlayerAlly(target.owner())) - ent.attack(orderData.target, PETRA.allowCapture(gameState, ent, target)); + ent.attack(orderData.target, PETRA.ignoreAttackEffects(gameState, ent, target)); } } return; Index: binaries/data/mods/public/simulation/components/Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/Attack.js +++ binaries/data/mods/public/simulation/components/Attack.js @@ -1,7 +1,5 @@ function Attack() {} -var g_AttackTypes = ["Melee", "Ranged", "Capture"]; - Attack.prototype.preferredClassesSchema = "" + "" + @@ -99,7 +97,7 @@ "" + "" + "" + - "" + + "" + "" + "" + "" + @@ -193,11 +191,11 @@ Attack.prototype.GetAttackTypes = function(wantedTypes) { - let types = g_AttackTypes.filter(type => !!this.template[type]); + const types = Object.keys(this.template); if (!wantedTypes) return types; - let wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0); + const wantedTypesReal = wantedTypes.filter(wtype => wtype.indexOf("!") != 0); return types.filter(type => wantedTypes.indexOf("!" + type) == -1 && (!wantedTypesReal || !wantedTypesReal.length || wantedTypesReal.indexOf(type) != -1)); }; @@ -220,66 +218,118 @@ return []; }; -Attack.prototype.CanAttack = function(target, wantedTypes) +/** + * Figure out whether we can attack a given target, with some attack type. + * @param {number} target - The entityID of the target. + * @param {boolean} mustBeInRange - Consider only attack types for which we are currently in range. + * @param {Object} ignoreAttackEffects - Object of the form { "Damage": true, "Capture": false } defining which attackEffects to ignore. + * @param {string[]} wantedTypes - List of (negated) attacktypes to allow. + * @param {string} projectile - Only allow types with(out) projectiles. Use "required" to allow only types with a projectile, use "disallowed" to allow only types without a projectile. + * @return {boolean} - Whether we can attack the target. + */ +Attack.prototype.CanAttack = function(target, mustBeInRange, ignoreAttackEffects, wantedTypes, projectile) +{ + return this.GetAllowedAttackTypes(target, mustBeInRange, ignoreAttackEffects, wantedTypes, projectile).length > 0; +}; + +/** + * Find all attack types we can use to attack the target. + * @param {number} target - The entityID of the target. + * @param {boolean} mustBeInRange - Consider only attack types for which we are currently in range. + * @param {Object} ignoreAttackEffects - Object of the form { "Damage": true, "Capture": false } defining which attackEffects to ignore. + * @param {string[]} preAttackTypes - List of (negated) attacktypes to allow. + * @param {string} projectile - Only allow types with(out) projectiles. Use "required" to allow only types with a projectile, use "disallowed" to allow only types without a projectile. + * @return {string[]} - The list of allowed attack types. + */ +Attack.prototype.GetAllowedAttackTypes = function(target, mustBeInRange, ignoreAttackEffects, wantedTypes, projectile) { const cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) - return true; + { + let types = []; + for (let member of cmpFormation.GetMembers()) + types = types.concat(cmpMemberAttack.GetAllowedAttackTypes(member, mustBeInRange, ignoreAttackEffects, wantedTypes, projectile)); + return types; + } + + const allowedAttackEffects = g_AttackEffects.Codes().filter(attackEffect => !ignoreAttackEffects || !ignoreAttackEffects[attackEffect]); + if (!allowedAttackEffects.length) + return []; + + const types = this.GetAttackTypes(wantedTypes); + if (!types.length) + return []; const cmpThisPosition = Engine.QueryInterface(this.entity, IID_Position); const cmpTargetPosition = Engine.QueryInterface(target, IID_Position); if (!cmpThisPosition || !cmpTargetPosition || !cmpThisPosition.IsInWorld() || !cmpTargetPosition.IsInWorld()) - return false; + return []; const cmpResistance = QueryMiragedInterface(target, IID_Resistance); if (!cmpResistance) - return false; - - const cmpIdentity = QueryMiragedInterface(target, IID_Identity); - if (!cmpIdentity) - return false; - - const cmpHealth = QueryMiragedInterface(target, IID_Health); - const targetClasses = cmpIdentity.GetClassesList(); - if (targetClasses.indexOf("Domestic") != -1 && this.template.Slaughter && cmpHealth && cmpHealth.GetHitpoints() && - (!wantedTypes || !wantedTypes.filter(wType => wType.indexOf("!") != 0).length || wantedTypes.indexOf("Slaughter") != -1)) - return true; + return []; const cmpEntityPlayer = QueryOwnerInterface(this.entity); - const cmpTargetPlayer = QueryOwnerInterface(target); - if (!cmpTargetPlayer || !cmpEntityPlayer) - return false; + const cmpTargetIdentity = QueryMiragedInterface(target, IID_Identity); + if (!cmpEntityPlayer || !cmpTargetIdentity) + return []; - const types = this.GetAttackTypes(wantedTypes); + const targetClasses = cmpTargetIdentity.GetClassesList(); const entityOwner = cmpEntityPlayer.GetPlayerID(); - const targetOwner = cmpTargetPlayer.GetPlayerID(); - const cmpCapturable = QueryMiragedInterface(target, IID_Capturable); - // Check if the relative height difference is larger than the attack range - // If the relative height is bigger, it means they will never be able to - // reach each other, no matter how close they come. - const heightDiff = Math.abs(cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset()); - - for (const type of types) - { - if (type != "Capture" && (!cmpEntityPlayer.IsEnemy(targetOwner) || !cmpHealth || !cmpHealth.GetHitpoints())) - continue; + const cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); + const heightDiff = cmpThisPosition.GetHeightOffset() - cmpTargetPosition.GetHeightOffset(); - if (type == "Capture" && (!cmpCapturable || !cmpCapturable.CanCapture(entityOwner))) - continue; + return types.filter(type => { + if (mustBeInRange) + { + const range = this.GetRange(type); + // Parabolic range compuation is the same as in and UnitAI's' MoveToTargetAttackRange and CheckTargetAttackRange. + // h is positive when I'm higher than the target. + const h = heightDiff + range.elevationBonus; + + // TODO: merge D3249 + // In case the target is too high compared to us, we are out of range. + if (h <= -range.max / 2) + return false; + + if (!cmpObstructionManager.IsInTargetRange( + this.entity, + target, + range.min, + Math.sqrt(Math.square(range.max) + 2 * range.max * h), + false)) + return false; + } - if (heightDiff > this.GetRange(type).max) - continue; + const attackEffects = this.GetAttackEffectsData(type, false); + const attackEffectsSplash = this.GetAttackEffectsData(type, true); - const restrictedClasses = this.GetRestrictedClasses(type); - if (!restrictedClasses.length) - return true; + const bonusMultiplier = attackEffects && attackEffects.Bonuses ? + AttackHelper.GetAttackBonus(this.entity, target, type, attackEffects.Bonuses) : + 1; + const bonusMultiplierSplash = attackEffectsSplash && attackEffectsSplash.Bonuses ? + AttackHelper.GetAttackBonus(this.entity, target, type + "/Splash", attackEffectsSplash.Bonuses) : + 1; + + // We can't use the type if we can't cause any effect. + if (allowedAttackEffects.every(effectType => { + const receiver = g_AttackEffects.GetReceiverFromCode(effectType); + const cmpReceiver = QueryMiragedInterface(target, global[receiver.IID]); + if (!cmpReceiver) + return true; + + return ((!attackEffects[effectType] || !cmpReceiver[receiver.getRelativeEffectMethod](attackEffects, effectType, bonusMultiplier, entityOwner, allowedAttackEffects)) && + (!attackEffectsSplash[effectType] || !cmpReceiver[receiver.getRelativeEffectMethod](attackEffectsSplash, effectType, bonusMultiplierSplash, entityOwner, allowedAttackEffects))); + })) + return false; - if (!MatchesClassList(targetClasses, restrictedClasses)) - return true; - } + if ((projectile == "required" && !this.template[type].Projectile) || (projectile == "disallowed" && this.template[type].Projectile)) + return false; - return false; + const restrictedClasses = this.GetRestrictedClasses(type); + return !restrictedClasses.length || !MatchesClassList(targetClasses, restrictedClasses); + }); }; /** @@ -330,62 +380,173 @@ { let template = this.template[type]; if (splash) + { + if (!template.Splash) + return {}; template = template.Splash; + } return AttackHelper.GetAttackEffectsData("Attack/" + type + (splash ? "/Splash" : ""), template, this.entity); }; /** - * Find the best attack against a target. + * Find the best attack against a target. Using a DPS/range algorithm. * @param {number} target - The entity-ID of the target. - * @param {boolean} allowCapture - Whether capturing is allowed. + * @param {boolean} mustBeInRange - Consider only attack types for which we are currently in range. This will always be honoured. + * @param {Object} ignoreAttackEffects - Object of the form { "Damage": true, "Capture": false } defining which attackEffects to ignore. + * @param {string[]} preAttackTypes - List of (negated) attacktypes to prefer. + * @param {string} projectile - Prefer types with(out) projectiles. Use "required" to prefer types with a projectile, use "disallowed" to prefer types without a projectile. * @return {string} - The preferred attack type. */ -Attack.prototype.GetBestAttackAgainst = function(target, allowCapture) +Attack.prototype.GetBestAttackAgainst = function(target, mustBeInRange, ignoreAttackEffects, prefTypes, projectile) { - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) + // Work out, based on the preferences, which types are potentially possible. + let types = this.GetAllowedAttackTypes(target, mustBeInRange, ignoreAttackEffects, prefTypes, projectile); + if (!types.length && projectile) + types = this.GetAllowedAttackTypes(target, mustBeInRange, ignoreAttackEffects, prefTypes); + if (!types.length && prefTypes && prefTypes.length) + types = this.GetAllowedAttackTypes(target, mustBeInRange, ignoreAttackEffects); + if (!types.length && ignoreAttackEffects && Object.keys(ignoreAttackEffects).filter(effect => ignoreAttackEffects[effect]).length) + types = this.GetAllowedAttackTypes(target, mustBeInRange); + + if (!types.length) + return undefined; + + //if (types.length == 1) + // return types[0]; + + // Work out if there is any preference among the possible types with respect to + // prefTypes and projectile. We already know the given types satisfy the + // relevant mustBeInRange condition and the best possible ignoreAttackEffects. + // So we don't recheck on them to improve performance. + if (prefTypes && prefTypes.length) { - // TODO: Formation against formation needs review - let types = this.GetAttackTypes(); - return g_AttackTypes.find(attack => types.indexOf(attack) != -1); + const types2 = this.GetAllowedAttackTypes(target, false, {}, prefTypes.concat(types)) + if (types2.length) + types = types2; } - let cmpIdentity = Engine.QueryInterface(target, IID_Identity); - if (!cmpIdentity) - return undefined; + //if (types.length == 1) + // return types[0]; - // Always slaughter domestic animals instead of using a normal attack - if (this.template.Slaughter && cmpIdentity.HasClass("Domestic")) - return "Slaughter"; - - let types = this.GetAttackTypes().filter(type => this.CanAttack(target, [type])); - - // Check whether the target is capturable and prefer that when it is allowed. - let captureIndex = types.indexOf("Capture"); - if (captureIndex != -1) - { - if (allowCapture) - return "Capture"; - types.splice(captureIndex, 1); + if (projectile) + { + const types2 = this.GetAllowedAttackTypes(target, false, {}, types, projectile) + if (types2.length) + types = types2; } - let targetClasses = cmpIdentity.GetClassesList(); - let isPreferred = attackType => MatchesClassList(targetClasses, this.GetPreferredClasses(attackType)); + //if (types.length == 1) + // return types[0]; - return types.sort((a, b) => - (types.indexOf(a) + (isPreferred(a) ? types.length : 0)) - - (types.indexOf(b) + (isPreferred(b) ? types.length : 0))).pop(); -}; + const cmpOwnership = Engine.QueryInterface(this.entity, IID_Ownership); + if (!cmpOwnership) + return undefined; + + const distance = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager).DistanceToTarget(this.entity, target); + if (distance < 0) + return undefined; + + const owner = cmpOwnership.GetOwner(); + const fullRange = this.GetFullAttackRange(); + const allAttackEffects = g_AttackEffects.Codes(); + const consideredAttackEffects = allAttackEffects.filter(attackEffect => !ignoreAttackEffects || !ignoreAttackEffects[attackEffect]); + + // Choose the best attack on a DPS/Range. + let bestType; + let bestDPSRange = -Infinity; + let bestAllEffectsDPSRange = -Infinity; + + const cmpFormation = Engine.QueryInterface(target, IID_Formation); + if (cmpFormation) + { + const members = cmpFormation.GetMembers(); + for (const type of types) + { + let DPSRange = 0; + for (const member of members) + DPSRange += this.GetDPSRange(type, member, consideredAttackEffects, owner, distance, fullRange); + + if (DPSRange > bestDPSRange) + { + bestType = type; + bestDPSRange = DPSRange; + bestAllEffectsDPSRange = 0; + for (const member of members) + bestAllEffectsDPSRange += this.GetDPSRange(type, member, allAttackEffects, owner, distance, fullRange) + } + else if (DPSRange == bestDPSRange) + { + let allEffectsDPSRange = 0; + for (const member of members) + allEffectsDPSRange += this.GetDPSRange(type, member, allAttackEffects, owner, distance, fullRange); + if (allEffectsDPSRange > bestAllEffectsDPSRange) + { + bestType = type; + bestAllEffectsDPSRange = allEffectsDPSRange; + } + } + } + return bestType; + } -Attack.prototype.CompareEntitiesByPreference = function(a, b) + for (const type of types) + { + const DPSRange = this.GetDPSRange(type, target, consideredAttackEffects, owner, distance, fullRange); + if (DPSRange > bestDPSRange) + { + bestType = type; + bestDPSRange = DPSRange; + bestAllEffectsDPSRange = this.GetDPSRange(type, target, allAttackEffects, owner, distance, fullRange); + } + else if (DPSRange == bestDPSRange) + { + const allEffectsDPSRange = this.GetDPSRange(type, target, allAttackEffects, owner, distance, fullRange); + if (allEffectsDPSRange > bestAllEffectsDPSRange) + { + bestType = type; + bestAllEffectsDPSRange = allEffectsDPSRange; + } + } + } + return bestType; +}; +/** + * Compute a DPS range. + * @param {string} type - The attack type. + * @param {number} target - Id of the target entity. + * @param {string[]} - Array of the AttackEffects we should consider. + * @param {number} attackerOwner - Owner of this entity + * @param {number} distance - Distance between this.entity and target. + * @param {Object} fullRange - Range object as returned by this.GetFullAttackRange. + * @return {number} - The DPS range value. + */ +Attack.prototype.GetDPSRange = function(type, target, consideredAttackEffects, owner, distance, fullRange) { - let aPreference = this.GetPreference(a); - let bPreference = this.GetPreference(b); + let DPSRange = 0; + const attackEffects = this.GetAttackEffectsData(type, false); + const multiplier = AttackHelper.GetAttackBonus(this.entity, target, type, attackEffects.Bonuses || {}); + for (const effectType of consideredAttackEffects) + { + if (!attackEffects[effectType]) + continue; + const receiver = g_AttackEffects.GetReceiverFromCode(effectType); + const cmpReceiver = QueryMiragedInterface(target, global[receiver.IID]); + if (!cmpReceiver) + continue; - if (aPreference === null && bPreference === null) return 0; - if (aPreference === null) return 1; - if (bPreference === null) return -1; - return aPreference - bPreference; + DPSRange += cmpReceiver[receiver.getRelativeEffectMethod](attackEffects, effectType, multiplier, owner, consideredAttackEffects); + } + DPSRange /= this.GetRepeatTime(type); + + // Apply an exponential dropoff when out of range. + // TODO elevation? + const range = this.GetRange(type); + if (distance < range.min) + DPSRange *= Math.pow(0.2, (range.min - distance) / fullRange.min); + else if (distance > range.max) + DPSRange *= Math.pow(0.2, (distance - range.max) / (fullRange.max || 1)); + + return DPSRange; }; Attack.prototype.GetAttackName = function(type) @@ -398,12 +559,7 @@ Attack.prototype.GetRepeatTime = function(type) { - let repeatTime = 1000; - - if (this.template[type] && this.template[type].RepeatTime) - repeatTime = +this.template[type].RepeatTime; - - return ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", repeatTime, this.entity); + return ApplyValueModificationsToEntity("Attack/" + type + "/RepeatTime", +this.template[type].RepeatTime, this.entity); }; Attack.prototype.GetTimers = function(type) @@ -456,7 +612,10 @@ if (this.target) this.StopAttacking(); - if (!this.CanAttack(target, [type])) + // We should be in range, but requiring that here yields an infinite loop: + // unitAI might keep trying to attack this entity from the idle state. + // TODO: figure our why we are not in range in this case. + if (!this.CanAttack(target, false, {}, [type])) return false; const cmpResistance = QueryMiragedInterface(target, IID_Resistance); @@ -534,7 +693,8 @@ */ Attack.prototype.Attack = function(type, lateness) { - if (!this.CanAttack(this.target, [type])) + // We will check the range after rather than before the attack to facilitate chasing. + if (!this.CanAttack(this.target, false, {}, [type])) { this.StopAttacking("TargetInvalidated"); return; @@ -553,7 +713,6 @@ if (!this.target) return; - // We check the range after the attack to facilitate chasing. if (!this.IsTargetInRange(this.target, type)) { this.StopAttacking("OutOfRange"); @@ -574,9 +733,9 @@ }; /** - * Attack the target entity. This should only be called after a successful range check, - * and should only be called after GetTimers().repeat msec has passed since the last - * call to PerformAttack. + * Attack the target entity. This should only be called after successful range and + * possibility check, and should only be called after GetTimers().repeat msec has + * passed since the last call to PerformAttack. */ Attack.prototype.PerformAttack = function(type, target) { @@ -704,6 +863,7 @@ data.position = targetPosition; data.direction = Vector3D.sub(targetPosition, selfPosition); } + if (delay) { let cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); Index: binaries/data/mods/public/simulation/components/BuildingAI.js =================================================================== --- binaries/data/mods/public/simulation/components/BuildingAI.js +++ binaries/data/mods/public/simulation/components/BuildingAI.js @@ -1,6 +1,5 @@ // Number of rounds of firing per 2 seconds. const roundCount = 10; -const attackType = "Ranged"; function BuildingAI() {} @@ -123,7 +122,7 @@ if (!enemies.length) return; - var range = cmpAttack.GetRange(attackType); + const range = cmpAttack.GetFullAttackRange(); // This takes entity sizes into accounts, so no need to compensate for structure size. this.enemyUnitsQuery = cmpRangeManager.CreateActiveParabolicQuery( this.entity, range.min, range.max, range.elevationBonus, @@ -151,7 +150,7 @@ if (!cmpPlayer || !cmpPlayer.IsEnemy(0)) return; - var range = cmpAttack.GetRange(attackType); + const range = cmpAttack.GetFullAttackRange(); // This query is only interested in Gaia entities that can attack. // This takes entity sizes into accounts, so no need to compensate for structure size. @@ -185,7 +184,7 @@ // Add new targets. for (let entity of msg.added) - if (cmpAttack.CanAttack(entity)) + if (cmpAttack.CanAttack(entity, false)) this.targetUnits.push(entity); // Remove targets outside of vision-range. @@ -210,7 +209,10 @@ return; var cmpTimer = Engine.QueryInterface(SYSTEM_ENTITY, IID_Timer); - var attackTimers = cmpAttack.GetTimers(attackType); + // Take the timer of the first attackType. This is a hack + // TODO: We should properly implement the timer and design what we + // actually want. See #4000. + var attackTimers = cmpAttack.GetTimers(cmpAttack.GetAttackTypes()[0]); this.timer = cmpTimer.SetInterval(this.entity, IID_BuildingAI, "FireArrows", attackTimers.prepare, attackTimers.repeat / roundCount, null); @@ -327,33 +329,17 @@ for (let target of this.targetUnits) addTarget(target); - // The obstruction manager performs approximate range checks. - // so we need to verify them here. - // TODO: perhaps an optional 'precise' mode to range queries would be more performant. - let cmpObstructionManager = Engine.QueryInterface(SYSTEM_ENTITY, IID_ObstructionManager); - let range = cmpAttack.GetRange(attackType); - - let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); - if (!thisCmpPosition.IsInWorld()) - return; - let s = thisCmpPosition.GetPosition(); - let firedArrows = 0; while (firedArrows < arrowsToFire && targets.length()) { let selectedTarget = targets.randomItem(); - - let targetCmpPosition = Engine.QueryInterface(selectedTarget, IID_Position); - if (targetCmpPosition && targetCmpPosition.IsInWorld() && this.CheckTargetVisible(selectedTarget)) + if (this.CheckTargetVisible(selectedTarget)) { - // Parabolic range compuation is the same as in UnitAI's MoveToTargetAttackRange. - // h is positive when I'm higher than the target. - let h = s.y - targetCmpPosition.GetPosition().y + range.elevationBonus; - if (h > -range.max / 2 && cmpObstructionManager.IsInTargetRange( - this.entity, - selectedTarget, - range.min, - Math.sqrt(Math.square(range.max) + 2 * range.max * h), false)) + // The obstruction manager performs approximate range checks. + // so we need to verify them here. Hence the second argument. + // TODO: perhaps an optional 'precise' mode to range queries would be more performant. + const attackType = cmpAttack.GetBestAttackAgainst(selectedTarget, true); + if (attackType) { cmpAttack.PerformAttack(attackType, selectedTarget); PlaySound("attack_" + attackType.toLowerCase(), this.entity); Index: binaries/data/mods/public/simulation/components/Capturable.js =================================================================== --- binaries/data/mods/public/simulation/components/Capturable.js +++ binaries/data/mods/public/simulation/components/Capturable.js @@ -39,6 +39,20 @@ return this.garrisonRegenRate; }; +Capturable.prototype.GetRelativeCapture = function(effectData, effectType, bonusMultiplier, attackerOwner) +{ + return this.CanCapture(attackerOwner) ? + AttackHelper.GetTotalAttackEffects( + this.entity, + effectData, + effectType, + bonusMultiplier, + QueryMiragedInterface(this.entity, IID_Resistance), + true + ) / this.maxCapturePoints : + 0; +}; + /** * Set the new capture points, used for cloning entities. * The caller should assure that the sum of capture points @@ -139,9 +153,13 @@ Capturable.prototype.CanCapture = function(playerID) { let cmpPlayerSource = QueryPlayerIDInterface(playerID); - if (!cmpPlayerSource) warn(playerID + " has no player component defined on its id."); + + let cmpResistance = QueryMiragedInterface(this.entity, IID_Resistance); + if (cmpResistance && cmpResistance.IsInvulnerable()) + return false; + let capturePoints = this.GetCapturePoints(); let sourceEnemyCapturePoints = 0; for (let i in this.GetCapturePoints()) @@ -370,8 +388,9 @@ }; function CapturableMirage() {} -CapturableMirage.prototype.Init = function(cmpCapturable) +CapturableMirage.prototype.Init = function(cmpCapturable, miragedEnt) { + this.entity = miragedEnt; this.capturePoints = clone(cmpCapturable.GetCapturePoints()); this.maxCapturePoints = cmpCapturable.GetMaxCapturePoints(); }; @@ -379,13 +398,14 @@ CapturableMirage.prototype.GetCapturePoints = function() { return this.capturePoints; }; CapturableMirage.prototype.GetMaxCapturePoints = function() { return this.maxCapturePoints; }; CapturableMirage.prototype.CanCapture = Capturable.prototype.CanCapture; +CapturableMirage.prototype.GetRelativeCapture = Capturable.prototype.GetRelativeCapture; Engine.RegisterGlobal("CapturableMirage", CapturableMirage); -Capturable.prototype.Mirage = function() +Capturable.prototype.Mirage = function(miragedEnt) { let mirage = new CapturableMirage(); - mirage.Init(this); + mirage.Init(this, miragedEnt); return mirage; }; Index: binaries/data/mods/public/simulation/components/GuiInterface.js =================================================================== --- binaries/data/mods/public/simulation/components/GuiInterface.js +++ binaries/data/mods/public/simulation/components/GuiInterface.js @@ -479,14 +479,6 @@ ret.attack[type].prepareTime = timers.prepare; ret.attack[type].repeatTime = timers.repeat; - if (type != "Ranged") - { - // Not a ranged attack, set some defaults. - ret.attack[type].elevationBonus = 0; - ret.attack[type].elevationAdaptedRange = ret.attack.maxRange; - continue; - } - ret.attack[type].elevationBonus = range.elevationBonus; if (cmpPosition && cmpPosition.IsInWorld()) @@ -1970,7 +1962,7 @@ GuiInterface.prototype.CanAttack = function(player, data) { let cmpAttack = Engine.QueryInterface(data.entity, IID_Attack); - return cmpAttack && cmpAttack.CanAttack(data.target, data.types || undefined); + return cmpAttack && cmpAttack.CanAttack(data.target, data.mustBeInRange || false, data.ignoreAttackEffects || {}, data.wantedTypes || [], data.projectile || undefined); }; /* Index: binaries/data/mods/public/simulation/components/Health.js =================================================================== --- binaries/data/mods/public/simulation/components/Health.js +++ binaries/data/mods/public/simulation/components/Health.js @@ -79,6 +79,39 @@ }; /** + * @param {Object} effectData - The effects calculate the effect for. + * @param {string} effectType - The type of effect to apply (e.g. Damage, Capture or ApplyStatus). + * @param {number} bonusMultiplier - The factor to multiply the total effect with. + * @param {number} attackerOwner - The player id of the attacker. + * @return {number} - The fraction of the damage when this attack is done with maxHitpoints. + */ +Health.prototype.GetRelativeDamage = function(effectData, effectType, bonusMultiplier, attackerOwner) +{ + + const cmpResistance = QueryMiragedInterface(this.entity, IID_Resistance); + if (cmpResistance && cmpResistance.IsInvulnerable()) + return 0; + + const cmpIdentity = QueryMiragedInterface(this.entity, IID_Identity); + const cmpPlayerEntity = QueryOwnerInterface(this.entity); + const cmpPlayerSource = QueryPlayerIDInterface(attackerOwner); + if (!cmpIdentity || !cmpPlayerEntity || !cmpPlayerSource) + return 0; + + if (this.hitpoints <= 0 || (!cmpPlayerSource.IsEnemy(cmpPlayerEntity.GetPlayerID()) && !cmpIdentity.GetClassesList().includes("Domestic"))) + return 0; + + return AttackHelper.GetTotalAttackEffects( + this.entity, + effectData, + effectType, + bonusMultiplier, + QueryMiragedInterface(this.entity, IID_Resistance), + true + ) / this.maxHitpoints; +}; + +/** * @return {boolean} Whether the units are injured. Dead units are not considered injured. */ Health.prototype.IsInjured = function() @@ -500,26 +533,28 @@ }; function HealthMirage() {} -HealthMirage.prototype.Init = function(cmpHealth) +HealthMirage.prototype.Init = function(cmpHealth, miragedEnt) { + this.entity = miragedEnt; this.maxHitpoints = cmpHealth.GetMaxHitpoints(); this.hitpoints = cmpHealth.GetHitpoints(); this.repairable = cmpHealth.IsRepairable(); this.injured = cmpHealth.IsInjured(); this.unhealable = cmpHealth.IsUnhealable(); }; -HealthMirage.prototype.GetMaxHitpoints = function() { return this.maxHitpoints; }; HealthMirage.prototype.GetHitpoints = function() { return this.hitpoints; }; +HealthMirage.prototype.GetMaxHitpoints = function() { return this.maxHitpoints; }; +HealthMirage.prototype.GetRelativeDamage = Health.prototype.GetRelativeDamage; HealthMirage.prototype.IsRepairable = function() { return this.repairable; }; HealthMirage.prototype.IsInjured = function() { return this.injured; }; HealthMirage.prototype.IsUnhealable = function() { return this.unhealable; }; Engine.RegisterGlobal("HealthMirage", HealthMirage); -Health.prototype.Mirage = function() +Health.prototype.Mirage = function(miragedEnt) { let mirage = new HealthMirage(); - mirage.Init(this); + mirage.Init(this, miragedEnt); return mirage; }; Index: binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js =================================================================== --- binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js +++ binaries/data/mods/public/simulation/components/StatusEffectsReceiver.js @@ -21,6 +21,42 @@ }; /** + * Quantify how much the effect does, relative to the maximum amount. + * For now just check if there is some effect. + */ +StatusEffectsReceiver.prototype.GetRelativeStatusEffect = function(effectData, effectType, bonusMultiplier, attackerOwner, consideredAttackEffects) +{ + const statusEffects = AttackHelper.GetTotalAttackEffects( + this.entity, + effectData, + effectType, + bonusMultiplier, + QueryMiragedInterface(this.entity, IID_Resistance), + true + ); + + let total = 0; + for (const effectTypeOfStatus of consideredAttackEffects) + { + const receiver = g_AttackEffects.GetReceiverFromCode(effectTypeOfStatus); + const cmpReceiver = QueryMiragedInterface(this.entity, global[receiver.IID]); + if (!cmpReceiver) + continue; + + for (const statusName in statusEffects) + { + const status = statusEffects[statusName]; + if (!status[effectTypeOfStatus]) + continue; + + total += cmpReceiver[receiver.getRelativeEffectMethod](status, effectTypeOfStatus, bonusMultiplier, attackerOwner, consideredAttackEffects) * Math.ceil(+status.Duration / +(status.Interval || this.DefaultInterval) + 1); + } + } + + return total; +}; + +/** * Called by Attacking effects. Adds status effects for each entry in the effectData. * * @param {Object} effectData - An object containing the status effects to give to the entity. @@ -167,4 +203,20 @@ this.RemoveStatus(statusCode); }; +function StatusEffectsReceiverMirage() {} +StatusEffectsReceiverMirage.prototype.Init = function(cmpStatusEffectsReceiver) +{ +}; + +StatusEffectsReceiverMirage.prototype.GetRelativeStatusEffect = StatusEffectsReceiver.prototype.GetRelativeStatusEffect; + +Engine.RegisterGlobal("StatusEffectsReceiverMirage", StatusEffectsReceiverMirage); + +StatusEffectsReceiver.prototype.Mirage = function() +{ + const mirage = new StatusEffectsReceiverMirage(); + mirage.Init(this); + return mirage; +}; + Engine.RegisterComponentType(IID_StatusEffectsReceiver, "StatusEffectsReceiver", StatusEffectsReceiver); Index: binaries/data/mods/public/simulation/components/UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/UnitAI.js +++ binaries/data/mods/public/simulation/components/UnitAI.js @@ -397,7 +397,25 @@ }, "Order.Attack": function(msg) { - let type = this.GetBestAttackAgainst(msg.data.target, msg.data.allowCapture); + // We should not attack formations, but their members. However if we try to attack a formation store the formation. + let cmpFormation = Engine.QueryInterface(this.order.data.target, IID_Formation); + if (cmpFormation) + { + this.order.data.formationTarget = this.order.data.target; + this.order.data.target = cmpFormation.GetClosestMember(this.entity, (t) => this.CanAttack( + t, + (this.GetStance().respondStandGround && !this.order.data.force) || !this.AbleToMove(), + this.order.data.ignoreAttackEffects, + this.order.data.prefAttackTypes, + this.order.data.projectile)); + } + + let type = this.GetBestAttackAgainst( + this.order.data.target, + (this.GetStance().respondStandGround && !this.order.data.force) || !this.AbleToMove(), + this.order.data.ignoreAttackEffects, + this.order.data.prefAttackTypes, + this.order.data.projectile); if (!type) return this.FinishOrder(); @@ -497,7 +515,7 @@ if (this.MustKillGatherTarget(msg.data.target)) { // Make sure we can attack the target, else we'll get very stuck - if (!this.GetBestAttackAgainst(msg.data.target, false)) + if (!this.GetBestAttackAgainst(msg.data.target, false, { "Capture": true })) { // Oops, we can't attack at all - give up // TODO: should do something so the player knows why this failed @@ -523,7 +541,14 @@ return ACCEPT_ORDER; } - this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false }); + this.PushOrderFront("Attack", { + "target": this.order.data.target, + "force": !!this.order.data.force, + "hunting": true, + "ignoreAttackEffects": { "Capture": true }, + "prefAttackTypes": [], + "projectile": undefined + }); return ACCEPT_ORDER; } @@ -755,7 +780,6 @@ "Order.Attack": function(msg) { let target = msg.data.target; - let allowCapture = msg.data.allowCapture; let cmpTargetUnitAI = Engine.QueryInterface(target, IID_UnitAI); if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); @@ -769,7 +793,7 @@ } return this.FinishOrder(); } - this.CallMemberFunction("Attack", [target, allowCapture, false]); + this.CallMemberFunction("Attack", [target, msg.data.ignoreAttackEffects, msg.data.prefAttackTypes, msg.data.projectile,, false]); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (cmpAttack && cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); @@ -820,7 +844,16 @@ } return ACCEPT_ORDER; } - this.PushOrderFront("Attack", { "target": msg.data.target, "force": !!msg.data.force, "hunting": true, "allowCapture": false, "min": 0, "max": 10 }); + this.PushOrderFront("Attack", { + "target": msg.data.target, + "force": !!msg.data.force, + "hunting": true, + "ignoreAttackEffects": { "Capture": true }, + "prefAttackTypes": [], + "projectile": undefined, + "min": 0, + "max": 10 + }); return ACCEPT_ORDER; } @@ -1093,7 +1126,9 @@ { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; - this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; + this.patrolStartPosOrder.ignoreAttackEffects = this.order.data.ignoreAttackEffects; + this.patrolStartPosOrder.prefAttackTypes = this.order.data.prefAttackTypes; + this.patrolStartPosOrder.projectile = this.order.data.projectile; } this.SetAnimationVariant("combat"); @@ -1270,7 +1305,7 @@ if (cmpTargetUnitAI && cmpTargetUnitAI.IsFormationMember()) target = cmpTargetUnitAI.GetFormationController(); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - this.CallMemberFunction("Attack", [target, this.order.data.allowCapture, false]); + this.CallMemberFunction("Attack", [target, this.order.data.ignoreAttackEffects, this.order.data.prefAttackTypes, this.order.data.projectile, false]); if (cmpAttack.CanAttackAsFormation()) this.SetNextState("COMBAT.ATTACKING"); else @@ -1282,10 +1317,9 @@ // Wait for individual members to finish "enter": function(msg) { let target = this.order.data.target; - let allowCapture = this.order.data.allowCapture; if (!this.CheckFormationTargetAttackRange(target)) { - if (this.CanAttack(target) && this.CheckTargetVisible(target)) + if (this.CanAttack(target, (this.GetStance().respondStandGround && !this.order.data.force) || !this.AbleToMove()) && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return true; @@ -1304,10 +1338,9 @@ "Timer": function(msg) { let target = this.order.data.target; - let allowCapture = this.order.data.allowCapture; if (!this.CheckFormationTargetAttackRange(target)) { - if (this.CanAttack(target) && this.CheckTargetVisible(target)) + if (this.CanAttack(target, (this.GetStance().respondStandGround && !this.order.data.force) || !this.AbleToMove()) && this.CheckTargetVisible(target)) { this.SetNextState("COMBAT.APPROACHING"); return; @@ -1529,7 +1562,7 @@ } // if we already are targeting another unit still alive, finish with it first if (this.order && (this.order.type == "WalkAndFight" || this.order.type == "Attack")) - if (this.order.data.target != msg.data.attacker && this.CanAttack(msg.data.attacker)) + if (this.order.data.target != msg.data.attacker && this.CanAttack(msg.data.attacker, this.GetStance().respondStandGround || !this.AbleToMove())) return; var cmpIdentity = Engine.QueryInterface(this.entity, IID_Identity); @@ -1552,7 +1585,7 @@ } if (this.CheckTargetVisible(msg.data.attacker)) - this.PushOrderFront("Attack", { "target": msg.data.attacker, "force": false, "allowCapture": true }); + this.PushOrderFront("Attack", { "target": msg.data.attacker, "force": false, "ignoreAttackEffects": {}, "prefAttackTypes": [], "projectile": undefined }); else { var cmpPosition = Engine.QueryInterface(msg.data.attacker, IID_Position); @@ -1804,7 +1837,9 @@ { this.patrolStartPosOrder = cmpPosition.GetPosition(); this.patrolStartPosOrder.targetClasses = this.order.data.targetClasses; - this.patrolStartPosOrder.allowCapture = this.order.data.allowCapture; + this.patrolStartPosOrder.ignoreAttackEffects = this.order.data.ignoreAttackEffects; + this.patrolStartPosOrder.prefAttackTypes = this.order.data.prefAttackTypes; + this.patrolStartPosOrder.projectile = this.order.data.projectile; } this.SetAnimationVariant("combat"); @@ -2026,10 +2061,9 @@ }, "Attacked": function(msg) { - // If we're already in combat mode, ignore anyone else who's attacking us - // unless it's a melee attack since they may be blocking our way to the target - if (msg.data.type == "Melee" && (this.GetStance().targetAttackersAlways || !this.order.data.force)) - this.RespondToTargetedEntities([msg.data.attacker]); + // Switch target to the attacker if that is better target. + if (this.GetStance().targetAttackersAlways || !this.order.data.force) + this.AttackEntitiesByPreference([this.order.data.target, msg.data.attacker]); }, "leave": function() { @@ -2092,13 +2126,11 @@ // under the assumption that this is desirable if the target // was somewhat far away - we'll likely end up closer to where // the player hoped we would. + // Don't set any prefered attack types, let the attack component handle itself. let lastPos = this.order.data.lastPos; this.PushOrder("WalkAndFight", { "x": lastPos.x, "z": lastPos.z, - "force": false, - // Force to true - otherwise structures might be attacked instead of captured, - // which is generally not expected (attacking units usually has allowCapture false). - "allowCapture": true + "force": false }); return; } @@ -2123,13 +2155,6 @@ "ATTACKING": { "enter": function() { let target = this.order.data.target; - let cmpFormation = Engine.QueryInterface(target, IID_Formation); - if (cmpFormation) - { - this.order.data.formationTarget = target; - target = cmpFormation.GetClosestMember(this.entity); - this.order.data.target = target; - } this.shouldCheer = false; @@ -2210,9 +2235,10 @@ }, "Attacked": function(msg) { - if (this.order.data.attackType == "Capture" && (this.GetStance().targetAttackersAlways || !this.order.data.force) && - this.order.data.target != msg.data.attacker && this.GetBestAttackAgainst(msg.data.attacker, true) != "Capture") - this.RespondToTargetedEntities([msg.data.attacker]); + // Switch target to the attacker if that is better target. + if ((this.GetStance().targetAttackersAlways || !this.order.data.force) && + this.order.data.target != msg.data.attacker && this.CanAttack(msg.data.attacker, (this.GetStance().respondStandGround && !this.order.data.force) || !this.AbleToMove())) + this.AttackEntitiesByPreference([this.order.data.target, msg.data.attacker]); }, }, @@ -2234,10 +2260,8 @@ // If the target is a formation, pick closest member. if (cmpFormation) { - let filter = (t) => this.CanAttack(t); this.order.data.formationTarget = this.order.data.target; - let target = cmpFormation.GetClosestMember(this.entity, filter); - this.order.data.target = target; + this.order.data.target = cmpFormation.GetClosestMember(this.entity, (target) => this.CanAttack(target, (this.GetStance().respondStandGround && !this.order.data.force) || !this.AbleToMove()));; this.SetNextState("COMBAT.ATTACKING"); return true; } @@ -3576,6 +3600,12 @@ return (state == "WALKING"); }; +UnitAI.prototype.isAttackingTarget = function(target) +{ + const state = this.GetCurrentState().split(".").pop(); + return state == "COMBAT" && this.order?.data?.target && this.order.data.target == target; +}; + /** * Return true if the current order is WalkAndFight or Patrol. */ @@ -4684,9 +4714,7 @@ }; /** - * Move unit so we hope the target is in the attack range - * for melee attacks, this goes straight to the default range checks - * for ranged attacks, the parabolic range is used + * Move unit so we hope the target is in the attack range. Use parabolic ranges. */ UnitAI.prototype.MoveToTargetAttackRange = function(target, type) { @@ -4704,10 +4732,7 @@ let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); - - if (type != "Ranged") - return this.MoveToTargetRange(target, IID_Attack, type); + target = cmpFormation.GetClosestMember(this.entity, (targ) => this.CanAttack(targ, false, {}, [type])); if (!this.CheckTargetVisible(target)) return false; @@ -4719,22 +4744,19 @@ let thisCmpPosition = Engine.QueryInterface(this.entity, IID_Position); if (!thisCmpPosition.IsInWorld()) return false; - let s = thisCmpPosition.GetPosition(); let targetCmpPosition = Engine.QueryInterface(target, IID_Position); if (!targetCmpPosition || !targetCmpPosition.IsInWorld()) return false; - // Parabolic range compuation is the same as in BuildingAI's FireArrows. - let t = targetCmpPosition.GetPosition(); - // h is positive when I'm higher than the target - let h = s.y - t.y + range.elevationBonus; - - let parabolicMaxRange = Math.sqrt(Math.square(range.max) + 2 * range.max * h); - // No negative roots please - if (h <= -range.max / 2) - // return false? Or hope you come close enough? - parabolicMaxRange = 0; + // Parabolic range compuation is the same as in Attack's GetAllowedAttackTypes and IsInTargetRange. + // TODO: merge D3249 + // h is positive when I'm higher than the target. + let h = thisCmpPosition.GetHeightOffset() - targetCmpPosition.GetHeightOffset() + range.elevationBonus; + + // Target is too high for our range. We can't attack. + // Return false? Or hope you come close enough? + let parabolicMaxRange = h <= -range.max / 2 ? 0 : Math.sqrt(Math.square(range.max) + 2 * range.max * h); // The parabole changes while walking so be cautious: let guessedMaxRange = parabolicMaxRange > range.max ? (range.max + parabolicMaxRange) / 2 : parabolicMaxRange; @@ -4761,7 +4783,7 @@ { let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); if (cmpTargetFormation) - target = cmpTargetFormation.GetClosestMember(this.entity); + target = cmpTargetFormation.GetClosestMember(this.entity, (targ) => this.CanAttack(targ)); if (!this.CheckTargetVisible(target)) return false; @@ -4814,10 +4836,9 @@ }; /** - * Check if the target is inside the attack range - * For melee attacks, this goes straigt to the regular range calculation - * For ranged attacks, the parabolic formula is used to accout for bigger ranges - * when the target is lower, and smaller ranges when the target is higher + * Check if the target is inside the attack range. We account for any + * height difference between the entity and the target. When the target is + * lower the range is bigger, and when the target is higher the range is smaller. */ UnitAI.prototype.CheckTargetAttackRange = function(target, type) { @@ -4832,7 +4853,7 @@ let cmpFormation = Engine.QueryInterface(target, IID_Formation); if (cmpFormation) - target = cmpFormation.GetClosestMember(this.entity); + target = cmpFormation.GetClosestMember(this.entity, (targ) => this.CanAttack(targ, false, {}, [type])); let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); return cmpAttack && cmpAttack.IsTargetInRange(target, type); @@ -4854,7 +4875,7 @@ { let cmpTargetFormation = Engine.QueryInterface(target, IID_Formation); if (cmpTargetFormation) - target = cmpTargetFormation.GetClosestMember(this.entity); + target = cmpTargetFormation.GetClosestMember(this.entity, (targ) => this.CanAttack(targ)); let cmpFormationAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpFormationAttack) @@ -4986,12 +5007,12 @@ return distance < range; }; -UnitAI.prototype.GetBestAttackAgainst = function(target, allowCapture) +UnitAI.prototype.GetBestAttackAgainst = function(target, mustBeInRange, ignoreAttackEffects, prefAttackTypes, projectile) { var cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); if (!cmpAttack) return undefined; - return cmpAttack.GetBestAttackAgainst(target, allowCapture); + return cmpAttack.GetBestAttackAgainst(target, mustBeInRange, ignoreAttackEffects, prefAttackTypes, projectile); }; /** @@ -5001,11 +5022,22 @@ */ UnitAI.prototype.AttackVisibleEntity = function(ents) { - var target = ents.find(target => this.CanAttack(target)); + const target = ents.find(targ => this.CanAttack(targ, this.GetStance().respondStandGround || !this.AbleToMove())); if (!target) return false; - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": true }); + // Don't push a new order if we already are attacking the target. + // TODO: Do allow to change the parameters of the order. + if (this.isAttackingTarget(target)) + return true; + + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "ignoreAttackEffects": {}, + "prefAttackTypes": [], + "projectile": undefined + }); return true; }; @@ -5016,15 +5048,26 @@ */ UnitAI.prototype.AttackEntityInZone = function(ents) { - var target = ents.find(target => - this.CanAttack(target) && - this.CheckTargetDistanceFromHeldPosition(target, IID_Attack, this.GetBestAttackAgainst(target, true)) && + const target = ents.find(target => + this.CanAttack(target, this.GetStance().respondStandGround || !this.AbleToMove()) && + this.CheckTargetDistanceFromHeldPosition(target, IID_Attack, this.GetBestAttackAgainst(target, this.GetStance().respondStandGround || !this.AbleToMove())) && (this.GetStance().respondChaseBeyondVision || this.CheckTargetIsInVisionRange(target)) ); if (!target) return false; - this.PushOrderFront("Attack", { "target": target, "force": false, "allowCapture": true }); + // Don't push a new order if we already are attacking the target. + // TODO: Do allow to change the parameters of the order. + if (this.isAttackIngTarget(target)) + return true; + + this.PushOrderFront("Attack", { + "target": target, + "force": false, + "ignoreAttackEffects": {}, + "prefAttackTypes": [], + "projectile": undefined + }); return true; }; @@ -5455,12 +5498,20 @@ * to a player order, and so is forced. * If targetClasses is given, only entities matching the targetClasses can be attacked. */ -UnitAI.prototype.WalkAndFight = function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false) +UnitAI.prototype.WalkAndFight = function(x, z, targetClasses, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = undefined, queued = false, pushFront = false) { - this.AddOrder("WalkAndFight", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued, pushFront); + this.AddOrder("WalkAndFight", { + "x": x, + "z": z, + "targetClasses": targetClasses, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile, + "force": true + }, queued, pushFront); }; -UnitAI.prototype.Patrol = function(x, z, targetClasses, allowCapture = true, queued = false, pushFront = false) +UnitAI.prototype.Patrol = function(x, z, targetClasses, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = undefined, queued = false, pushFront = false) { if (!this.CanPatrol()) { @@ -5468,7 +5519,15 @@ return; } - this.AddOrder("Patrol", { "x": x, "z": z, "targetClasses": targetClasses, "allowCapture": allowCapture, "force": true }, queued, pushFront); + this.AddOrder("Patrol", { + "x": x, + "z": z, + "targetClasses": targetClasses, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile, + "force": true + }, queued, pushFront); }; /** @@ -5498,8 +5557,10 @@ /** * Adds attack order to the queue, forced by the player. */ -UnitAI.prototype.Attack = function(target, allowCapture = true, queued = false, pushFront = false) +UnitAI.prototype.Attack = function(target, ignoreAttackEffects = {}, prefAttackTypes = [], projectile = undefined, queued = false, pushFront = false) { + // Use ignoreAttackEffects, prefAttackTypes and projectile only for choosing the attackType here. + // We should allow to attack with other types if we can't have the prefered one. if (!this.CanAttack(target)) { // We don't want to let healers walk to the target unit so they can be easily killed. @@ -5514,7 +5575,9 @@ let order = { "target": target, "force": true, - "allowCapture": allowCapture, + "ignoreAttackEffects": ignoreAttackEffects, + "prefAttackTypes": prefAttackTypes, + "projectile": projectile }; this.RememberTargetPosition(order); @@ -5522,7 +5585,9 @@ if (this.order && this.order.type == "Attack" && this.order.data && this.order.data.target === order.target && - this.order.data.allowCapture === order.allowCapture) + JSON.stringify(this.order.data.ignoreAttackEffects) === JSON.stringify(order.ignoreAttackEffects) && + this.order.data.prefAttackTypes.toString() === order.prefAttackTypes.toString() && + this.order.data.projectile === order.projectile) { this.order.data.lastPos = order.lastPos; this.order.data.force = order.force; @@ -6014,7 +6079,7 @@ } let attackfilter = e => { - if (this?.order?.data?.targetClasses) + if (this.order?.data?.targetClasses) { let cmpIdentity = Engine.QueryInterface(e, IID_Identity); let targetClasses = this.order.data.targetClasses; @@ -6041,12 +6106,18 @@ let pref; for (let v of entities) { - if (this.CanAttack(v) && attackfilter(v)) + if (this.CanAttack(v, this.GetStance().respondStandGround || !this.AbleToMove()) && attackfilter(v)) { pref = cmpAttack.GetPreference(v); if (pref === 0) { - this.PushOrderFront("Attack", { "target": v, "force": false, "allowCapture": this?.order?.data?.allowCapture }); + this.PushOrderFront("Attack", { + "target": v, + "force": false, + "ignoreAttackEffects": this.order?.data?.ignoreAttackEffects || {}, + "prefAttackTypes": this.order?.data?.prefAttackTypes || [], + "projectile": this.order?.data?.projectile + }); return true; } targets.push(v); @@ -6060,7 +6131,13 @@ { if (prefs[targ] !== bestPref) continue; - this.PushOrderFront("Attack", { "target": targ, "force": false, "allowCapture": this?.order?.data?.allowCapture }); + this.PushOrderFront("Attack", { + "target": targ, + "force": false, + "ignoreAttackEffects": this.order?.data?.ignoreAttackEffects || {}, + "prefAttackTypes": this.order?.data?.prefAttackTypes || [], + "projectile": this.order?.data?.projectile, + }); return true; } @@ -6227,7 +6304,7 @@ return component.GetRange(type, target); }; -UnitAI.prototype.CanAttack = function(target) +UnitAI.prototype.CanAttack = function(target, mustBeInRange = false, ignoreAttackEffects = {}, wantedAttackTypes = [], projectile = undefined) { // Formation controllers should always respond to commands // (then the individual units can make up their own minds) @@ -6235,7 +6312,7 @@ return true; let cmpAttack = Engine.QueryInterface(this.entity, IID_Attack); - return cmpAttack && cmpAttack.CanAttack(target); + return cmpAttack && cmpAttack.CanAttack(target, mustBeInRange, ignoreAttackEffects, wantedAttackTypes, projectile); }; UnitAI.prototype.CanGarrison = function(target) @@ -6409,8 +6486,10 @@ if (!cmpAttack) return false; + const mustBeInRange = this.GetStance().respondStandGround || !this.AbleToMove(); + let attackfilter = function(e) { - if (!cmpAttack.CanAttack(e)) + if (!cmpAttack.CanAttack(e, mustBeInRange)) return false; let cmpOwnership = Engine.QueryInterface(e, IID_Ownership); Index: binaries/data/mods/public/simulation/components/tests/test_Attack.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Attack.js +++ binaries/data/mods/public/simulation/components/tests/test_Attack.js @@ -1,24 +1,37 @@ AttackEffects = class AttackEffects { - constructor() {} - Receivers() - { - return [{ + constructor() { + this.effectReceivers = [{ "type": "Damage", "IID": "IID_Health", - "method": "TakeDamage" + "method": "TakeDamage", + "getRelativeEffectMethod": "GetRelativeDamage" }, { "type": "Capture", "IID": "IID_Capturable", - "method": "Capture" + "method": "Capture", + "getRelativeEffectMethod": "GetRelativeCapture" }, { "type": "ApplyStatus", "IID": "IID_StatusEffectsReceiver", - "method": "ApplyStatus" + "method": "ApplyStatus", + "getRelativeEffectMethod": "GetRelativeStatusEffect" }]; } + Receivers() + { + return this.effectReceivers; + } + Codes() + { + return ["Damage", "Capture", "ApplyStatus"]; + } + GetReceiverFromCode(type) + { + return this.effectReceivers.find(receiver => receiver.type == type); + } }; Engine.LoadHelperScript("Attack.js"); @@ -30,6 +43,7 @@ Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Resistance.js"); +Engine.LoadComponentScript("interfaces/StatusEffectsReceiver.js"); Engine.LoadComponentScript("interfaces/TechnologyManager.js"); Engine.LoadComponentScript("Attack.js"); @@ -43,15 +57,20 @@ "GetPlayerByID": () => playerEnt1 }); + AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { + "IsInTargetRange": () => true, + "DistanceToTarget": (ent, target) => 10 + }); + AddMock(playerEnt1, IID_Player, { - "GetPlayerID": () => 1, - "IsEnemy": () => isEnemy + "GetPlayerID": () => 1 }); let attacker = entityID; AddMock(attacker, IID_Position, { "IsInWorld": () => true, + "GetTurretParent": () => INVALID_ENTITY, "GetHeightOffset": () => 5, "GetPosition2D": () => new Vector2D(1, 2) }); @@ -61,7 +80,7 @@ }); let cmpAttack = ConstructComponent(attacker, "Attack", { - "Melee": { + "Spear": { "Damage": { "Hack": 11, "Pierce": 5, @@ -69,6 +88,8 @@ }, "MinRange": 3, "MaxRange": 5, + "PrepareTime": 0, + "RepeatTime": 1000, "PreferredClasses": { "_string": "FemaleCitizen" }, @@ -83,7 +104,7 @@ } } }, - "Ranged": { + "Bow": { "Damage": { "Hack": 0, "Pierce": 10, @@ -125,8 +146,22 @@ "Capture": { "Capture": 8, "MaxRange": 10, + "PrepareTime": 0, + "RepeatTime": 1000 + }, + "Slaughter": { + "Damage": { + "Hack": 100, + "Pierce": 0, + "Crush": 0 + }, + "MaxRange": 5, + "PrepareTime": 0, + "RepeatTime": 1000, + "RestrictedClasses": { + "_string": "!Domestic" + } }, - "Slaughter": {}, "StatusEffect": { "ApplyStatus": { "StatusInternalName": { @@ -149,7 +184,12 @@ } }, "MinRange": "10", - "MaxRange": "80" + "MaxRange": "80", + "PrepareTime": 0, + "RepeatTime": 1000, + "RestrictedClasses": { + "_string": "Elephant" + } } }); @@ -171,7 +211,20 @@ }); AddMock(defender, IID_Health, { - "GetHitpoints": () => 100 + "GetHitpoints": () => 100, + "GetRelativeDamage": (attackEffects, effectType) => { + if (!isEnemy && defenderClass != "Domestic") + return 0; + let strength = 0; + if (attackEffects[effectType]) + for (const damageType in attackEffects[effectType]) + strength += attackEffects[effectType][damageType]; + return strength / 100; + } + }); + + AddMock(defender, IID_StatusEffectsReceiver, { + "GetRelativeStatusEffect": () => 0.0000000001 }); AddMock(defender, IID_Resistance, { @@ -182,24 +235,23 @@ // Validate template getter functions attackComponentTest(undefined, true, (attacker, cmpAttack, defender) => { - - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(), ["Melee", "Ranged", "Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes([]), ["Melee", "Ranged", "Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Melee", "Ranged", "Capture"]), ["Melee", "Ranged", "Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Melee", "Ranged"]), ["Melee", "Ranged"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(), ["Spear", "Bow", "Capture", "Slaughter", "StatusEffect"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes([]), ["Spear", "Bow", "Capture", "Slaughter", "StatusEffect"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Spear", "Bow", "Capture"]), ["Spear", "Bow", "Capture"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Spear", "Bow"]), ["Spear", "Bow"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture"]), ["Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Melee", "!Melee"]), []); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["!Melee"]), ["Ranged", "Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["!Melee", "!Ranged"]), ["Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture", "!Ranged"]), ["Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture", "Melee", "!Ranged"]), ["Melee", "Capture"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Spear", "!Spear"]), []); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["!Spear"]), ["Bow", "Capture", "Slaughter", "StatusEffect"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["!Spear", "!Bow"]), ["Capture", "Slaughter", "StatusEffect"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture", "!Bow"]), ["Capture"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackTypes(["Capture", "Spear", "!Bow"]), ["Spear", "Capture"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetPreferredClasses("Melee"), ["FemaleCitizen"]); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRestrictedClasses("Melee"), ["Elephant", "Archer"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetPreferredClasses("Spear"), ["FemaleCitizen"]); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRestrictedClasses("Spear"), ["Elephant", "Archer"]); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetFullAttackRange(), { "min": 0, "max": 80 }); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Capture"), { "Capture": 8 }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged"), { + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Bow"), { "Damage": { "Hack": 0, "Pierce": 10, @@ -207,7 +259,7 @@ } }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Ranged", true), { + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetAttackEffectsData("Bow", true), { "Damage": { "Hack": 0.0, "Pierce": 15.0, @@ -242,13 +294,13 @@ } }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Ranged"), { + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Bow"), { "prepare": 300, "repeat": 500 }); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRepeatTime("Ranged"), 500); + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRepeatTime("Bow"), 500); TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("Capture"), { "prepare": 0, @@ -257,7 +309,14 @@ TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRepeatTime("Capture"), 1000); - TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetSplashData("Ranged"), { + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetTimers("StatusEffect"), { + "prepare": 0, + "repeat": 1000 + }); + + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetRepeatTime("StatusEffect"), 1000); + + TS_ASSERT_UNEVAL_EQUALS(cmpAttack.GetSplashData("Bow"), { "attackData": { "Damage": { "Hack": 0, @@ -280,14 +339,14 @@ for (let className of ["Infantry", "Cavalry"]) attackComponentTest(className, true, (attacker, cmpAttack, defender) => { - TS_ASSERT_EQUALS(cmpAttack.GetAttackEffectsData("Melee").Bonuses.BonusCav.Multiplier, 2); + TS_ASSERT_EQUALS(cmpAttack.GetAttackEffectsData("Spear").Bonuses.BonusCav.Multiplier, 2); TS_ASSERT_EQUALS(cmpAttack.GetAttackEffectsData("Capture").Bonuses || null, null); let getAttackBonus = (s, t, e, splash) => AttackHelper.GetAttackBonus(s, e, t, cmpAttack.GetAttackEffectsData(t, splash).Bonuses || null); - TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Melee", defender), className == "Cavalry" ? 2 : 1); - TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Ranged", defender), 1); - TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Ranged", defender, true), className == "Cavalry" ? 3 : 1); + TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Spear", defender), className == "Cavalry" ? 2 : 1); + TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Bow", defender), 1); + TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Bow", defender, true), className == "Cavalry" ? 3 : 1); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Capture", defender), 1); TS_ASSERT_UNEVAL_EQUALS(getAttackBonus(attacker, "Slaughter", defender), 1); }); @@ -297,7 +356,7 @@ TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), false); }); -function testGetBestAttackAgainst(defenderClass, bestAttack, bestAllyAttack, isBuilding = false) +function testGetBestAttackAgainst(defenderClass, bestAttack, isBuilding = false) { attackComponentTest(defenderClass, true, (attacker, cmpAttack, defender) => { @@ -306,25 +365,47 @@ "CanCapture": playerID => { TS_ASSERT_EQUALS(playerID, 1); return true; - } + }, + "GetRelativeCapture": (attackEffects, effectType) => attackEffects[effectType] / 10 }); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), true); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, []), true); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged"]), true); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Melee"]), true); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Capture"]), isBuilding); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "Capture"]), defenderClass != "Archer"); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged", "Capture"]), true); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Ranged", "!Melee"]), isBuilding || defenderClass == "Domestic"); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "!Melee"]), false); - - let allowCapturing = [true]; - if (!isBuilding) - allowCapturing.push(false); - - for (let ac of allowCapturing) - TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, ac), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, []), true); + + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Bow"]), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!Spear"]), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Capture"]), isBuilding); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }), isBuilding); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }, ["!Capture"]), false); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }, ["Bow"]), false); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }, ["Capture"]), isBuilding); + + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Slaughter"]), defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!Slaughter"]), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Spear", "Capture"]), defenderClass != "Archer"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Bow", "Capture"]), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!Bow", "!Spear", "!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Spear", "!Spear"]), false); + + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, []), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Slaughter"]), bestAttack); + + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!Slaughter"]), bestAttack == "Slaughter" ? "Bow" : bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Bow"]), "Bow"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!Spear"]), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Capture"]), bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Capture": true }), bestAttack == "Capture" ? "Bow" : bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Capture": true }, ["Bow"]), "Bow"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Capture": true }, ["!Capture"]), bestAttack == "Capture" ? "Bow" : bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Capture": true }, ["Capture"]), bestAttack == "Capture" ? "Bow" : bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Spear", "Capture"]), isBuilding ? "Capture" : defenderClass == "Archer" ? "Bow" : "Spear"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Bow", "Capture"]), isBuilding ? "Capture" : "Bow"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!Bow", "!Spear", "!StatusEffect"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : bestAttack); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Spear", "!Spear"]), bestAttack); }); attackComponentTest(defenderClass, false, (attacker, cmpAttack, defender) => { @@ -334,33 +415,62 @@ "CanCapture": playerID => { TS_ASSERT_EQUALS(playerID, 1); return true; - } + }, + "GetRelativeCapture": (attackEffects, effectType) => attackEffects[effectType] / 10 }); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), isBuilding || defenderClass == "Domestic"); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, []), isBuilding || defenderClass == "Domestic"); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged"]), false); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Melee"]), isBuilding || defenderClass == "Domestic"); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Capture"]), isBuilding); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "Capture"]), isBuilding); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Ranged", "Capture"]), isBuilding); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["!Ranged", "!Melee"]), isBuilding || defenderClass == "Domestic"); - TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, ["Melee", "!Melee"]), false); - - let allowCapturing = [true]; - if (!isBuilding) - allowCapturing.push(false); - - for (let ac of allowCapturing) - TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, ac), bestAllyAttack); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}), true); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, []), true); + + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Bow", "!StatusEffect"]), defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!Spear", "!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Capture", "!StatusEffect"]), isBuilding); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Spear", "Capture", "!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Bow", "Capture", "!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Slaughter", "!StatusEffect"]), defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["!Bow", "!Spear", "!StatusEffect"]), isBuilding || defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, {}, ["Spear", "!Spear"]), false); + + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Capture": true }, ["!StatusEffect"]), defenderClass == "Domestic"); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }), isBuilding); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }, ["!Capture"]), false); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }, ["Bow"]), false); + TS_ASSERT_EQUALS(cmpAttack.CanAttack(defender, false, { "Damage": true, "ApplyStatus": true }, ["Capture"]), isBuilding); + + + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, []), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!StatusEffect"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Slaughter"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!Slaughter"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Bow" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Bow"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Bow" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Spear"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Spear" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!Spear"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Capture"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + + // When we have a domestic animal, we will end up doing the first in the array, since we ignore Damage. + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Damage": true, "ApplyStatus": true }), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Damage": true, "ApplyStatus": true }, ["Bow"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Damage": true, "ApplyStatus": true }, ["!Capture"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, { "Damage": true, "ApplyStatus": true }, ["Capture"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Spear", "Capture"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Spear" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Bow", "Capture"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Bow" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["!Bow", "!Spear"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); + TS_ASSERT_EQUALS(cmpAttack.GetBestAttackAgainst(defender, false, {}, ["Spear", "!Spear"]), isBuilding ? "Capture" : defenderClass == "Domestic" ? "Slaughter" : "StatusEffect"); }); } -testGetBestAttackAgainst("FemaleCitizen", "Melee", undefined); -testGetBestAttackAgainst("Archer", "Ranged", undefined); -testGetBestAttackAgainst("Domestic", "Slaughter", "Slaughter"); -testGetBestAttackAgainst("Structure", "Capture", "Capture", true); -testGetBestAttackAgainst("Structure", "Ranged", undefined, false); +testGetBestAttackAgainst("FemaleCitizen", "Bow"); +testGetBestAttackAgainst("Archer", "Bow"); +testGetBestAttackAgainst("Domestic", "Slaughter"); +testGetBestAttackAgainst("Structure", "Capture", true); +testGetBestAttackAgainst("Structure", "Bow", false); function testAttackPreference() Index: binaries/data/mods/public/simulation/components/tests/test_Damage.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_Damage.js +++ binaries/data/mods/public/simulation/components/tests/test_Damage.js @@ -16,6 +16,7 @@ Engine.LoadHelperScript("Position.js"); Engine.LoadHelperScript("ValueModification.js"); Engine.LoadComponentScript("interfaces/DelayedDamage.js"); +Engine.LoadComponentScript("interfaces/Formation.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Loot.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); @@ -33,6 +34,9 @@ let cmpTimer = ConstructComponent(SYSTEM_ENTITY, "Timer"); cmpTimer.OnUpdate({ "turnLength": 1 }); + AddMock(SYSTEM_ENTITY, IID_ObstructionManager, { + "IsInTargetRange": () => true + }); let attacker = 11; let atkPlayerEntity = 1; let attackerOwner = 6; @@ -78,7 +82,8 @@ }; AddMock(atkPlayerEntity, IID_Player, { - "GetEnemies": () => [targetOwner] + "GetEnemies": () => [targetOwner], + "GetPlayerID": () => atkPlayerEntity }); AddMock(SYSTEM_ENTITY, IID_PlayerManager, { @@ -86,6 +91,10 @@ "GetAllPlayers": () => [0, 1, 2, 3, 4] }); + AddMock(target, IID_Identity, { + "GetClassesList": () => [] + }); + AddMock(SYSTEM_ENTITY, IID_ProjectileManager, { "RemoveProjectile": () => {}, "LaunchProjectileAtPoint": (ent, pos, speed, gravity) => {}, @@ -97,6 +106,7 @@ "GetPosition2D": () => Vector2D.From(targetPos), "GetHeightAt": () => 0, "IsInWorld": () => true, + "GetHeightOffset": () => 0 }); AddMock(target, IID_Health, { @@ -104,6 +114,7 @@ damageTaken = true; return { "healthChange": -amount }; }, + "GetRelativeDamage": () => 1 }); AddMock(SYSTEM_ENTITY, IID_DelayedDamage, { @@ -138,6 +149,8 @@ "GetPosition": () => new Vector3D(2, 0, 3), "GetRotation": () => new Vector3D(1, 2, 3), "IsInWorld": () => true, + "GetTurretParent": () => INVALID_ENTITY, + "GetHeightOffset": () => 0 }); function TestDamage() Index: binaries/data/mods/public/simulation/components/tests/test_UnitAI.js =================================================================== --- binaries/data/mods/public/simulation/components/tests/test_UnitAI.js +++ binaries/data/mods/public/simulation/components/tests/test_UnitAI.js @@ -132,6 +132,7 @@ "ResetActiveQuery": function(id) { if (mode == 0) return []; return [enemy]; }, "DisableActiveQuery": function(id) { }, "GetEntityFlagMask": function(identifier) { }, + "GetLosVisibility": function() { return "visible"; } }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { @@ -161,6 +162,7 @@ AddMock(unit, IID_Position, { "GetTurretParent": function() { return INVALID_ENTITY; }, + "GetHeightOffset": function() { return 0; }, "GetPosition": function() { return new Vector3D(); }, "GetPosition2D": function() { return new Vector2D(); }, "GetRotation": function() { return { "y": 0 }; }, @@ -176,6 +178,7 @@ "StopMoving": () => {}, "SetFacePointAfterMove": () => {}, "GetFacePointAfterMove": () => true, + "FaceTowardsPoint": () => {}, "GetPassabilityClassName": () => "default" }); @@ -186,11 +189,10 @@ AddMock(unit, IID_Attack, { "GetRange": function() { return { "max": 10, "min": 0 }; }, "GetFullAttackRange": function() { return { "max": 40, "min": 0 }; }, - "GetBestAttackAgainst": function(t) { return "melee"; }, + "GetBestAttackAgainst": function(t) { return "Melee"; }, "GetPreference": function(t) { return 0; }, "GetTimers": function() { return { "prepare": 500, "repeat": 1000 }; }, "CanAttack": function(v) { return true; }, - "CompareEntitiesByPreference": function(a, b) { return 0; }, "IsTargetInRange": () => true, "StartAttacking": () => true }); @@ -205,6 +207,14 @@ AddMock(enemy, IID_Health, { "GetHitpoints": function() { return 10; }, }); + + AddMock(enemy, IID_Position, { + "GetHeightOffset": function() { return 0; }, + "GetPosition": function() { return new Vector3D(); }, + "GetPosition2D": function() { return new Vector2D(this.x, this.z); }, + "IsInWorld": function() { return true; } + }); + AddMock(enemy, IID_UnitAI, { "IsAnimal": () => "false", "IsDangerousAnimal": () => "false" @@ -234,6 +244,7 @@ AddMock(controller, IID_Position, { "JumpTo": function(x, z) { this.x = x; this.z = z; }, "GetTurretParent": function() { return INVALID_ENTITY; }, + "GetHeightOffset": function() { return 0; }, "GetPosition": function() { return new Vector3D(this.x, 0, this.z); }, "GetPosition2D": function() { return new Vector2D(this.x, this.z); }, "GetRotation": function() { return { "y": 0 }; }, @@ -304,6 +315,7 @@ "ResetActiveQuery": function(id) { return [enemy]; }, "DisableActiveQuery": function(id) { }, "GetEntityFlagMask": function(identifier) { }, + "GetLosVisibility": function() { return "visible"; } }); AddMock(SYSTEM_ENTITY, IID_TemplateManager, { @@ -343,8 +355,10 @@ AddMock(unit + i, IID_Position, { "GetTurretParent": function() { return INVALID_ENTITY; }, + "GetHeightOffset": function() { return 0; }, "GetPosition": function() { return new Vector3D(); }, "GetPosition2D": function() { return new Vector2D(); }, + "TurnTo": function() {}, "GetRotation": function() { return { "y": 0 }; }, "IsInWorld": function() { return true; }, }); @@ -358,6 +372,7 @@ "StopMoving": () => {}, "SetFacePointAfterMove": () => {}, "GetFacePointAfterMove": () => true, + "FaceTowardsPoint": () => {}, "GetPassabilityClassName": () => "default" }); @@ -368,10 +383,9 @@ AddMock(unit + i, IID_Attack, { "GetRange": function() { return { "max": 10, "min": 0 }; }, "GetFullAttackRange": function() { return { "max": 40, "min": 0 }; }, - "GetBestAttackAgainst": function(t) { return "melee"; }, + "GetBestAttackAgainst": function(t) { return "Melee"; }, "GetTimers": function() { return { "prepare": 500, "repeat": 1000 }; }, "CanAttack": function(v) { return true; }, - "CompareEntitiesByPreference": function(a, b) { return 0; }, "IsTargetInRange": () => true, "StartAttacking": () => true, "StopAttacking": () => {} @@ -389,6 +403,13 @@ "GetHitpoints": function() { return 40; }, }); + AddMock(enemy, IID_Position, { + "GetHeightOffset": function() { return 0; }, + "GetPosition": function() { return new Vector3D(); }, + "GetPosition2D": function() { return new Vector2D(); }, + "IsInWorld": function() { return true; } + }); + let controllerFormation = ConstructComponent(controller, "Formation", { "FormationName": "Line Closed", "FormationShape": "square", @@ -435,7 +456,7 @@ controllerFormation.SetMembers(units); - controllerAI.Attack(enemy, []); + controllerAI.Attack(enemy); for (let ent of unitAIs) TS_ASSERT_EQUALS(unitAI.fsmStateName, "INDIVIDUAL.COMBAT.ATTACKING"); Index: binaries/data/mods/public/simulation/data/attack_effects/applystatus.json =================================================================== --- binaries/data/mods/public/simulation/data/attack_effects/applystatus.json +++ binaries/data/mods/public/simulation/data/attack_effects/applystatus.json @@ -1,8 +1,10 @@ { "code": "ApplyStatus", "description": "Various (timed) effects.", + "cmp": "StatusEffectsReceiver", "IID": "IID_StatusEffectsReceiver", "method": "ApplyStatus", + "getRelativeEffectMethod": "GetRelativeStatusEffect", "name": "Apply Status", "order": 3 } Index: binaries/data/mods/public/simulation/data/attack_effects/capture.json =================================================================== --- binaries/data/mods/public/simulation/data/attack_effects/capture.json +++ binaries/data/mods/public/simulation/data/attack_effects/capture.json @@ -1,8 +1,10 @@ { "code": "Capture", "description": "Reduces capture points of a target.", + "cmp": "Capturable", "IID": "IID_Capturable", "method": "Capture", + "getRelativeEffectMethod": "GetRelativeCapture", "name": "Capture", "order": 2 } Index: binaries/data/mods/public/simulation/data/attack_effects/damage.json =================================================================== --- binaries/data/mods/public/simulation/data/attack_effects/damage.json +++ binaries/data/mods/public/simulation/data/attack_effects/damage.json @@ -1,8 +1,10 @@ { "code": "Damage", "description": "Reduces the health of a target.", + "cmp": "Health", "IID": "IID_Health", "method": "TakeDamage", + "getRelativeEffectMethod": "GetRelativeDamage", "name": "Damage", "order": 1 } Index: binaries/data/mods/public/simulation/helpers/Attack.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Attack.js +++ binaries/data/mods/public/simulation/helpers/Attack.js @@ -74,10 +74,10 @@ "" + "" + "" + - "" + + "" + "" + - "" + - "" + + "" + + "" + "" + "" + "" + @@ -161,23 +161,23 @@ * * @return {number} - The total value of the effect. */ -AttackHelper.prototype.GetTotalAttackEffects = function(target, effectData, effectType, bonusMultiplier, cmpResistance) +AttackHelper.prototype.GetTotalAttackEffects = function(target, effectData, effectType, bonusMultiplier, cmpResistance, staticCall) { let total = 0; if (!cmpResistance) - cmpResistance = Engine.QueryInterface(target, IID_Resistance); + cmpResistance = QueryMiragedInterface(target, IID_Resistance); let resistanceStrengths = cmpResistance ? cmpResistance.GetEffectiveResistanceAgainst(effectType) : {}; - if (effectType == "Damage") + if (effectType == "Damage" && effectData.Damage) for (let type in effectData.Damage) total += effectData.Damage[type] * Math.pow(0.9, resistanceStrengths.Damage ? resistanceStrengths.Damage[type] || 0 : 0); - else if (effectType == "Capture") + else if (effectType == "Capture" && effectData.Capture) { total = effectData.Capture * Math.pow(0.9, resistanceStrengths.Capture || 0); // If Health is lower we are more susceptible to capture attacks. - let cmpHealth = Engine.QueryInterface(target, IID_Health); + let cmpHealth = QueryMiragedInterface(target, IID_Health); if (cmpHealth) total /= 0.1 + 0.9 * cmpHealth.GetHitpoints() / cmpHealth.GetMaxHitpoints(); } @@ -196,7 +196,8 @@ continue; } - if (randBool(resistanceStrengths.ApplyStatus[statusEffect].blockChance)) + // When using this function from the GUI we shouldn't call randBool since that will OOS. + if (!staticCall && randBool(resistanceStrengths.ApplyStatus[statusEffect].blockChance)) continue; result[statusEffect] = effectData[effectType][statusEffect]; @@ -318,7 +319,7 @@ if (!cmpReceiver) continue; - Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, data.attackData, receiver.type, bonusMultiplier, cmpResistance), data.attacker, data.attackerOwner)); + Object.assign(targetState, cmpReceiver[receiver.method](this.GetTotalAttackEffects(target, data.attackData, receiver.type, bonusMultiplier, cmpResistance, false), data.attacker, data.attackerOwner)); } if (!Object.keys(targetState).length) Index: binaries/data/mods/public/simulation/helpers/Commands.js =================================================================== --- binaries/data/mods/public/simulation/helpers/Commands.js +++ binaries/data/mods/public/simulation/helpers/Commands.js @@ -183,41 +183,59 @@ "attack-walk": function(player, cmd, data) { - let allowCapture = cmd.allowCapture || cmd.allowCapture == null; - GetFormationUnitAIs(data.entities, player, cmd, data.formation).forEach(cmpUnitAI => { - cmpUnitAI.WalkAndFight(cmd.x, cmd.z, cmd.targetClasses, allowCapture, cmd.queued, cmd.pushFront); + cmpUnitAI.WalkAndFight( + cmd.x, + cmd.z, + cmd.targetClasses, + cmd.ignoreAttackEffects || {}, + cmd.prefAttackTypes || [], + cmd.projectile || undefined, + cmd.queued, + cmd.pushFront); }); }, "attack-walk-custom": function(player, cmd, data) { - let allowCapture = cmd.allowCapture || cmd.allowCapture == null; - for (let ent in data.entities) + for (const ent in data.entities) GetFormationUnitAIs([data.entities[ent]], player, cmd, data.formation).forEach(cmpUnitAI => { - cmpUnitAI.WalkAndFight(cmd.targetPositions[ent].x, cmd.targetPositions[ent].y, cmd.targetClasses, allowCapture, cmd.queued, cmd.pushFront); + cmpUnitAI.WalkAndFight( + cmd.targetPositions[ent].x, + cmd.targetPositions[ent].y, + cmd.targetClasses, + cmd.ignoreAttackEffects || {}, + cmd.prefAttackTypes || [], + cmd.projectile || undefined, + cmd.queued, + cmd.pushFront); }); }, "attack": function(player, cmd, data) { - let allowCapture = cmd.allowCapture || cmd.allowCapture == null; - - if (g_DebugCommands && !allowCapture && - !(IsOwnedByEnemyOfPlayer(player, cmd.target) || IsOwnedByNeutralOfPlayer(player, cmd.target))) - warn("Invalid command: attack target is not owned by enemy of player "+player+": "+uneval(cmd)); - GetFormationUnitAIs(data.entities, player, cmd, data.formation).forEach(cmpUnitAI => { - cmpUnitAI.Attack(cmd.target, allowCapture, cmd.queued, cmd.pushFront); + cmpUnitAI.Attack( + cmd.target, + cmd.ignoreAttackEffects || {}, + cmd.prefAttackTypes || [], + cmd.projectile || undefined, + cmd.queued, + cmd.pushFront); }); }, "patrol": function(player, cmd, data) { - let allowCapture = cmd.allowCapture || cmd.allowCapture == null; - GetFormationUnitAIs(data.entities, player, cmd, data.formation).forEach(cmpUnitAI => - cmpUnitAI.Patrol(cmd.x, cmd.z, cmd.targetClasses, allowCapture, cmd.queued) + cmpUnitAI.Patrol( + cmd.x, + cmd.z, + cmd.targetClasses, + cmd.ignoreAttackEffects || {}, + cmd.prefAttackTypes || [], + cmd.projectile || undefined, + cmd.queued) ); }, @@ -289,13 +307,29 @@ const target = cmd.target; if (cmd.pushFront) { - cmpUnitAI.WalkAndFight(target.x, target.z, cmd.targetClasses, cmd.allowCapture, false, cmd.pushFront); + cmpUnitAI.WalkAndFight( + target.x, + target.z, + cmd.targetClasses, + cmd.ignoreAttackEffects || {}, + cmd.prefAttackTypes || [], + cmd.projectile || undefined, + false, + cmd.pushFront); cmpUnitAI.DropAtNearestDropSite(false, cmd.pushFront); } else { cmpUnitAI.DropAtNearestDropSite(cmd.queued, false) - cmpUnitAI.WalkAndFight(target.x, target.z, cmd.targetClasses, cmd.allowCapture, true, false); + cmpUnitAI.WalkAndFight( + target.x, + target.z, + cmd.targetClasses, + cmd.ignoreAttackEffects || {}, + cmd.prefAttackTypes || [], + cmd.projectile || undefined, + true, + false); } }); }, Index: binaries/data/mods/public/simulation/helpers/tests/test_Attack.js =================================================================== --- binaries/data/mods/public/simulation/helpers/tests/test_Attack.js +++ binaries/data/mods/public/simulation/helpers/tests/test_Attack.js @@ -22,6 +22,7 @@ }; Engine.LoadHelperScript("Attack.js"); +Engine.LoadHelperScript("Player.js"); Engine.LoadComponentScript("interfaces/Capturable.js"); Engine.LoadComponentScript("interfaces/Health.js"); Engine.LoadComponentScript("interfaces/Promotion.js"); Index: binaries/data/mods/public/simulation/templates/units/pers/champion_infantry.xml =================================================================== --- binaries/data/mods/public/simulation/templates/units/pers/champion_infantry.xml +++ binaries/data/mods/public/simulation/templates/units/pers/champion_infantry.xml @@ -1,5 +1,26 @@ + + + Bow + + 0 + 6.0 + 0 + + 72.0 + 0.0 + 600 + 1000 + + 75.0 + 3.0 + false + 9.81 + + + + pers persian